Files
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

138 lines
5.0 KiB
TypeScript

/**
* Dependency graph over *all* sheets.
*
* Nodes are cell keys (see `keyOf` in ./types). An edge P -> C means
* "cell C reads cell P" (P is a precedent of C, C is a dependent of P).
*
* The graph offers two things the recalculation driver needs:
* 1. `affected(dirty)` -- every cell that must be revisited after `dirty`
* changed (the dirty cells plus their transitive dependents).
* 2. `topoOrder(nodes)` -- a safe evaluation order for a set of nodes, plus
* the subset that sits on a dependency cycle (`#CIRC`).
*
* Circular references -- including cross-sheet ones -- are found by an iterative
* DFS with white/grey/black colouring. When a back-edge to a grey node is seen,
* every node currently on the DFS stack from that node onwards is flagged
* circular. Flagged nodes are dropped from the returned order; the driver writes
* `#CIRC` into them directly and their dependents pick that up through normal
* error propagation.
*/
export class DependencyGraph {
/** precedents: key -> set of cells it reads */
private readonly precedents = new Map<string, Set<string>>();
/** dependents: key -> set of cells that read it */
private readonly dependents = new Map<string, Set<string>>();
/** Replace the precedent set of `cell` (called after (re)parsing a formula). */
setPrecedents(cell: string, precedents: Iterable<string>): void {
// unlink old edges
const old = this.precedents.get(cell);
if (old) {
for (const p of old) this.dependents.get(p)?.delete(cell);
}
// Keep a self-reference if present: `=A1` inside A1 must surface as #CIRC.
const next = new Set(precedents);
this.precedents.set(cell, next);
for (const p of next) {
let d = this.dependents.get(p);
if (!d) this.dependents.set(p, (d = new Set()));
d.add(cell);
}
}
/** Forget a cell entirely (e.g. it became a literal or was cleared). */
clear(cell: string): void {
const old = this.precedents.get(cell);
if (old) for (const p of old) this.dependents.get(p)?.delete(cell);
this.precedents.delete(cell);
// keep `dependents.get(cell)` -- other cells may still point here (=> #REF/0),
// that entry is harmless and gets cleaned as those cells are re-parsed.
}
/** Drop every edge (used on structural rebuilds). */
reset(): void {
this.precedents.clear();
this.dependents.clear();
}
getPrecedents(cell: string): Set<string> {
return this.precedents.get(cell) ?? new Set();
}
getDependents(cell: string): Set<string> {
return this.dependents.get(cell) ?? new Set();
}
/** Dirty cells + all their transitive dependents. */
affected(dirty: Iterable<string>): Set<string> {
const seen = new Set<string>();
const stack = [...dirty];
while (stack.length) {
const k = stack.pop()!;
if (seen.has(k)) continue;
seen.add(k);
const deps = this.dependents.get(k);
if (deps) for (const d of deps) if (!seen.has(d)) stack.push(d);
}
return seen;
}
/**
* Topologically sort `nodes` using precedent edges that stay inside `nodes`.
* Returns the evaluation order (precedents first) and the set of nodes that
* lie on a cycle.
*/
topoOrder(nodes: Set<string>): { order: string[]; circular: Set<string> } {
const WHITE = 0;
const GREY = 1;
const BLACK = 2;
const color = new Map<string, number>();
const order: string[] = [];
const circular = new Set<string>();
for (const n of nodes) color.set(n, WHITE);
for (const root of nodes) {
if (color.get(root) !== WHITE) continue;
// iterative DFS; frame = { node, iterator over its in-scope precedents }
const stack: Array<{ node: string; iter: Iterator<string> }> = [];
const pathIndex = new Map<string, number>(); // node -> position on stack
const push = (node: string) => {
color.set(node, GREY);
pathIndex.set(node, stack.length);
const precs = [...this.getPrecedents(node)].filter((p) => nodes.has(p));
stack.push({ node, iter: precs[Symbol.iterator]() });
};
push(root);
while (stack.length) {
const frame = stack[stack.length - 1];
const step = frame.iter.next();
if (step.done) {
color.set(frame.node, BLACK);
pathIndex.delete(frame.node);
order.push(frame.node);
stack.pop();
continue;
}
const p = step.value;
const c = color.get(p);
if (c === WHITE) {
push(p);
} else if (c === GREY) {
// back-edge -> cycle. Flag everything from p to the current frame.
const from = pathIndex.get(p)!;
for (let k = from; k < stack.length; k++) circular.add(stack[k].node);
}
// BLACK -> already settled, ignore
}
}
// A node that depends on a circular node is not itself circular, but it must
// be evaluated *after* the driver writes #CIRC. Keeping it in `order` is
// fine; drop only the truly circular nodes.
return { order: order.filter((n) => !circular.has(n)), circular };
}
}