dzql 0.6.37 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +16 -0
  2. package/docs/README.md +1 -1
  3. package/docs/for_ai.md +39 -9
  4. package/package.json +12 -5
  5. package/src/cli/codegen/notification.ts +3 -3
  6. package/src/cli/codegen/sql/core.ts +21 -5
  7. package/src/cli/codegen/sql/delete.ts +5 -7
  8. package/src/cli/codegen/sql/get.ts +9 -11
  9. package/src/cli/codegen/sql/index.ts +7 -4
  10. package/src/cli/codegen/sql/lookup.ts +4 -5
  11. package/src/cli/codegen/sql/save.ts +58 -39
  12. package/src/cli/codegen/sql/search.ts +44 -10
  13. package/src/cli/codegen/sql/utils.ts +16 -1
  14. package/src/cli/codegen/subscribable_sql.ts +116 -124
  15. package/src/cli/compile.ts +263 -0
  16. package/src/cli/compiler/path-validator.ts +115 -83
  17. package/src/cli/compiler/paths.ts +299 -379
  18. package/src/cli/compiler/permissions.ts +26 -26
  19. package/src/cli/index.ts +17 -219
  20. package/src/client/ws.ts +20 -10
  21. package/src/postgres/auth-jwt-sql.ts +32 -0
  22. package/src/postgres/auth-jwt.ts +209 -0
  23. package/src/postgres/auth.ts +50 -0
  24. package/src/postgres/bootstrap.ts +55 -0
  25. package/src/postgres/carry.ts +40 -0
  26. package/src/postgres/codegen.ts +172 -0
  27. package/src/postgres/index.ts +58 -0
  28. package/src/postgres/listener.ts +706 -0
  29. package/src/postgres/migrate.ts +143 -0
  30. package/src/postgres/pglite.ts +142 -0
  31. package/src/postgres/registry.ts +253 -0
  32. package/src/postgres/schema.ts +171 -0
  33. package/src/postgres/sql.ts +42 -0
  34. package/src/runtime/auth.ts +33 -2
  35. package/src/runtime/index.ts +47 -6
  36. package/src/runtime/manifest_loader.ts +10 -0
  37. package/src/runtime/projection.ts +19 -0
  38. package/src/sql/001a-delta-meta.sql +204 -0
  39. package/src/sql/001b-delta-scope.sql +619 -0
  40. package/src/sql/001c-delta-read.sql +506 -0
  41. package/src/sql/001d-delta-write.sql +1088 -0
  42. package/src/sql/001e-delta-ops.sql +142 -0
  43. package/src/sql/001f-delta-as.sql +41 -0
  44. package/src/sql/001g-delta-ledger.sql +564 -0
  45. package/src/sql/auth-jwt.sql +62 -0
