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>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
@@ -0,0 +1,491 @@
import { describe, it, expect } from 'vitest';
import {
parseFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
collectRefsInBlock,
ParseError,
type Node,
type Statement,
type BlockNode,
} from '../parser';
/** Compact s-expression rendering of an AST for precedence assertions. */
function sexpr(n: Node): string {
switch (n.kind) {
case 'number':
return String(n.value);
case 'string':
return JSON.stringify(n.value);
case 'errorliteral':
return n.code;
case 'unary':
return `(${n.op} ${sexpr(n.operand)})`;
case 'binary':
return `(${n.op} ${sexpr(n.left)} ${sexpr(n.right)})`;
case 'func':
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
case 'cell':
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
case 'range':
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param':
return `param(${n.name})`;
case 'namedref':
return `namedref(${n.name})`;
case 'blockcall':
// never appears in freshly-parsed input (see parser.ts's comment on
// BlockCallNode) -- exhaustiveness only, no test exercises this.
return `blockcall(${n.label})`;
case 'omitted':
return 'omitted';
case 'json':
// never produced by the parser (see parser.ts's comment on
// JsonLiteralNode) -- exhaustiveness only, no test exercises this.
return `json(${JSON.stringify(n.value)})`;
}
}
const parse = (s: string) => sexpr(parseFormula(s));
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
function sexprStmt(s: Statement): string {
switch (s.kind) {
case 'assign':
return `${s.name}=${sexpr(s.value)}`;
case 'return':
return `RETURN ${sexpr(s.value)}`;
case 'for':
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
case 'ifstmt':
return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`;
case 'action':
return `ACTION(${s.name};${s.args.map(sexpr).join(';')})`;
}
}
function sexprBlock(b: BlockNode): string {
return `{${b.statements.map(sexprStmt).join('; ')}}`;
}
const parseBlock = (s: string, paramNames: readonly string[]) =>
sexprBlock(parseFunctionBlock(s, paramNames));
describe('parser precedence & associativity', () => {
it('* / bind tighter than + -', () => {
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
expect(parse('=1-2/2')).toBe('(- 1 (/ 2 2))');
});
it('+ - are left-associative', () => {
expect(parse('=1-2-3')).toBe('(- (- 1 2) 3)');
});
it('^ binds tighter than * / and is right-associative', () => {
expect(parse('=2*3^2')).toBe('(* 2 (^ 3 2))');
expect(parse('=2^3^2')).toBe('(^ 2 (^ 3 2))');
});
it('unary minus binds weaker than ^ (-2^2 = -(2^2))', () => {
expect(parse('=-2^2')).toBe('(- (^ 2 2))');
expect(parse('=2^-3')).toBe('(^ 2 (- 3))');
});
it('parentheses override precedence', () => {
expect(parse('=(1+2)*3')).toBe('(* (+ 1 2) 3)');
expect(parse('=(B2+C2)*2-D2/4')).toBe(
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
);
});
it('& binds looser than + - but tighter than a comparison', () => {
expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))');
expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")');
});
it('& is left-associative', () => {
expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")');
});
});
describe('parser references', () => {
it('parses local cells and ranges', () => {
expect(parse('=A1')).toBe('cell(0,0)');
expect(parse('=A1:C5')).toBe('range(0,0:2,4)');
});
it('parses $-locked (absolute/mixed) references to the same coords -- evaluation is positional', () => {
expect(parse('=$A$1')).toBe('cell(0,0)');
expect(parse('=A$1')).toBe('cell(0,0)');
expect(parse('=$A1')).toBe('cell(0,0)');
expect(parse('=$A$1:B$2')).toBe('range(0,0:1,1)');
});
it('parses table-qualified refs, bare and quoted', () => {
expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)');
expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
});
it('accepts the Numbers-style "::" qualifier interchangeably with "!"', () => {
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
expect(parse("='Kosten Q3'::B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
expect(parse('=Umsatz::B2')).toBe(parse('=Umsatz!B2'));
});
it('parses function calls with ; separator and mixed args', () => {
expect(parse('=SUM(Umsatz!B2:E2; 100; A1)')).toBe(
'(SUM Umsatz!range(1,1:4,1) 100 cell(0,0))',
);
});
it('collectRefs finds every reference', () => {
const refs = collectRefs(parseFormula('=SUM(A1:A3; Kosten!B2) + C4'));
expect(refs).toHaveLength(3);
});
it('an empty argument slot parses as "omitted" -- leading, middle, trailing', () => {
expect(parse('=XLOOKUP(1;A1;B1;;-1)')).toBe(
'(XLOOKUP 1 cell(0,0) cell(1,0) omitted (- 1))',
);
expect(parse('=SUM(;5)')).toBe('(SUM omitted 5)');
expect(parse('=SUM(5;)')).toBe('(SUM 5 omitted)');
expect(parse('=SUM()')).toBe('(SUM )');
});
});
describe('parser whole-column / whole-row references', () => {
const range = (src: string) => parseFormula(src) as Extract<Node, { kind: 'range' }>;
it('B:B, A:C and $B:$B are whole columns', () => {
expect(range('B:B')).toMatchObject({ kind: 'range', whole: 'col', qualifier: null, start: { col: 1, row: 0 } });
expect(range('A:C')).toMatchObject({ whole: 'col', start: { col: 0 }, end: { col: 2 } });
expect(range('$B:$B')).toMatchObject({ whole: 'col', start: { col: 1 }, end: { col: 1 } });
expect(range('aa:ab')).toMatchObject({ whole: 'col', start: { col: 26 }, end: { col: 27 } });
});
it('1:1, 2:5 and $3:$3 are whole rows (1-based in the text)', () => {
expect(range('1:1')).toMatchObject({ kind: 'range', whole: 'row', start: { col: 0, row: 0 }, end: { row: 0 } });
expect(range('2:5')).toMatchObject({ whole: 'row', start: { row: 1 }, end: { row: 4 } });
expect(range('$3:$3')).toMatchObject({ whole: 'row', start: { row: 2 }, end: { row: 2 } });
});
it('take table and sheet qualifiers', () => {
expect(range('Umsatz!B:B')).toMatchObject({ qualifier: 'Umsatz', whole: 'col' });
expect(range('Umsatz::1:1')).toMatchObject({ qualifier: 'Umsatz', whole: 'row' });
expect(range("'Blatt 2'!Kosten::A:C")).toMatchObject({ sheetQualifier: 'Blatt 2', qualifier: 'Kosten', whole: 'col' });
});
it('work as function arguments and are collected as refs', () => {
const ast = parseFormula('SUM(B:B; 2:3) + 1');
expect(collectRefs(ast).map((r) => (r.kind === 'range' ? r.whole : r.kind))).toEqual(['col', 'row']);
});
it('reject mixed or invalid shapes', () => {
expect(() => parseFormula('A:1')).toThrow();
expect(() => parseFormula('0:0')).toThrow();
// a plain number still is one
expect(parseFormula('12')).toEqual({ kind: 'number', value: 12 });
});
});
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
it('parses the explicit 3-part form, bare and quoted', () => {
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
expect(parse("='Meine Analyse'!Umsatz::B2")).toBe('Meine Analyse!Umsatz!cell(1,1)');
expect(parse("=Analyse!'Kosten Q3'::B2:E2")).toBe('Analyse!Kosten Q3!range(1,1:4,1)');
expect(parse("='Meine Analyse'!'Kosten Q3'::B2")).toBe('Meine Analyse!Kosten Q3!cell(1,1)');
});
it('is unaffected in a range end / function call', () => {
expect(parse('=SUM(Analyse!Umsatz::B2:E2; 1)')).toBe('(SUM Analyse!Umsatz!range(1,1:4,1) 1)');
});
it('a bare "X!Y" (no further "::") keeps its original, table-only meaning -- fully backward-compatible', () => {
// no "::" ever follows -> "Analyse" is read as the TABLE, "A1" the cell,
// exactly as parseFormula() behaved before the sheet qualifier existed.
expect(parse('=Analyse!A1')).toBe('Analyse!cell(0,0)');
expect(parse('=Umsatz!B2')).toBe(parse('=Umsatz!B2')); // unchanged from the existing suite
});
it('"X::Y" (no leading sheet) is still the plain table qualifier, never a sheet one', () => {
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
});
it('"X!Y!Z" (a second "!" instead of "::") does not form a sheet qualifier -- "!" only introduces a sheet name when followed by NAME "::"', () => {
// no "::" anywhere ahead of "Y" -> "X!" is read as an ordinary (old-style)
// table qualifier, leaving "Y" to be parsed as the cell -- which it isn't
// shaped like (no digits) -> ParseError, same as e.g. "=X!Y" always was.
expect(() => parseFormula('=X!Y!Z1')).toThrow(ParseError);
});
it('rejects a malformed 3-part reference (missing cell after "::")', () => {
expect(() => parseFormula('=Analyse!Umsatz::')).toThrow(ParseError);
});
});
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
it('a name in paramNames parses as a param reference, case-insensitively', () => {
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
expect(parse2('=REVENUE * 2', ['revenue'])).toBe('(* param(REVENUE) 2)');
});
it('a bare name not in paramNames still behaves exactly as without the list', () => {
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
});
it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => {
// same string, no param list -- "revenue" doesn't look like a cell, but
// this is now an ordinary top-level formula, where that's a named-range
// candidate (see the "named ranges" describe block below) rather than a
// ParseError -- unlike inside a function body (the case just above),
// where the fallback is deliberately off.
expect(parse('=revenue')).toBe('namedref(revenue)');
});
it('a param can mix with real cell references in the same formula', () => {
expect(parse2('=x + A1', ['x'])).toBe('(+ param(x) cell(0,0))');
});
it('a param is never qualified or part of a range -- those still need a real cell', () => {
expect(() => parseFormula('=Umsatz!x', ['x'])).toThrow(ParseError);
expect(() => parseFormula('=x:B2', ['x'])).toThrow(ParseError);
});
});
describe('parser errors', () => {
it('throws ParseError on a dangling operator', () => {
expect(() => parseFormula('=A1+')).toThrow(ParseError);
});
it('throws on an unbalanced paren', () => {
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
});
});
describe('parser named-range references (NamedRefNode)', () => {
it('a bare, unqualified, non-cell identifier parses as a namedref', () => {
expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)');
expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)');
});
it('mixes with real cell/table references and functions', () => {
expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe(
'(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))',
);
});
it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => {
expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError);
expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError);
});
it('never a range-start: "Name:B2" is not supported', () => {
expect(() => parseFormula('=foo:B2')).toThrow(ParseError);
});
it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => {
expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError);
expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError);
});
});
describe('parser error literals (ErrorLiteralNode)', () => {
it('every error code parses as a leaf', () => {
expect(parse('=#REF')).toBe('#REF');
expect(parse('=#DIV/0!')).toBe('#DIV/0!');
expect(parse('=#NAME?')).toBe('#NAME?');
expect(parse('=#ERR')).toBe('#ERR');
expect(parse('=#CIRC')).toBe('#CIRC');
expect(parse('=#VALUE!')).toBe('#VALUE!');
expect(parse('=#N/A')).toBe('#N/A');
});
it('composes with operators/functions like any other literal', () => {
expect(parse('=1+#REF')).toBe('(+ 1 #REF)');
expect(parse('=IF(1=0;#N/A;5)')).toBe('(IF (= 1 0) #N/A 5)');
});
});
describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
it('parses all six comparison operators', () => {
expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))');
expect(parse('=A1<>B1')).toBe('(<> cell(0,0) cell(1,0))');
expect(parse('=A1>B1')).toBe('(> cell(0,0) cell(1,0))');
expect(parse('=A1<B1')).toBe('(< cell(0,0) cell(1,0))');
expect(parse('=A1>=B1')).toBe('(>= cell(0,0) cell(1,0))');
expect(parse('=A1<=B1')).toBe('(<= cell(0,0) cell(1,0))');
});
it('binds looser than + -', () => {
expect(parse('=A1+1=B1')).toBe('(= (+ cell(0,0) 1) cell(1,0))');
});
it('is not chainable -- "a < b < c" is a parse error', () => {
expect(() => parseFormula('=1<2<3')).toThrow(ParseError);
});
});
describe('parseFunctionBlock -- statement grammar for "block" function bodies', () => {
it('a single FOR loop, accumulator pattern (DO ... END, one statement)', () => {
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
expect(parseBlock(src, ['bereich'])).toBe(
'{ergebnis=1; FOR(x IN param(bereich) DO {ergebnis=(* param(ergebnis) param(x))}); RETURN param(ergebnis)}',
);
});
it('a FOR body with multiple statements', () => {
const src = 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total';
expect(parseBlock(src, ['bereich'])).toBe(
'{total=0; FOR(x IN param(bereich) DO {y=(* param(x) 2); total=(+ param(total) param(y))}); RETURN param(total)}',
);
});
it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
const src =
'total = 0 FOR a IN r1 DO total = total + a END FOR b IN r2 DO total = total + b END RETURN total';
expect(parseBlock(src, ['r1', 'r2'])).toBe(
'{total=0; FOR(a IN param(r1) DO {total=(+ param(total) param(a))}); ' +
'FOR(b IN param(r2) DO {total=(+ param(total) param(b))}); RETURN param(total)}',
);
});
it('IF ... THEN ... END, no ELSE', () => {
const src = 'x = 1 IF x > 0 THEN x = x + 1 END RETURN x';
expect(parseBlock(src, [])).toBe('{x=1; IF((> param(x) 0) THEN {x=(+ param(x) 1)}); RETURN param(x)}');
});
it('IF ... THEN ... ELSE ... END', () => {
const src = 'IF x > 0 THEN y = 1 ELSE y = -1 END RETURN y';
expect(parseBlock(src, ['x'])).toBe(
'{IF((> param(x) 0) THEN {y=1} ELSE {y=(- 1)}); RETURN param(y)}',
);
});
it('IF nested inside a FOR body, with an early RETURN -- "if kann in jedem block vorkommen"', () => {
const src =
'total = 0 FOR x IN bereich DO IF x > 100 THEN RETURN x END total = total + x END RETURN total';
expect(parseBlock(src, ['bereich'])).toBe(
'{total=0; FOR(x IN param(bereich) DO {IF((> param(x) 100) THEN {RETURN param(x)}); ' +
'total=(+ param(total) param(x))}); RETURN param(total)}',
);
});
it('a FOR loop nested inside another FOR loop\'s body', () => {
const src = 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total';
expect(parseBlock(src, ['r1', 'r2'])).toBe(
'{total=0; FOR(a IN param(r1) DO {FOR(b IN param(r2) DO ' +
'{total=(+ param(total) (* param(a) param(b)))})}); RETURN param(total)}',
);
});
it('RETURN may appear anywhere, not just at the end, and may be any expression', () => {
expect(parseBlock('RETURN 42', [])).toBe('{RETURN 42}');
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5; RETURN (* param(x) 2)}');
});
it('a plain assignment can reference an already-declared variable', () => {
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}');
});
it('the FOR source may itself be a cell/range reference, not just a parameter', () => {
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x END RETURN total', [])).toBe(
'{total=0; FOR(x IN range(0,0:0,4) DO {total=(+ param(total) param(x))}); RETURN param(total)}',
);
});
it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
expect(parseBlock('x = 1', [])).toBe('{x=1}');
});
it('throws on FOR without a matching END', () => {
expect(() => parseFunctionBlock('FOR x IN bereich DO y = x RETURN y', ['bereich'])).toThrow(
ParseError,
);
});
it('throws on FOR without a matching DO', () => {
expect(() => parseFunctionBlock('FOR x IN bereich RETURN x', ['bereich'])).toThrow(ParseError);
});
it('throws on FOR without IN', () => {
expect(() => parseFunctionBlock('FOR x bereich DO y = x END RETURN y', ['bereich'])).toThrow(
ParseError,
);
});
it('throws on IF without THEN', () => {
expect(() => parseFunctionBlock('IF x > 0 x = 1 END RETURN x', ['x'])).toThrow(ParseError);
});
it('throws on IF without a matching END', () => {
expect(() => parseFunctionBlock('IF x > 0 THEN x = 1 RETURN x', ['x'])).toThrow(ParseError);
});
it('throws when an assignment is missing "="', () => {
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
});
it('a name is only usable after its own declaring statement (self-reference in its own initializer fails)', () => {
expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError);
});
});
describe('parseMacroBlock -- write-action statements (ActionNode), macro-only', () => {
const parseMacro = (s: string) => sexprBlock(parseMacroBlock(s));
it('a bare NAME(args) at statement position parses as an action call', () => {
expect(parseMacro('SETVALUE(Umsatz::A1; 42)')).toBe(
'{ACTION(SETVALUE;Umsatz!cell(0,0);42)}',
);
});
it('an action with no arguments', () => {
expect(parseMacro('ADDSHEET("Neu")')).toBe('{ACTION(ADDSHEET;"Neu")}');
});
it('";"-separated arguments, including an omitted middle slot', () => {
expect(parseMacro('SETVALUE(Umsatz::A1;;)')).toBe('{ACTION(SETVALUE;Umsatz!cell(0,0);omitted;omitted)}');
});
it('an action call composes with local variables and & concatenation', () => {
expect(parseMacro('n = "Blatt" ADDSHEET("Neu " & n)')).toBe(
'{n="Blatt"; ACTION(ADDSHEET;(& "Neu " param(n)))}',
);
});
it('an action call can appear inside FOR/IF bodies', () => {
expect(
parseMacro('FOR c IN Umsatz::A1:A3 DO SETVALUE(c; 0) END'),
).toBe('{FOR(c IN Umsatz!range(0,0:0,2) DO {ACTION(SETVALUE;param(c);0)})}');
});
it('assignment (IDENT "=" expr) still wins over an action call -- disambiguated by the token after the name', () => {
expect(parseMacro('x = 1 RETURN x')).toBe('{x=1; RETURN param(x)}');
});
it('a bare NAME(args) at statement position is only recognised in a macro body, never a plain function body', () => {
// Same source: a function body has no `allowWrites`, so "SETVALUE(...)"
// at statement start falls through to parseAssignment(), which then
// fails immediately for lack of "=".
expect(() => parseFunctionBlock('SETVALUE(A1; 1) RETURN 1', [])).toThrow(ParseError);
});
it('rejects an action call missing its closing ")"', () => {
expect(() => parseMacroBlock('SETVALUE(Umsatz::A1; 1')).toThrow(ParseError);
});
});
describe('collectRefsInBlock -- reference walk used by the macro save-time qualifier check', () => {
it('collects a SETVALUE action\'s reference arguments too', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; Kosten::B2)');
const refs = collectRefsInBlock(block);
expect(refs.map((r) => r.kind)).toEqual(['cell', 'cell']);
expect(refs.map((r) => (r as { qualifier: string | null }).qualifier)).toEqual(['Umsatz', 'Kosten']);
});
it('collects refs from every action argument, not just the first', () => {
const block = parseMacroBlock('INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A1; Kosten::B2 + 1)');
const refs = collectRefsInBlock(block);
// INSERTROW's args are plain strings/numbers -- no refs from that
// statement; SETVALUE contributes both its target and its value's ref.
expect(refs).toHaveLength(2);
});
});