@@ -0,0 +1,564 @@
1
+ -- =========================================================================
2
+ -- Delta-doc: the ledger
3
+ -- =========================================================================
4
+ -- Every write, with its inverse, written in the write's own transaction, by the
5
+ -- database every process shares: so undo works across processes. The same
6
+ -- entry as the SQLite backend's (src/server/ledger.ts):
7
+ --
8
+ -- who the identity that made the change, for the audit
9
+ -- cursor the key undo walks, opaque here (eta passes its session)
10
+ -- undoes the entry this one walked back (undo) or forward again (redo)
11
+ --
12
+ -- `_delta_ops_log` is a catch-up buffer, pruned within the hour; this is the
13
+ -- history, kept.
14
+ -- =========================================================================
15
+
16
+ CREATE TABLE IF NOT EXISTS _delta_ledger (
17
+ id BIGSERIAL PRIMARY KEY,
18
+ doc_name TEXT NOT NULL,
19
+ version BIGINT NOT NULL,
20
+ ops JSONB NOT NULL,
21
+ inverse JSONB NOT NULL,
22
+ who TEXT,
23
+ cursor TEXT,
24
+ at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
25
+ undoes BIGINT REFERENCES _delta_ledger (id),
26
+ undoable BOOLEAN NOT NULL DEFAULT TRUE
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_delta_ledger_doc ON _delta_ledger (doc_name, version);
30
+ CREATE INDEX IF NOT EXISTS idx_delta_ledger_cursor ON _delta_ledger (cursor);
31
+ CREATE INDEX IF NOT EXISTS idx_delta_ledger_undoes ON _delta_ledger (undoes);
32
+
33
+ -- ---------------------------------------------------------------------------
34
+ -- _delta_row_at: the row a document holds at a row's path, split -- the twin
35
+ -- of rowAt in src/server/ledger.ts: /<coll>/<id> in a map, /<coll> its root,
36
+ -- and, in a single document, /<root>/<id>, the root named by its id, as a
37
+ -- write that adds or removes the root tells it. NULL when it holds none.
38
+ -- ---------------------------------------------------------------------------
39
+
40
+ CREATE OR REPLACE FUNCTION _delta_row_at(p_doc JSONB, p_parts TEXT[])
41
+ RETURNS JSONB LANGUAGE sql IMMUTABLE AS $$
42
+ SELECT CASE
43
+ WHEN array_length(p_parts, 1) = 1 THEN p_doc->p_parts[1]
44
+ WHEN COALESCE(p_doc->p_parts[1]->p_parts[2], 'null'::jsonb) <> 'null'::jsonb THEN p_doc->p_parts[1]->p_parts[2]
45
+ WHEN jsonb_typeof(p_doc->p_parts[1]->'id') IN ('number', 'string')
46
+ AND p_doc->p_parts[1]->>'id' = p_parts[2] THEN p_doc->p_parts[1]
47
+ END;
48
+ $$;
49
+
50
+ -- ---------------------------------------------------------------------------
51
+ -- _delta_inverse: what applied ops walked back, read from the document as it
52
+ -- was. Applied ops are whole rows (/coll/id) or the root (/coll; added or
53
+ -- removed, /coll/id, read where the document holds it: _delta_row_at): an add is
54
+ -- removed, a remove added back, a replace replaced by its old self. Reverse
55
+ -- order, except that a remove and the rows its removal cascaded to (a run) come
56
+ -- back in their own order, parent first -- the rule of inverseOf in
57
+ -- src/server/sqlite.ts. p_asked, the ops as the caller sent them, says where
58
+ -- each run starts: at a remove the caller asked for. An undo's removes come
59
+ -- children first, each asked for, so each is its own run, and the runs come back
60
+ -- in reverse: parent first again. Without it (the two-argument form), removes
61
+ -- one after another are taken for one run, right only for a single remove and
62
+ -- its cascade. A single document's root written and then removed by the write
63
+ -- is one row at two paths (/root, /root/id): the add that takes back its
64
+ -- removal puts it back as it was before the write, so its replaces before the
65
+ -- removal have no inverse of their own -- a walk would find /root gone. A row
66
+ -- the write makes and takes out again never was outside it: its make, and what
67
+ -- it had since, walk back nothing, and its removal adds nothing back --
68
+ -- [add, remove] inverts to [], [add, remove, add] to [remove] (todo #67).
69
+ -- ---------------------------------------------------------------------------
70
+
71
+ CREATE OR REPLACE FUNCTION _delta_inverse(p_before JSONB, p_ops JSONB, p_asked JSONB)
72
+ RETURNS JSONB AS $$
73
+ DECLARE
74
+ v_inverse JSONB := '[]'::jsonb;
75
+ v_run JSONB := '[]'::jsonb;
76
+ v_heads TEXT[] := '{}'; -- the removes asked for, at the path the write tells each (/t/007 at /t/7)
77
+ v_op JSONB;
78
+ v_i BIGINT;
79
+ v_removed JSONB; -- each path the write removes, at its last remove: one pass, however long the write
80
+ v_parts TEXT[];
81
+ v_prior JSONB;
82
+ v_made JSONB := '{}'::jsonb; -- each row the write made, by path: the op that made it (each inverse op carries its own, _i)
83
+ BEGIN
84
+ FOR v_op IN SELECT * FROM jsonb_array_elements(COALESCE(p_asked, '[]'::jsonb)) LOOP
85
+ CONTINUE WHEN v_op->>'op' IS DISTINCT FROM 'remove';
86
+ v_parts := _delta_split_path(v_op->>'path');
87
+ v_heads := v_heads || CASE WHEN array_length(v_parts, 1) = 2
88
+ THEN _delta_build_path(v_parts[1], _delta_row_id(v_parts[1], v_parts[2])::text)
89
+ ELSE v_op->>'path' END;
90
+ END LOOP;
91
+
92
+ SELECT COALESCE(jsonb_object_agg(path, last), '{}'::jsonb) INTO v_removed FROM (
93
+ SELECT o->>'path' AS path, max(i) AS last
94
+ FROM jsonb_array_elements(COALESCE(p_ops, '[]'::jsonb)) WITH ORDINALITY AS r(o, i)
95
+ WHERE o->>'op' = 'remove' GROUP BY 1) removes;
96
+
97
+ FOR v_op, v_i IN SELECT o, i FROM jsonb_array_elements(COALESCE(p_ops, '[]'::jsonb)) WITH ORDINALITY AS t(o, i) LOOP
98
+ v_parts := _delta_split_path(v_op->>'path');
99
+ CONTINUE WHEN v_op->>'op' = 'replace' AND array_length(v_parts, 1) = 1 AND p_before->v_parts[1]->>'id' IS NOT NULL
100
+ AND (v_removed->>_delta_build_path(v_parts[1], p_before->v_parts[1]->>'id'))::bigint > v_i;
101
+ IF v_op->>'op' = 'remove' AND v_made ? (v_op->>'path') THEN
102
+ SELECT COALESCE(jsonb_agg(e ORDER BY n), '[]'::jsonb) INTO v_inverse
103
+ FROM jsonb_array_elements(v_inverse) WITH ORDINALITY AS x(e, n)
104
+ WHERE NOT COALESCE(e->>'path' = v_op->>'path' AND (e->>'_i')::bigint >= (v_made->>(v_op->>'path'))::bigint, false);
105
+ v_made := v_made - (v_op->>'path');
106
+ CONTINUE;
107
+ END IF;
108
+ v_prior := _delta_row_at(p_before, v_parts);
109
+ IF v_prior IS NOT NULL AND jsonb_typeof(v_prior) = 'object' THEN
110
+ v_prior := _delta_strip_temporal(v_prior);
111
+ END IF;
112
+
113
+ IF v_op->>'op' = 'remove' THEN
114
+ IF (v_op->>'path') = ANY(v_heads) THEN -- a remove asked for starts its own run; one it cascaded to joins it
115
+ v_inverse := v_run || v_inverse;
116
+ v_run := '[]'::jsonb;
117
+ END IF;
118
+ v_run := v_run || jsonb_build_array(jsonb_build_object('op', 'add', 'path', v_op->>'path', 'value', v_prior));
119
+ ELSE
120
+ v_inverse := v_run || v_inverse;
121
+ v_run := '[]'::jsonb;
122
+ IF v_op->>'op' = 'add' THEN
123
+ v_inverse := jsonb_build_array(jsonb_build_object('op', 'remove', 'path', v_op->>'path', '_i', v_i)) || v_inverse;
124
+ v_made := v_made || jsonb_build_object(v_op->>'path', v_i);
125
+ ELSE
126
+ v_inverse := jsonb_build_array(jsonb_build_object('op', 'replace', 'path', v_op->>'path', 'value', v_prior, '_i', v_i)) || v_inverse;
127
+ END IF;
128
+ END IF;
129
+ END LOOP;
130
+ SELECT COALESCE(jsonb_agg(e - '_i' ORDER BY n), '[]'::jsonb) INTO v_inverse
131
+ FROM jsonb_array_elements(v_run || v_inverse) WITH ORDINALITY AS x(e, n);
132
+ RETURN v_inverse;
133
+ END;
134
+ $$ LANGUAGE plpgsql IMMUTABLE;
135
+
136
+ CREATE OR REPLACE FUNCTION _delta_inverse(p_before JSONB, p_ops JSONB)
137
+ RETURNS JSONB LANGUAGE sql IMMUTABLE AS $$ SELECT _delta_inverse(p_before, p_ops, NULL); $$;
138
+
139
+ -- ---------------------------------------------------------------------------
140
+ -- _delta_beside: a document with rows outside it beside it -- rows, each
141
+ -- {coll, id, row}, of collections it neither roots nor includes -- for the
142
+ -- ledger: what a removal's cascade takes beyond the document (the schema's
143
+ -- cascade, not the include's), which its inverse puts back and a walk of it is
144
+ -- guarded by. `besides` in sqlite.ts.
145
+ -- ---------------------------------------------------------------------------
146
+
147
+ CREATE OR REPLACE FUNCTION _delta_beside(p_doc JSONB, p_def _delta_docs, p_rows JSONB)
148
+ RETURNS JSONB LANGUAGE sql STABLE AS $$
149
+ SELECT p_doc || COALESCE(jsonb_object_agg(coll, COALESCE(p_doc->coll, '{}'::jsonb) || rows), '{}'::jsonb)
150
+ FROM (SELECT r->>'coll' AS coll, jsonb_object_agg(r->>'id', r->'row') AS rows
151
+ FROM jsonb_array_elements(COALESCE(p_rows, '[]'::jsonb)) AS r
152
+ WHERE r->>'coll' IS DISTINCT FROM p_def.root_collection
153
+ AND NOT (r->>'coll' = ANY(COALESCE(p_def.include, ARRAY[]::text[])))
154
+ AND jsonb_typeof(r->'row') = 'object'
155
+ GROUP BY 1) AS beyond;
156
+ $$;
157
+
158
+ -- ---------------------------------------------------------------------------
159
+ -- delta_apply_logged: delta_apply, with its ledger entry, in one transaction.
160
+ -- A lock per document keeps another writer of it from landing between the read
161
+ -- the inverse is taken from and the write. Returns delta_apply's {version, ops}
162
+ -- with the inverse and the entry's id.
163
+ -- ---------------------------------------------------------------------------
164
+
165
+ CREATE OR REPLACE FUNCTION delta_apply_logged(
166
+ p_doc_name TEXT,
167
+ p_ops JSONB,
168
+ p_who TEXT,
169
+ p_cursor TEXT,
170
+ p_undoable BOOLEAN DEFAULT TRUE,
171
+ p_undoes BIGINT DEFAULT NULL
172
+ ) RETURNS JSONB AS $$
173
+ DECLARE
174
+ v_before JSONB;
175
+ v_result JSONB;
176
+ v_inverse JSONB;
177
+ v_entry BIGINT;
178
+ BEGIN
179
+ PERFORM pg_advisory_xact_lock(hashtext('delta:' || p_doc_name));
180
+ v_before := COALESCE(_delta_open(p_doc_name, TRUE), '{}'::jsonb); -- whole: the inverse puts back every column
181
+ -- the rows each removal's cascade will take beyond the document, as they are: its inverse puts them back
182
+ v_before := _delta_beside(v_before, _delta_find_doc(p_doc_name), (
183
+ SELECT jsonb_agg(r) FROM jsonb_array_elements(p_ops) AS o,
184
+ LATERAL _delta_split_path(o->>'path') AS p,
185
+ LATERAL jsonb_array_elements(_delta_cascade_rows(p[1], p[2]::bigint)) AS r
186
+ WHERE o->>'op' = 'remove' AND array_length(p, 1) = 2
187
+ AND p[2] ~ '^[0-9]+$' AND p[2]::numeric <= 9007199254740991));
188
+ v_result := delta_apply(p_doc_name, p_ops, p_undoes IS NOT NULL); -- an entry that walks another is a walk
189
+ v_inverse := _delta_inverse(v_before, v_result->'ops', p_ops);
190
+ IF jsonb_array_length(v_result->'ops') > 0 THEN
191
+ INSERT INTO _delta_ledger (doc_name, version, ops, inverse, who, cursor, undoes, undoable)
192
+ VALUES (p_doc_name, (v_result->>'version')::BIGINT, v_result->'ops', v_inverse, p_who, p_cursor, p_undoes, p_undoable)
193
+ RETURNING id INTO v_entry;
194
+ END IF;
195
+ RETURN v_result || jsonb_build_object('inverse', v_inverse, 'entry', v_entry);
196
+ END;
197
+ $$ LANGUAGE plpgsql;
198
+
199
+ -- ---------------------------------------------------------------------------
200
+ -- The cursor: undo and redo walk one cursor's entries. An entry's `undoes`
201
+ -- links it to the entry it walked, so entries form chains; depth along a chain
202
+ -- says which way an entry went (even: forward, odd: back), and the tip -- the
203
+ -- entry nothing has walked yet -- is the one that counts. A fresh write ends
204
+ -- what could be redone; an entry not undoable (a fact) starts no chain.
205
+ -- ---------------------------------------------------------------------------
206
+
207
+ CREATE OR REPLACE FUNCTION _delta_ledger_tip(p_cursor TEXT, p_back BOOLEAN)
208
+ RETURNS _delta_ledger AS $$
209
+ WITH RECURSIVE chain(id, depth) AS (
210
+ SELECT id, 0 FROM _delta_ledger WHERE cursor = p_cursor AND undoes IS NULL AND undoable
211
+ UNION ALL
212
+ SELECT l.id, c.depth + 1 FROM _delta_ledger l JOIN chain c ON l.undoes = c.id
213
+ ),
214
+ tips AS (
215
+ SELECT c.id, c.depth FROM chain c
216
+ WHERE NOT EXISTS (SELECT 1 FROM _delta_ledger w WHERE w.undoes = c.id)
217
+ )
218
+ SELECT l.* FROM _delta_ledger l JOIN tips t ON t.id = l.id
219
+ WHERE CASE WHEN p_back THEN t.depth % 2 = 0
220
+ ELSE t.depth % 2 = 1 AND l.undoable -- a walk recorded as changing nothing is never redone
221
+ AND l.id > COALESCE((SELECT MAX(id) FROM _delta_ledger WHERE cursor = p_cursor AND undoes IS NULL AND undoable), 0)
222
+ END
223
+ ORDER BY l.id DESC
224
+ LIMIT 1;
225
+ $$ LANGUAGE sql STABLE;
226
+
227
+ -- JSON values the same, with JSON null and a missing value the same.
228
+ CREATE OR REPLACE FUNCTION _delta_same(a JSONB, b JSONB)
229
+ RETURNS BOOLEAN LANGUAGE sql IMMUTABLE AS $$
230
+ SELECT COALESCE(a, 'null'::jsonb) = COALESCE(b, 'null'::jsonb);
231
+ $$;
232
+
233
+ -- A field of a collection's row the same in two values: a timestamptz by the
234
+ -- instant it names, whatever zone its text was written in (a ledger's entry
235
+ -- keeps the session's TimeZone of its write), anything else as _delta_same.
236
+ CREATE OR REPLACE FUNCTION _delta_same(p_coll TEXT, p_field TEXT, a JSONB, b JSONB)
237
+ RETURNS BOOLEAN LANGUAGE sql STABLE AS $$
238
+ SELECT CASE
239
+ WHEN jsonb_typeof(a) = 'string' AND jsonb_typeof(b) = 'string'
240
+ AND (SELECT _delta_cols(collection_key, columns_def)->p_field->>'type' FROM _delta_collections WHERE collection_key = p_coll) = 'timestamptz'
241
+ THEN (a #>> '{}')::timestamptz = (b #>> '{}')::timestamptz
242
+ ELSE _delta_same(a, b)
243
+ END;
244
+ $$;
245
+
246
+ -- _delta_walk_key: a row's key in a walk -- the keyOf of planWalk in
247
+ -- src/server/ledger.ts. A single document's root is one row at two paths,
248
+ -- /root (a replace of it) and /root/id (its add, its remove): both are keyed
249
+ -- by the second, the id read from the row, or else from the document, so the
250
+ -- root is walked once whichever way the entry spelled it. Every other path is
251
+ -- its own key.
252
+ CREATE OR REPLACE FUNCTION _delta_walk_key(p_path TEXT, p_value JSONB, p_doc JSONB)
253
+ RETURNS TEXT LANGUAGE plpgsql IMMUTABLE AS $$
254
+ DECLARE
255
+ v_parts TEXT[] := _delta_split_path(p_path);
256
+ v_own JSONB;
257
+ BEGIN
258
+ IF array_length(v_parts, 1) <> 1 THEN RETURN p_path; END IF;
259
+ v_own := CASE WHEN jsonb_typeof(p_value) = 'object' THEN p_value->'id' END;
260
+ IF jsonb_typeof(v_own) IS DISTINCT FROM 'number' AND jsonb_typeof(v_own) IS DISTINCT FROM 'string' THEN
261
+ v_own := p_doc->v_parts[1]->'id';
262
+ END IF;
263
+ IF jsonb_typeof(v_own) IN ('number', 'string') THEN RETURN _delta_build_path(v_parts[1], v_own #>> '{}'); END IF;
264
+ RETURN p_path;
265
+ END;
266
+ $$;
267
+
268
+ -- _delta_walk_plan: what walking an entry does to the document as it is now
269
+ -- -- the rule of planWalk in src/server/ledger.ts. Only the fields the entry
270
+ -- changed are set back, each guarded by what the entry left there: a row it
271
+ -- changed gets back those fields if each still holds what it wrote; a row it
272
+ -- made is removed if it is as it was left, and every row its removal would take
273
+ -- with it (_delta_cascade_rows, in the document's include) the entry made too;
274
+ -- a row it removed comes back if nobody has put one there. Each row is walked once, from what it was before
275
+ -- the entry (every inverse op of a path carries it: an add or replace its
276
+ -- value, a remove none) to what the entry left, so a row the entry touched
277
+ -- twice is walked by its net change. A guard that fails is a conflict, by
278
+ -- path, and then nothing is walked. A row a removal planned before it takes
279
+ -- with it is left to that removal. Returns { ops } or { ops: [], conflict: [paths] }.
280
+ CREATE OR REPLACE FUNCTION _delta_walk_plan(p_entry _delta_ledger)
281
+ RETURNS JSONB AS $$
282
+ DECLARE
283
+ v_doc JSONB := _delta_open_held(p_entry.doc_name); -- an implied document's absent root: null
284
+ v_left JSONB := '{}'::jsonb; -- row key -> what the entry left there (JSON null: it removed it)
285
+ v_before JSONB := '{}'::jsonb; -- row key -> what it held before the entry (JSON null: nothing)
286
+ v_shown JSONB := '{}'::jsonb; -- row key -> its path as the inverse first says it: where a conflict is reported
287
+ v_paths TEXT[] := '{}'; -- the row keys, in the order the inverse first names them
288
+ v_op JSONB;
289
+ v_path TEXT;
290
+ v_key TEXT;
291
+ v_at TEXT;
292
+ v_parts TEXT[];
293
+ v_here JSONB;
294
+ v_wrote JSONB;
295
+ v_was JSONB;
296
+ v_fields TEXT[];
297
+ v_ops JSONB := '[]'::jsonb;
298
+ v_conflict JSONB := '[]'::jsonb;
299
+ v_def _delta_docs := _delta_find_doc(p_entry.doc_name);
300
+ v_others BOOLEAN;
301
+ v_coll RECORD;
302
+ v_row JSONB;
303
+ v_beyond JSONB := '[]'::jsonb;
304
+ BEGIN
305
+ -- a row outside the document that the entry's cascade took is guarded by the tables, as they hold it now
306
+ FOR v_parts IN SELECT DISTINCT _delta_split_path(o->>'path') FROM jsonb_array_elements(p_entry.ops || p_entry.inverse) AS o LOOP
307
+ CONTINUE WHEN array_length(v_parts, 1) <> 2 OR v_parts[2] !~ '^[0-9]+$' OR v_parts[2]::numeric > 9007199254740991
308
+ OR v_parts[1] = v_def.root_collection OR v_parts[1] = ANY(COALESCE(v_def.include, ARRAY[]::text[]));
309
+ SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = v_parts[1];
310
+ CONTINUE WHEN NOT FOUND;
311
+ EXECUTE format('SELECT _delta_strip_temporal(to_jsonb(t.*)) FROM %I t WHERE t.id = $1', _delta_source_view(v_coll.table_name, v_coll.temporal))
312
+ INTO v_row USING v_parts[2]::bigint;
313
+ v_beyond := v_beyond || jsonb_build_array(jsonb_build_object('coll', v_parts[1], 'id', v_parts[2]::bigint, 'row', v_row));
314
+ END LOOP;
315
+ v_doc := _delta_beside(v_doc, v_def, v_beyond);
316
+
317
+ FOR v_op IN SELECT * FROM jsonb_array_elements(p_entry.ops) LOOP
318
+ v_left := v_left || jsonb_build_object(_delta_walk_key(v_op->>'path', v_op->'value', v_doc),
319
+ CASE WHEN v_op->>'op' = 'remove' THEN 'null'::jsonb ELSE v_op->'value' END);
320
+ END LOOP;
321
+ FOR v_op IN SELECT * FROM jsonb_array_elements(p_entry.inverse) LOOP
322
+ v_key := _delta_walk_key(v_op->>'path', CASE WHEN v_op->>'op' <> 'remove' THEN v_op->'value' END, v_doc);
323
+ IF NOT v_before ? v_key THEN
324
+ v_paths := v_paths || v_key;
325
+ v_before := v_before || jsonb_build_object(v_key, 'null'::jsonb);
326
+ v_shown := v_shown || jsonb_build_object(v_key, v_op->>'path');
327
+ END IF;
328
+ IF v_op->>'op' <> 'remove' AND COALESCE(v_op->'value', 'null'::jsonb) <> 'null'::jsonb THEN
329
+ v_before := v_before || jsonb_build_object(v_key, v_op->'value');
330
+ END IF;
331
+ END LOOP;
332
+ FOREACH v_path IN ARRAY v_paths LOOP
333
+ v_at := v_shown->>v_path;
334
+ v_parts := _delta_split_path(v_path);
335
+ v_here := _delta_row_at(v_doc, v_parts);
336
+ IF v_here = 'null'::jsonb THEN v_here := NULL; END IF;
337
+ v_wrote := NULLIF(v_left->v_path, 'null'::jsonb);
338
+ v_was := NULLIF(v_before->v_path, 'null'::jsonb);
339
+ CONTINUE WHEN v_was IS NULL AND v_wrote IS NULL; -- made and removed by the entry
340
+ IF v_was IS NULL THEN -- it made the row
341
+ -- a row under it that the entry did not make: what its removal would take with it
342
+ v_others := array_length(v_parts, 1) = 2 AND EXISTS (
343
+ SELECT 1 FROM jsonb_array_elements(_delta_cascade_rows(v_parts[1], _delta_row_id(v_parts[1], v_parts[2]))) WITH ORDINALITY AS c(r, i)
344
+ WHERE i > 1 AND NOT (
345
+ COALESCE(v_before->_delta_build_path(r->>'coll', r->>'id'), 'null'::jsonb) = 'null'::jsonb
346
+ AND COALESCE(v_left->_delta_build_path(r->>'coll', r->>'id'), 'null'::jsonb) <> 'null'::jsonb));
347
+ IF v_here IS NULL OR v_others OR EXISTS (
348
+ SELECT 1 FROM jsonb_each(v_wrote) w
349
+ WHERE w.key NOT IN ('valid_from', 'valid_to') AND NOT _delta_same(v_parts[1], w.key, v_here->w.key, w.value)
350
+ ) THEN v_conflict := v_conflict || to_jsonb(v_at);
351
+ ELSE v_ops := v_ops || jsonb_build_array(jsonb_build_object('op', 'remove', 'path', v_path)); END IF;
352
+ ELSIF v_wrote IS NULL THEN -- it removed the row
353
+ IF v_here IS NOT NULL THEN v_conflict := v_conflict || to_jsonb(v_at);
354
+ ELSE v_ops := v_ops || jsonb_build_array(jsonb_build_object('op', 'add', 'path', v_path, 'value', v_was)); END IF;
355
+ ELSE -- it changed the row's fields
356
+ SELECT array_agg(k ORDER BY k) INTO v_fields FROM (
357
+ SELECT jsonb_object_keys(v_was) AS k
358
+ UNION SELECT jsonb_object_keys(v_wrote)
359
+ ) keys
360
+ WHERE k NOT IN ('valid_from', 'valid_to') AND NOT _delta_same(v_was->k, v_wrote->k);
361
+ CONTINUE WHEN v_fields IS NULL;
362
+ IF v_here IS NULL OR EXISTS (SELECT 1 FROM unnest(v_fields) f WHERE NOT _delta_same(v_parts[1], f, v_here->f, v_wrote->f)) THEN
363
+ v_conflict := v_conflict || to_jsonb(v_at);
364
+ ELSE
365
+ -- each field set back, one replace each (a replace of the row is the row whole, todo #3);
366
+ -- the document's root at /root/<field>, however the entry spelled it
367
+ v_ops := v_ops || (SELECT jsonb_agg(jsonb_build_object('op', 'replace',
368
+ 'path', CASE WHEN v_here = v_doc->v_parts[1] THEN _delta_build_path(v_parts[1]) ELSE v_path END || _delta_build_path(f),
369
+ 'value', COALESCE(v_was->f, 'null'::jsonb)) ORDER BY f COLLATE "C") FROM unnest(v_fields) f);
370
+ END IF;
371
+ END IF;
372
+ END LOOP;
373
+ IF jsonb_array_length(v_conflict) > 0 THEN
374
+ RETURN jsonb_build_object('ops', '[]'::jsonb, 'conflict', v_conflict);
375
+ END IF;
376
+ -- a row the removal of one before it takes with it is taken by that removal: removed again after it, it would not be there
377
+ SELECT COALESCE(jsonb_agg(o ORDER BY n), '[]'::jsonb) INTO v_ops
378
+ FROM jsonb_array_elements(v_ops) WITH ORDINALITY AS x(o, n)
379
+ WHERE NOT (o->>'op' = 'remove' AND EXISTS (
380
+ SELECT 1 FROM jsonb_array_elements(v_ops) WITH ORDINALITY AS t(e, m),
381
+ LATERAL _delta_split_path(e->>'path') AS p,
382
+ LATERAL jsonb_array_elements(_delta_cascade_rows(p[1], _delta_row_id(p[1], p[2]))) WITH ORDINALITY AS c(r, i)
383
+ WHERE m < n AND e->>'op' = 'remove' AND array_length(p, 1) = 2 AND i > 1
384
+ AND _delta_build_path(r->>'coll', r->>'id') = o->>'path'));
385
+ RETURN jsonb_build_object('ops', v_ops);
386
+ END;
387
+ $$ LANGUAGE plpgsql STABLE;
388
+
389
+ -- _delta_change_tip: the entry a walk of one change takes -- the rule of
390
+ -- changeTip in src/server/ledger.ts. The change is the chain that holds entry
391
+ -- p_change, named by any entry of it (its write, an undo, a redo); its write
392
+ -- must be the cursor's own and undoable, else SQLSTATE P0002 (404). Its tip, if
393
+ -- it goes the way asked: back when the change stands (an even depth), forward
394
+ -- when it was undone by a walk that changed something (odd); else a NULL row.
395
+ CREATE OR REPLACE FUNCTION _delta_change_tip(p_cursor TEXT, p_change BIGINT, p_back BOOLEAN)
396
+ RETURNS _delta_ledger AS $$
397
+ DECLARE
398
+ v_tip _delta_ledger;
399
+ v_next _delta_ledger;
400
+ v_depth INT := 0;
401
+ BEGIN
402
+ SELECT * INTO v_tip FROM _delta_ledger WHERE id = p_change;
403
+ WHILE v_tip.undoes IS NOT NULL LOOP -- up to the change's write
404
+ SELECT * INTO v_tip FROM _delta_ledger WHERE id = v_tip.undoes;
405
+ END LOOP;
406
+ IF v_tip.id IS NULL OR v_tip.cursor IS DISTINCT FROM p_cursor OR NOT v_tip.undoable THEN
407
+ RAISE EXCEPTION 'Entry % is no change of this cursor''s', p_change USING ERRCODE = 'P0002';
408
+ END IF;
409
+ LOOP -- down to the entry nothing has walked
410
+ SELECT * INTO v_next FROM _delta_ledger WHERE undoes = v_tip.id;
411
+ EXIT WHEN NOT FOUND;
412
+ v_tip := v_next;
413
+ v_depth := v_depth + 1;
414
+ END LOOP;
415
+ IF (p_back AND v_depth % 2 = 0) OR (NOT p_back AND v_depth % 2 = 1 AND v_tip.undoable) THEN
416
+ RETURN v_tip;
417
+ END IF;
418
+ RETURN NULL;
419
+ END;
420
+ $$ LANGUAGE plpgsql STABLE;
421
+
422
+ -- delta_walk: the cursor's next entry (p_back: the next to undo, else to
423
+ -- redo), or the next of one change (p_change: any entry of it, the cursor's
424
+ -- own), walked through delta_apply_logged -- by its plan, above -- and
425
+ -- recorded as walking it. NULL when there is none.
426
+ -- p_dry answer the plan ({ doc, entry, ops, conflict? }) and walk nothing
427
+ -- p_entry walk only if this is the entry the walk would take (SQLSTATE 40001 → 409)
428
+ -- A conflict, a walk the document refuses, or one that changes nothing is
429
+ -- recorded all the same (no ops, not undoable: never redone), so the cursor
430
+ -- moves on to the entry before it instead of meeting it again for ever. A
431
+ -- named walk has no cursor to move on: it walks nothing and records nothing,
432
+ -- and the change stays to be walked once it can be.
433
+ CREATE OR REPLACE FUNCTION delta_walk(
434
+ p_cursor TEXT, p_who TEXT, p_back BOOLEAN, p_dry BOOLEAN, p_entry BIGINT, p_change BIGINT
435
+ ) RETURNS JSONB AS $$
436
+ DECLARE
437
+ v_entry _delta_ledger;
438
+ v_plan JSONB;
439
+ v_version BIGINT;
440
+ v_skip BIGINT;
441
+ BEGIN
442
+ IF p_cursor IS NULL THEN
443
+ IF p_change IS NOT NULL THEN
444
+ RAISE EXCEPTION 'Entry % is no change of this cursor''s', p_change USING ERRCODE = 'P0002';
445
+ END IF;
446
+ RETURN NULL;
447
+ END IF;
448
+ -- one walker per cursor at a time: two undos pressed at once take two entries, not one twice
449
+ PERFORM pg_advisory_xact_lock(hashtext('delta-cursor:' || p_cursor));
450
+ IF p_change IS NULL THEN
451
+ v_entry := _delta_ledger_tip(p_cursor, p_back);
452
+ ELSE
453
+ v_entry := _delta_change_tip(p_cursor, p_change, p_back);
454
+ END IF;
455
+ IF v_entry.id IS NULL THEN RETURN NULL; END IF;
456
+ IF p_entry IS NOT NULL AND p_entry <> v_entry.id THEN
457
+ RAISE EXCEPTION 'The %''s next entry to % is %, not %', CASE WHEN p_change IS NULL THEN 'cursor' ELSE 'change' END,
458
+ CASE WHEN p_back THEN 'undo' ELSE 'redo' END, v_entry.id, p_entry USING ERRCODE = '40001';
459
+ END IF;
460
+ -- the document's lock (delta_apply_logged takes it again): no writer lands between plan and walk
461
+ PERFORM pg_advisory_xact_lock(hashtext('delta:' || v_entry.doc_name));
462
+ v_plan := _delta_walk_plan(v_entry);
463
+ IF p_dry THEN RETURN v_plan || jsonb_build_object('doc', v_entry.doc_name, 'entry', v_entry.id); END IF;
464
+ IF NOT v_plan ? 'conflict' AND jsonb_array_length(v_plan->'ops') > 0 THEN
465
+ BEGIN
466
+ RETURN delta_apply_logged(v_entry.doc_name, v_plan->'ops', p_who, p_cursor, TRUE, v_entry.id)
467
+ || jsonb_build_object('doc', v_entry.doc_name);
468
+ EXCEPTION WHEN SQLSTATE '22023' OR SQLSTATE '22P02' OR SQLSTATE '23502' OR SQLSTATE '23505' OR SQLSTATE 'P0002' THEN
469
+ v_plan := jsonb_build_object('ops', '[]'::jsonb,
470
+ 'conflict', (SELECT jsonb_agg(o->>'path') FROM jsonb_array_elements(v_plan->'ops') o));
471
+ END;
472
+ END IF;
473
+ SELECT COALESCE((SELECT version FROM _delta_versions WHERE doc_name = v_entry.doc_name), 0) INTO v_version;
474
+ IF p_change IS NULL THEN
475
+ INSERT INTO _delta_ledger (doc_name, version, ops, inverse, who, cursor, undoes, undoable)
476
+ VALUES (v_entry.doc_name, v_version, '[]'::jsonb, '[]'::jsonb, p_who, p_cursor, v_entry.id, FALSE)
477
+ RETURNING id INTO v_skip;
478
+ END IF;
479
+ RETURN jsonb_strip_nulls(jsonb_build_object('doc', v_entry.doc_name, 'ops', '[]'::jsonb, 'inverse', '[]'::jsonb, 'version', v_version, 'entry', v_skip))
480
+ || CASE WHEN v_plan ? 'conflict' THEN jsonb_build_object('conflict', v_plan->'conflict') ELSE '{}'::jsonb END;
481
+ END;
482
+ $$ LANGUAGE plpgsql;
483
+
484
+ -- The cursor's next entry, as delta_walk was first called: no change named.
485
+ CREATE OR REPLACE FUNCTION delta_walk(
486
+ p_cursor TEXT, p_who TEXT, p_back BOOLEAN, p_dry BOOLEAN DEFAULT FALSE, p_entry BIGINT DEFAULT NULL
487
+ ) RETURNS JSONB AS $$
488
+ BEGIN
489
+ RETURN delta_walk(p_cursor, p_who, p_back, p_dry, p_entry, NULL::BIGINT);
490
+ END;
491
+ $$ LANGUAGE plpgsql;
492
+
493
+ -- delta_undo / delta_redo: delta_walk for the cursor's next entry back or forward.
494
+ CREATE OR REPLACE FUNCTION _delta_walk(p_cursor TEXT, p_who TEXT, p_back BOOLEAN)
495
+ RETURNS JSONB AS $$ SELECT delta_walk(p_cursor, p_who, p_back); $$ LANGUAGE sql;
496
+
497
+ CREATE OR REPLACE FUNCTION delta_undo(p_cursor TEXT, p_who TEXT DEFAULT NULL)
498
+ RETURNS JSONB AS $$ SELECT _delta_walk(p_cursor, p_who, TRUE); $$ LANGUAGE sql;
499
+
500
+ CREATE OR REPLACE FUNCTION delta_redo(p_cursor TEXT, p_who TEXT DEFAULT NULL)
501
+ RETURNS JSONB AS $$ SELECT _delta_walk(p_cursor, p_who, FALSE); $$ LANGUAGE sql;
502
+
503
+ -- delta_history: a document's newest entries, each saying whether p_cursor
504
+ -- wrote it -- never who did, never a cursor. Newest first: by version, then
505
+ -- by id (an undo that walked nothing shares the version of the write before
506
+ -- it), as SQLite lists them.
507
+ CREATE OR REPLACE FUNCTION delta_history(p_doc_name TEXT, p_cursor TEXT, p_limit INT DEFAULT 50)
508
+ RETURNS JSONB AS $$
509
+ SELECT COALESCE(jsonb_agg(e ORDER BY (e->>'version')::BIGINT DESC, (e->>'id')::BIGINT DESC), '[]'::jsonb) FROM (
510
+ SELECT jsonb_build_object(
511
+ 'id', id, 'doc', doc_name, 'version', version, 'ops', ops, 'inverse', inverse,
512
+ 'at', (EXTRACT(EPOCH FROM at) * 1000)::BIGINT, 'undoable', undoable,
513
+ 'mine', p_cursor IS NOT NULL AND cursor = p_cursor
514
+ ) AS e
515
+ FROM _delta_ledger WHERE doc_name = p_doc_name
516
+ ORDER BY version DESC, id DESC LIMIT p_limit
517
+ ) entries;
518
+ $$ LANGUAGE sql STABLE;
519
+
520
+ -- The same, with row-level security: app.user_id set for the transaction, as
521
+ -- delta_apply_as does (001f).
522
+ CREATE OR REPLACE FUNCTION delta_apply_logged_as(
523
+ p_user_id TEXT, p_doc_name TEXT, p_ops JSONB, p_who TEXT, p_cursor TEXT,
524
+ p_undoable BOOLEAN DEFAULT TRUE, p_undoes BIGINT DEFAULT NULL
525
+ ) RETURNS JSONB LANGUAGE plpgsql AS $$
526
+ BEGIN
527
+ PERFORM set_config('app.user_id', p_user_id, true);
528
+ RETURN delta_apply_logged(p_doc_name, p_ops, p_who, p_cursor, p_undoable, p_undoes);
529
+ END;
530
+ $$;
531
+
532
+ CREATE OR REPLACE FUNCTION delta_undo_as(p_user_id TEXT, p_cursor TEXT, p_who TEXT DEFAULT NULL)
533
+ RETURNS JSONB LANGUAGE plpgsql AS $$
534
+ BEGIN
535
+ PERFORM set_config('app.user_id', p_user_id, true);
536
+ RETURN _delta_walk(p_cursor, p_who, TRUE);
537
+ END;
538
+ $$;
539
+
540
+ CREATE OR REPLACE FUNCTION delta_redo_as(p_user_id TEXT, p_cursor TEXT, p_who TEXT DEFAULT NULL)
541
+ RETURNS JSONB LANGUAGE plpgsql AS $$
542
+ BEGIN
543
+ PERFORM set_config('app.user_id', p_user_id, true);
544
+ RETURN _delta_walk(p_cursor, p_who, FALSE);
545
+ END;
546
+ $$;
547
+
548
+ CREATE OR REPLACE FUNCTION delta_walk_as(
549
+ p_user_id TEXT, p_cursor TEXT, p_who TEXT, p_back BOOLEAN, p_dry BOOLEAN DEFAULT FALSE, p_entry BIGINT DEFAULT NULL
550
+ ) RETURNS JSONB LANGUAGE plpgsql AS $$
551
+ BEGIN
552
+ PERFORM set_config('app.user_id', p_user_id, true);
553
+ RETURN delta_walk(p_cursor, p_who, p_back, p_dry, p_entry);
554
+ END;
555
+ $$;
556
+
557
+ CREATE OR REPLACE FUNCTION delta_walk_as(
558
+ p_user_id TEXT, p_cursor TEXT, p_who TEXT, p_back BOOLEAN, p_dry BOOLEAN, p_entry BIGINT, p_change BIGINT
559
+ ) RETURNS JSONB LANGUAGE plpgsql AS $$
560
+ BEGIN
561
+ PERFORM set_config('app.user_id', p_user_id, true);
562
+ RETURN delta_walk(p_cursor, p_who, p_back, p_dry, p_entry, p_change);
563
+ END;
564
+ $$;
@@ -0,0 +1,62 @@
1
+ -- =========================================================================
2
+ -- auth-jwt — reference users schema + login / register stored functions.
3
+ -- =========================================================================
4
+ --
5
+ -- Apply this alongside the @blueshed/delta/auth-jwt module. Consumers who
6
+ -- want a different identity schema (sessions, OAuth, custom tables) can
7
+ -- substitute their own `login` / `register` functions and pass the SQL
8
+ -- strings via `jwtAuth({ loginSql, registerSql })`.
9
+ --
10
+ -- Uses pgcrypto for bcrypt. Passwords are stored as bcrypt hashes at an
11
+ -- explicit work factor (cost 12 — tune via gen_salt('bf', N)). `login` always
12
+ -- runs one crypt() — even for an unknown email it compares against a fixed
13
+ -- dummy hash — so its timing does not reveal whether an email is registered.
14
+ -- =========================================================================
15
+
16
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
17
+
18
+ CREATE TABLE IF NOT EXISTS users (
19
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
20
+ name TEXT NOT NULL,
21
+ email TEXT NOT NULL UNIQUE,
22
+ password TEXT NOT NULL,
23
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
24
+ );
25
+
26
+ -- register(name, email, password) → { id, name, email }
27
+ -- Raises unique_violation (23505) when email already exists.
28
+ CREATE OR REPLACE FUNCTION register(p_name TEXT, p_email TEXT, p_password TEXT)
29
+ RETURNS JSONB LANGUAGE plpgsql AS $$
30
+ DECLARE
31
+ rec users;
32
+ BEGIN
33
+ INSERT INTO users (name, email, password)
34
+ VALUES (p_name, p_email, crypt(p_password, gen_salt('bf', 12)))
35
+ RETURNING * INTO rec;
36
+ RETURN jsonb_build_object('id', rec.id, 'name', rec.name, 'email', rec.email);
37
+ END;
38
+ $$;
39
+
40
+ -- login(email, password) → { id, name, email } or NULL on bad credentials.
41
+ -- Always runs exactly one bcrypt crypt(): when the email is unknown it
42
+ -- compares against a fixed dummy hash, so an attacker can't tell a missing
43
+ -- account from a wrong password by timing (user-enumeration oracle).
44
+ CREATE OR REPLACE FUNCTION login(p_email TEXT, p_password TEXT)
45
+ RETURNS JSONB LANGUAGE plpgsql AS $$
46
+ DECLARE
47
+ rec users;
48
+ v_hash TEXT;
49
+ v_ok BOOLEAN;
50
+ -- A valid throwaway bcrypt hash (cost 12) to spend crypt() time on when the
51
+ -- email doesn't exist. Its plaintext is irrelevant — it just equalises cost.
52
+ c_dummy CONSTANT TEXT := '$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW';
53
+ BEGIN
54
+ SELECT * INTO rec FROM users WHERE email = p_email;
55
+ v_hash := COALESCE(rec.password, c_dummy);
56
+ v_ok := (crypt(p_password, v_hash) = v_hash);
57
+ IF rec.id IS NULL OR NOT v_ok THEN
58
+ RETURN NULL;
59
+ END IF;
60
+ RETURN jsonb_build_object('id', rec.id, 'name', rec.name, 'email', rec.email);
61
+ END;
62
+ $$;