dzql 0.7.0 → 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.
@@ -0,0 +1,1088 @@
1
+ -- =========================================================================
2
+ -- Delta-doc: cascade removal, delta_apply
3
+ -- =========================================================================
4
+
5
+ -- ---------------------------------------------------------------------------
6
+ -- _delta_serial_past: moves a collection's serial (seq_<table>) past an id an
7
+ -- add named, never back, so a later add at /- does not meet it (todo #63).
8
+ -- setval needs UPDATE on the sequence, and an app role is granted USAGE,
9
+ -- SELECT: so it runs as the framework's owner (SECURITY DEFINER), for a table
10
+ -- delta knows, an id a path may name, and a caller that may insert into the
11
+ -- table, and nothing else. Running as the owner, current_user is the owner:
12
+ -- the caller is the role SET ROLE named, else the session's. Its search_path
13
+ -- is pinned below, pg_temp last.
14
+ -- ---------------------------------------------------------------------------
15
+
16
+ CREATE OR REPLACE FUNCTION _delta_serial_past(p_table TEXT, p_id BIGINT)
17
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$
18
+ BEGIN
19
+ IF p_id > 9007199254740991 OR NOT EXISTS (SELECT 1 FROM _delta_collections WHERE table_name = p_table)
20
+ OR NOT has_table_privilege(COALESCE(NULLIF(current_setting('role'), 'none'), session_user), p_table, 'INSERT') THEN
21
+ RETURN;
22
+ END IF;
23
+ EXECUTE format('SELECT setval(%L, $1) FROM %I WHERE $1 >= last_value + is_called::int',
24
+ 'seq_' || p_table, 'seq_' || p_table) USING p_id;
25
+ END;
26
+ $$;
27
+ -- the search path it was applied with, and pg_temp last (as _delta_holders, 001b)
28
+ DO $$ BEGIN
29
+ EXECUTE format('ALTER FUNCTION _delta_serial_past(TEXT, BIGINT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
30
+ END $$;
31
+
32
+ -- ---------------------------------------------------------------------------
33
+ -- _delta_serial_new: whether an id is past every one a collection's serial has
34
+ -- handed out (minted, or passed for an add that named it): what an add may
35
+ -- name (todo #71). The serial is read as the framework's owner, as
36
+ -- _delta_serial_past sets it, for a caller that may read the table: to any
37
+ -- other every id is taken, so it learns nothing of the table's rows.
38
+ -- ---------------------------------------------------------------------------
39
+
40
+ CREATE OR REPLACE FUNCTION _delta_serial_new(p_table TEXT, p_id BIGINT)
41
+ RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$
42
+ DECLARE
43
+ v_new BOOLEAN;
44
+ BEGIN
45
+ IF NOT EXISTS (SELECT 1 FROM _delta_collections WHERE table_name = p_table)
46
+ OR NOT has_table_privilege(COALESCE(NULLIF(current_setting('role'), 'none'), session_user), p_table, 'SELECT') THEN
47
+ RETURN FALSE;
48
+ END IF;
49
+ EXECUTE format('SELECT $1 >= last_value + is_called::int FROM %I', 'seq_' || p_table) INTO v_new USING p_id;
50
+ RETURN v_new;
51
+ END;
52
+ $$;
53
+
54
+ -- Both run as the framework's owner, so each looks its names up only in the
55
+ -- schemas the framework is applied in, and pg_temp last: a caller's temporary
56
+ -- _delta_collections, or a temporary table named as one of delta's, never
57
+ -- stands in for the real one. (search_path FROM CURRENT took "$user", public,
58
+ -- and pg_temp is searched first when it is not named.)
59
+ DO $$
60
+ DECLARE
61
+ v_path TEXT := COALESCE((SELECT string_agg(quote_ident(s), ', ') FROM unnest(current_schemas(false)) AS s), 'public') || ', pg_temp';
62
+ BEGIN
63
+ EXECUTE format('ALTER FUNCTION _delta_serial_past(text, bigint) SET search_path = %s', v_path);
64
+ EXECUTE format('ALTER FUNCTION _delta_serial_new(text, bigint) SET search_path = %s', v_path);
65
+ END $$;
66
+
67
+ -- ---------------------------------------------------------------------------
68
+ -- _delta_cascade_remove: remove a row + cascade to children & referencedBy,
69
+ -- as the schema says, whichever document the removal is written through: one
70
+ -- that does not include the drinks still takes a course's drinks (no orphans).
71
+ -- Returns the array of broadcast ops generated. (Until 0.12.0 it took the
72
+ -- writer's include, and left the rest: that form is dropped.) In one order on
73
+ -- every backend: each child collection by key, each row by id, then each
74
+ -- collection that cascades on it (cascadeRows in sqlite.ts).
75
+ -- ---------------------------------------------------------------------------
76
+
77
+ DROP FUNCTION IF EXISTS _delta_cascade_remove(TEXT, BIGINT, TEXT[]);
78
+ CREATE OR REPLACE FUNCTION _delta_cascade_remove(
79
+ p_collection_key TEXT,
80
+ p_id BIGINT
81
+ ) RETURNS JSONB AS $$
82
+ DECLARE
83
+ v_coll RECORD;
84
+ v_view TEXT;
85
+ v_child RECORD;
86
+ v_child_row RECORD;
87
+ v_ref RECORD;
88
+ v_ref_row RECORD;
89
+ v_affected BIGINT;
90
+ v_ops JSONB := '[]'::jsonb;
91
+ BEGIN
92
+ SELECT * INTO v_coll FROM _delta_collections
93
+ WHERE collection_key = p_collection_key;
94
+ IF NOT FOUND THEN RETURN v_ops; END IF;
95
+
96
+ -- Close or delete the row itself
97
+ IF v_coll.temporal THEN
98
+ EXECUTE format(
99
+ 'UPDATE %I SET valid_to = NOW() WHERE id = $1 AND valid_to IS NULL',
100
+ v_coll.table_name
101
+ ) USING p_id;
102
+ ELSE
103
+ EXECUTE format('DELETE FROM %I WHERE id = $1', v_coll.table_name)
104
+ USING p_id;
105
+ END IF;
106
+
107
+ -- Only claim a removal that actually happened. The row may already be closed,
108
+ -- or an RLS policy may have silently filtered the UPDATE/DELETE to zero rows —
109
+ -- in which case emitting the op would tell every subscriber to drop a row that
110
+ -- is still in the table, and cascading from it would compound the lie.
111
+ GET DIAGNOSTICS v_affected = ROW_COUNT;
112
+ IF v_affected = 0 THEN RETURN v_ops; END IF;
113
+
114
+ v_ops := v_ops || jsonb_build_array(
115
+ jsonb_build_object('op', 'remove', 'path', _delta_build_path(p_collection_key, p_id::text))
116
+ );
117
+
118
+ -- Cascade via parent relationship (children of this collection)
119
+ FOR v_child IN
120
+ SELECT * FROM _delta_collections
121
+ WHERE parent_collection = p_collection_key
122
+ ORDER BY collection_key COLLATE "C"
123
+ LOOP
124
+ v_view := _delta_source_view(v_child.table_name, v_child.temporal);
125
+
126
+ FOR v_child_row IN
127
+ EXECUTE format('SELECT id FROM %I WHERE %I = $1 ORDER BY id', v_view, v_child.parent_fk)
128
+ USING p_id
129
+ LOOP
130
+ v_ops := v_ops || _delta_cascade_remove(v_child.collection_key, v_child_row.id);
131
+ END LOOP;
132
+ END LOOP;
133
+
134
+ -- Cascade via cascadeOn (referencedBy)
135
+ -- Find collections whose cascade_on references this collection
136
+ FOR v_ref IN
137
+ SELECT c.collection_key, c.table_name, c.temporal,
138
+ elem->>'fk' AS fk_column
139
+ FROM _delta_collections c,
140
+ jsonb_array_elements(c.cascade_on) AS elem
141
+ WHERE elem->>'collection' = p_collection_key
142
+ ORDER BY c.collection_key COLLATE "C", elem->>'fk' COLLATE "C"
143
+ LOOP
144
+ v_view := _delta_source_view(v_ref.table_name, v_ref.temporal);
145
+
146
+ FOR v_ref_row IN
147
+ EXECUTE format('SELECT id FROM %I WHERE %I = $1 ORDER BY id', v_view, v_ref.fk_column)
148
+ USING p_id
149
+ LOOP
150
+ v_ops := v_ops || _delta_cascade_remove(v_ref.collection_key, v_ref_row.id);
151
+ END LOOP;
152
+ END LOOP;
153
+
154
+ RETURN v_ops;
155
+ END;
156
+ $$ LANGUAGE plpgsql;
157
+
158
+ -- ---------------------------------------------------------------------------
159
+ -- _delta_cascade_rows: the rows _delta_cascade_remove would take, read before
160
+ -- it takes them, in the order it emits their removes -- so who holds each can
161
+ -- be asked while every row of every chain is still there.
162
+ -- ---------------------------------------------------------------------------
163
+
164
+ DROP FUNCTION IF EXISTS _delta_cascade_rows(TEXT, BIGINT, TEXT[]);
165
+ CREATE OR REPLACE FUNCTION _delta_cascade_rows(
166
+ p_collection_key TEXT,
167
+ p_id BIGINT
168
+ ) RETURNS JSONB AS $$
169
+ DECLARE
170
+ v_coll RECORD;
171
+ v_view TEXT;
172
+ v_row JSONB;
173
+ v_child RECORD;
174
+ v_ref RECORD;
175
+ v_id BIGINT;
176
+ v_rows JSONB := '[]'::jsonb;
177
+ BEGIN
178
+ SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_collection_key;
179
+ IF NOT FOUND THEN RETURN v_rows; END IF;
180
+ v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
181
+ EXECUTE format('SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1', v_view) INTO v_row USING p_id;
182
+ IF v_row IS NULL THEN RETURN v_rows; END IF;
183
+ v_rows := jsonb_build_array(jsonb_build_object('coll', p_collection_key, 'id', p_id, 'row', _delta_strip_temporal(v_row)));
184
+
185
+ FOR v_child IN
186
+ SELECT * FROM _delta_collections
187
+ WHERE parent_collection = p_collection_key
188
+ ORDER BY collection_key COLLATE "C"
189
+ LOOP
190
+ FOR v_id IN EXECUTE format('SELECT id FROM %I WHERE %I = $1 ORDER BY id', _delta_source_view(v_child.table_name, v_child.temporal), v_child.parent_fk) USING p_id
191
+ LOOP
192
+ v_rows := v_rows || _delta_cascade_rows(v_child.collection_key, v_id);
193
+ END LOOP;
194
+ END LOOP;
195
+
196
+ FOR v_ref IN
197
+ SELECT c.collection_key, c.table_name, c.temporal, elem->>'fk' AS fk_column
198
+ FROM _delta_collections c, jsonb_array_elements(c.cascade_on) AS elem
199
+ WHERE elem->>'collection' = p_collection_key
200
+ ORDER BY c.collection_key COLLATE "C", elem->>'fk' COLLATE "C"
201
+ LOOP
202
+ FOR v_id IN EXECUTE format('SELECT id FROM %I WHERE %I = $1 ORDER BY id', _delta_source_view(v_ref.table_name, v_ref.temporal), v_ref.fk_column) USING p_id
203
+ LOOP
204
+ v_rows := v_rows || _delta_cascade_rows(v_ref.collection_key, v_id);
205
+ END LOOP;
206
+ END LOOP;
207
+
208
+ RETURN v_rows;
209
+ END;
210
+ $$ LANGUAGE plpgsql STABLE;
211
+
212
+ -- ---------------------------------------------------------------------------
213
+ -- _delta_hanging_rows: the rows that hang from a row -- its children, theirs,
214
+ -- and on down, whichever documents include them -- parent first, each
215
+ -- {coll, id, row}. A row moved to another parent takes them along (todo #62;
216
+ -- hanging in sqlite.ts).
217
+ -- ---------------------------------------------------------------------------
218
+
219
+ CREATE OR REPLACE FUNCTION _delta_hanging_rows(p_collection_key TEXT, p_id BIGINT)
220
+ RETURNS JSONB AS $$
221
+ DECLARE
222
+ v_child RECORD;
223
+ v_part JSONB;
224
+ v_rows JSONB := '[]'::jsonb;
225
+ BEGIN
226
+ -- a child collection at a time, in one aggregate: each row by id, the rows under it after it
227
+ FOR v_child IN SELECT * FROM _delta_collections WHERE parent_collection = p_collection_key ORDER BY collection_key COLLATE "C" LOOP
228
+ EXECUTE format(
229
+ 'SELECT COALESCE(jsonb_agg(e ORDER BY t.id, n), ''[]''::jsonb) FROM %I t,
230
+ jsonb_array_elements(jsonb_build_array(jsonb_build_object(''coll'', %L, ''id'', t.id, ''row'', _delta_strip_temporal(to_jsonb(t.*))))
231
+ || _delta_hanging_rows(%L, t.id)) WITH ORDINALITY AS x(e, n)
232
+ WHERE t.%I = $1',
233
+ _delta_source_view(v_child.table_name, v_child.temporal), v_child.collection_key, v_child.collection_key, v_child.parent_fk
234
+ ) INTO v_part USING p_id;
235
+ v_rows := v_rows || v_part;
236
+ END LOOP;
237
+ RETURN v_rows;
238
+ END;
239
+ $$ LANGUAGE plpgsql STABLE;
240
+
241
+ -- ---------------------------------------------------------------------------
242
+ -- _delta_tell: tell every document that holds a row a write changed what
243
+ -- changed for it (todo #28), and answer the writer's new version.
244
+ --
245
+ -- `p_touched` is the write's rows in order, each {coll, id, before, after}:
246
+ -- `before` the documents that held it before that touch (_delta_holders, asked
247
+ -- then), `after` the row as the touch left it (null when it is gone), and a
248
+ -- replace's `was`, the row as it was before (a document that holds the row
249
+ -- either way is told only the fields that changed: set by the write, a
250
+ -- default or a trigger -- each a replace of its field, in field order); a
251
+ -- `carried` row hangs under one the write moved, and is told only where it
252
+ -- arrives or leaves (todo #62). Each
253
+ -- touch is told in turn: the documents that hold the row after it are those
254
+ -- that held it at the row's next touch, or after its last, those that hold it
255
+ -- once the write is done -- so a row made and then taken out (a drink, with its
256
+ -- course) is told its add wherever its remove is told (todo #75; tell in
257
+ -- sqlite.ts). For each document that held it before or holds it after: a row
258
+ -- that arrives is an add, one that stays a replace of each field that changed,
259
+ -- one that leaves a remove -- and where the row is the document's root, a
260
+ -- replace of the root when it arrives (null when it leaves). The
261
+ -- writer's document is always told, and told first; each other document is
262
+ -- told only what concerns it, with a version of its own, one per write.
263
+ -- `p_applied`, the write as applied, is logged once, on the writer's entry,
264
+ -- for the custom documents that watch the rows (001a's `applied`) -- only where
265
+ -- it differs from what the writer is told (a row that left the writer's
266
+ -- document, or was never in it); where they agree the entry carries none, and
267
+ -- is heard as told. Every other document's entry carries '[]'. The
268
+ -- two-argument form logs none.
269
+ -- ---------------------------------------------------------------------------
270
+
271
+ CREATE OR REPLACE FUNCTION _delta_tell(p_writer TEXT, p_touched JSONB, p_applied JSONB)
272
+ RETURNS BIGINT AS $$
273
+ DECLARE
274
+ v_rows JSONB := '[]'::jsonb;
275
+ v_t JSONB;
276
+ v_after JSONB;
277
+ v_targets TEXT[] := ARRAY[]::text[];
278
+ v_target TEXT;
279
+ v_def _delta_docs;
280
+ v_single BOOLEAN;
281
+ v_told JSONB;
282
+ v_was BOOLEAN;
283
+ v_is BOOLEAN;
284
+ v_root BOOLEAN;
285
+ v_path TEXT;
286
+ v_version BIGINT;
287
+ v_next JSONB := '{}'::jsonb; -- a row's holders, as its next touch found them
288
+ v_key TEXT;
289
+ v_i INT;
290
+ BEGIN
291
+ -- who holds each row after each touch, last touch first, and every document concerned
292
+ FOR v_i IN REVERSE COALESCE(jsonb_array_length(p_touched), 0) - 1 .. 0 LOOP
293
+ v_t := p_touched->v_i;
294
+ v_key := (v_t->>'coll') || '/' || (v_t->>'id');
295
+ v_after := CASE WHEN jsonb_typeof(v_t->'after') = 'object' THEN v_t->'after' END;
296
+ v_t := v_t || jsonb_build_object('holders',
297
+ COALESCE(v_next->v_key, to_jsonb(_delta_holders(v_t->>'coll', v_after, p_writer))));
298
+ v_next := v_next || jsonb_build_object(v_key, v_t->'before');
299
+ v_rows := jsonb_build_array(v_t) || v_rows;
300
+ v_targets := v_targets
301
+ || ARRAY(SELECT jsonb_array_elements_text(v_t->'before'))
302
+ || ARRAY(SELECT jsonb_array_elements_text(v_t->'holders'));
303
+ END LOOP;
304
+
305
+ FOR v_target IN
306
+ SELECT p_writer
307
+ UNION ALL
308
+ SELECT DISTINCT t FROM unnest(v_targets) AS t WHERE t IS DISTINCT FROM p_writer
309
+ LOOP
310
+ v_def := _delta_find_doc(v_target);
311
+ CONTINUE WHEN v_def.prefix IS NULL;
312
+ v_single := (_delta_resolve_scope(v_def, v_target)->>'mode') = 'single';
313
+ v_told := '[]'::jsonb;
314
+ FOR v_t IN SELECT jsonb_array_elements(v_rows) LOOP
315
+ v_was := (v_t->'before') ? v_target;
316
+ v_is := (v_t->'holders') ? v_target;
317
+ CONTINUE WHEN NOT v_was AND NOT v_is;
318
+ CONTINUE WHEN v_was AND v_is AND (v_t ? 'carried'); -- carried along, and held either way: unchanged for it
319
+ v_root := v_single AND (v_t->>'coll') = v_def.root_collection;
320
+ v_path := CASE WHEN v_root THEN _delta_build_path(v_t->>'coll') ELSE _delta_build_path(v_t->>'coll', v_t->>'id') END;
321
+ -- each row as this document shows it: the columns it holds (_delta_show, todo #70)
322
+ IF v_was AND v_is THEN -- stayed: what changed
323
+ v_told := v_told || COALESCE((SELECT jsonb_agg(jsonb_build_object('op', 'replace', 'path', v_path || _delta_build_path(f), 'value', v) ORDER BY f COLLATE "C")
324
+ FROM jsonb_each(_delta_show(v_def, v_t->>'coll', v_t->'after')) AS e(f, v) WHERE v IS DISTINCT FROM v_t->'was'->f), '[]'::jsonb);
325
+ ELSIF v_is THEN
326
+ v_told := v_told || jsonb_build_array(jsonb_build_object(
327
+ 'op', CASE WHEN v_root THEN 'replace' ELSE 'add' END, 'path', v_path, 'value', _delta_show(v_def, v_t->>'coll', v_t->'after')));
328
+ ELSIF v_root THEN -- gone: null, or the empty root an implied document then opens with
329
+ v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'replace', 'path', v_path, 'value',
330
+ CASE WHEN COALESCE(v_def.implied, FALSE) THEN _delta_show(v_def, v_t->>'coll', _delta_implied_root(v_def, (v_t->>'id')::BIGINT)) ELSE 'null'::jsonb END));
331
+ ELSE
332
+ v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'remove', 'path', v_path));
333
+ END IF;
334
+ END LOOP;
335
+ IF v_target = p_writer THEN
336
+ v_version := _delta_bump_and_notify(p_writer, v_told, CASE WHEN p_applied IS DISTINCT FROM v_told THEN p_applied END);
337
+ ELSIF jsonb_array_length(v_told) > 0 THEN
338
+ PERFORM _delta_bump_and_notify(v_target, v_told, CASE WHEN p_applied IS NOT NULL THEN '[]'::jsonb END);
339
+ END IF;
340
+ END LOOP;
341
+ RETURN v_version;
342
+ END;
343
+ $$ LANGUAGE plpgsql;
344
+
345
+ CREATE OR REPLACE FUNCTION _delta_tell(p_writer TEXT, p_touched JSONB)
346
+ RETURNS BIGINT LANGUAGE sql AS $$ SELECT _delta_tell(p_writer, p_touched, NULL); $$;
347
+
348
+ -- ---------------------------------------------------------------------------
349
+ -- _delta_row_there: is row p_id of a collection there now? A parent a row is
350
+ -- added or moved under must be, whatever document the write comes through: a
351
+ -- list holds every row of its root, but names their parents only by key, and
352
+ -- a row under a parent that is not there is no document's (an implied one
353
+ -- not yet written opens empty). `there` in sqlite.ts.
354
+ -- ---------------------------------------------------------------------------
355
+
356
+ CREATE OR REPLACE FUNCTION _delta_row_there(p_collection_key TEXT, p_id BIGINT)
357
+ RETURNS BOOLEAN AS $$
358
+ DECLARE
359
+ v_coll RECORD;
360
+ v_exists BOOLEAN;
361
+ BEGIN
362
+ SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_collection_key;
363
+ IF NOT FOUND OR p_id IS NULL THEN RETURN FALSE; END IF;
364
+ EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I WHERE id = $1)', _delta_source_view(v_coll.table_name, v_coll.temporal))
365
+ INTO v_exists USING p_id;
366
+ RETURN v_exists;
367
+ END;
368
+ $$ LANGUAGE plpgsql STABLE;
369
+
370
+ -- ---------------------------------------------------------------------------
371
+ -- _delta_assert_parent_held: a parent key written (in `p_value`, over the row
372
+ -- as it is, `p_row`) moves the row, and only under a parent the document holds,
373
+ -- as an add names one: through it, a row is never moved into another
374
+ -- document (you may write what you may read), RLS or none. A single
375
+ -- document's own root too: it holds no parent of it, so the root moves
376
+ -- through a list that holds both (0.10.0 review). The parent must be there
377
+ -- (_delta_row_there). A key written as it is, is no move. P0002 (404) when refused.
378
+ -- ---------------------------------------------------------------------------
379
+
380
+ CREATE OR REPLACE FUNCTION _delta_assert_parent_held(p_def _delta_docs, p_doc_name TEXT, p_parent_fk TEXT, p_parent TEXT, p_row JSONB, p_value JSONB)
381
+ RETURNS void AS $$
382
+ BEGIN
383
+ IF p_parent_fk IS NOT NULL
384
+ AND jsonb_typeof(p_value->p_parent_fk) IN ('number', 'string')
385
+ AND (p_value->>p_parent_fk)::BIGINT IS DISTINCT FROM (p_row->>p_parent_fk)::BIGINT
386
+ AND (NOT _delta_row_in_scope(p_def, p_doc_name, p_parent, (p_value->>p_parent_fk)::BIGINT)
387
+ OR NOT _delta_row_there(p_parent, (p_value->>p_parent_fk)::BIGINT)) THEN
388
+ RAISE EXCEPTION 'row not found: %/%', p_parent, p_value->>p_parent_fk
389
+ USING ERRCODE = 'P0002';
390
+ END IF;
391
+ END;
392
+ $$ LANGUAGE plpgsql STABLE;
393
+
394
+ -- ---------------------------------------------------------------------------
395
+ -- _delta_whole: a replace of a row itself (`/<coll>/<id>`, a single
396
+ -- document's `/<root>`) is the row, as RFC 6902 says: each column
397
+ -- (`p_columns`) holds what an add gives one its value leaves out -- its
398
+ -- default, or null -- the row as blank as that, for the row as it is to be
399
+ -- written over and the value merged over that. Its id and parent key, where
400
+ -- it is, stay. A required column the value leaves out (not nullable, no
401
+ -- default) is the writer's mistake: 23502, 400 on the wire, as for an add,
402
+ -- and asked with the value's fields, before whether the row is there (todo
403
+ -- #3; replaceRow in sqlite.ts).
404
+ -- ---------------------------------------------------------------------------
405
+
406
+ -- _delta_held_columns: the columns of `p_coll` (`p_columns`, its columns_def)
407
+ -- a document holds -- those it names (`held`, todo #70), else all. A write
408
+ -- through it names those alone (_delta_assert_fields: 400 for another, as for
409
+ -- a column there is not), and replacing a row whole sets those alone.
410
+
411
+ CREATE OR REPLACE FUNCTION _delta_held_columns(p_def _delta_docs, p_coll TEXT, p_columns JSONB)
412
+ RETURNS JSONB AS $$
413
+ SELECT CASE WHEN NOT COALESCE(p_def.held, '{}'::jsonb) ? p_coll THEN p_columns
414
+ ELSE COALESCE((SELECT jsonb_object_agg(k, v) FROM jsonb_each(p_columns) AS e(k, v) WHERE (p_def.held->p_coll) ? k), '{}'::jsonb) END;
415
+ $$ LANGUAGE sql STABLE;
416
+
417
+ CREATE OR REPLACE FUNCTION _delta_whole(p_columns JSONB, p_value JSONB)
418
+ RETURNS JSONB AS $$
419
+ DECLARE
420
+ v_missing TEXT;
421
+ BEGIN
422
+ SELECT string_agg(col_key, ', ' ORDER BY col_key) INTO v_missing
423
+ FROM jsonb_each(p_columns) AS x(col_key, col_def)
424
+ WHERE NOT p_value ? col_key AND NOT COALESCE((col_def->>'nullable')::boolean, false) AND NOT col_def ? 'default';
425
+ IF v_missing IS NOT NULL THEN
426
+ RAISE EXCEPTION 'Required field missing: % (give it a value, or declare a default or make it nullable in the schema)', v_missing
427
+ USING ERRCODE = '23502';
428
+ END IF;
429
+ RETURN COALESCE((SELECT jsonb_object_agg(col_key, COALESCE(col_def->'default', 'null'::jsonb)) FROM jsonb_each(p_columns) AS x(col_key, col_def)), '{}'::jsonb);
430
+ END;
431
+ $$ LANGUAGE plpgsql IMMUTABLE;
432
+
433
+ -- ---------------------------------------------------------------------------
434
+ -- delta_apply: apply delta ops to relational tables, bump version, NOTIFY
435
+ --
436
+ -- Handles:
437
+ -- replace /<root> → the root row, whole (_delta_whole)
438
+ -- replace /<root>/field → temporal update on root row
439
+ -- add /<collection>/<id> → insert new row
440
+ -- remove /<collection>/<id> → temporal close + cascades
441
+ -- replace /<collection>/<id>/f → temporal update on collection row field
442
+ --
443
+ -- Returns {version, ops} where ops are the broadcast ops.
444
+ --
445
+ -- `p_walk`: the write is a walk of the ledger, an undo or redo (001g's
446
+ -- delta_apply_logged says so for delta_walk). A single document whose root is
447
+ -- gone takes no writes but a walk, which may put that root back (todo #43,
448
+ -- #59), or its root's add in a write that took it out (todo #61). The
449
+ -- two-argument form is no walk.
450
+ -- ---------------------------------------------------------------------------
451
+
452
+ CREATE OR REPLACE FUNCTION delta_apply(p_doc_name TEXT, p_ops JSONB, p_walk BOOLEAN)
453
+ RETURNS JSONB AS $$
454
+ DECLARE
455
+ v_def _delta_docs;
456
+ v_scope JSONB;
457
+ v_is_list BOOLEAN;
458
+ v_doc_id BIGINT;
459
+ v_root_coll RECORD;
460
+ v_op JSONB;
461
+ v_parts TEXT[];
462
+ v_coll_key TEXT;
463
+ v_coll RECORD;
464
+ v_view TEXT;
465
+ v_root_view TEXT;
466
+ v_opened BOOLEAN; -- a single document's root was there as the write began
467
+ v_back BOOLEAN; -- this op adds that root back, taken out earlier in the write
468
+ v_root_was JSONB; -- that root as this write took it out: added back, it goes under the same parent
469
+ v_id BIGINT;
470
+ v_id_text TEXT;
471
+ v_field TEXT;
472
+ v_row JSONB;
473
+ v_new_row JSONB;
474
+ v_blank JSONB; -- a replace of the row whole: each column as an add leaves it (_delta_whole); of a field, nothing
475
+ v_cols JSONB; -- the op's collection's columns the document holds (_delta_held_columns); a walk's, all
476
+ v_exists BOOLEAN;
477
+ v_missing TEXT;
478
+ v_ts TIMESTAMPTZ := NOW();
479
+ v_version BIGINT;
480
+ v_broadcast_ops JSONB := '[]'::jsonb;
481
+ -- the rows this write changes, in order, for _delta_tell: {coll, id, before, after}
482
+ v_touched JSONB := '[]'::jsonb;
483
+ v_before TEXT[];
484
+ v_befores JSONB;
485
+ v_removed JSONB;
486
+ v_r JSONB;
487
+ v_rp TEXT[];
488
+ v_told_path TEXT;
489
+ v_run_at INT; -- the touched entry of the run of replaces under way
490
+ v_run_carried BOOLEAN := FALSE; -- that run has carried the rows under its row along
491
+ v_hung JSONB;
492
+ v_outside BOOLEAN; -- the op's collection is neither the root nor included: a walk's, put back or taken again
493
+ v_passed JSONB := '{}'::jsonb; -- each table's highest id this write named: its serial passes it once the write is done
494
+ BEGIN
495
+ -- Guard: ops must be a JSON array
496
+ IF p_ops IS NULL OR jsonb_typeof(p_ops) != 'array' THEN
497
+ RAISE EXCEPTION 'ops must be a JSON array';
498
+ END IF;
499
+ -- every path is a JSON Pointer, asked of the whole write before any op (400): a grammar, not a row
500
+ PERFORM _delta_split_path(o->>'path') FROM jsonb_array_elements(p_ops) AS o;
501
+
502
+ v_def := _delta_find_doc(p_doc_name);
503
+ IF v_def.prefix IS NULL THEN
504
+ RAISE EXCEPTION 'no doc def for: %', p_doc_name;
505
+ END IF;
506
+
507
+ v_scope := _delta_resolve_scope(v_def, p_doc_name);
508
+ -- a document named with a time reads it as it stood: what is written now is not its to write (403)
509
+ IF v_scope->>'at' IS NOT NULL THEN
510
+ RAISE EXCEPTION '% reads % as it stood at %: it takes no writes. Write through a document with no time in its name.',
511
+ p_doc_name, v_def.root_collection, left(v_scope->>'at', 10) USING ERRCODE = '25006';
512
+ END IF;
513
+ v_is_list := (v_scope->>'mode') = 'list';
514
+ IF NOT v_is_list THEN
515
+ v_doc_id := (v_scope->'values'->>'id')::BIGINT;
516
+ END IF;
517
+
518
+ SELECT * INTO v_root_coll FROM _delta_collections
519
+ WHERE collection_key = v_def.root_collection;
520
+ v_root_view := _delta_source_view(v_root_coll.table_name, v_root_coll.temporal);
521
+
522
+ -- an implied document's first write makes its root row (todo #34), told as a
523
+ -- row carried in: an add to a list of those roots, nothing to the implied
524
+ -- documents, which showed it already (ensureImpliedRoot in sqlite.ts); an
525
+ -- empty write makes nothing
526
+ IF NOT v_is_list AND COALESCE(v_def.implied, FALSE) AND jsonb_array_length(p_ops) > 0 THEN
527
+ EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I WHERE id = $1)', v_root_view) INTO v_exists USING v_doc_id;
528
+ PERFORM _delta_make_implied(v_def, v_doc_id, p_ops, v_ts);
529
+ IF NOT v_exists THEN
530
+ v_passed := jsonb_build_object(v_root_coll.table_name, v_doc_id);
531
+ EXECUTE format('SELECT _delta_strip_temporal(to_jsonb(t.*)) FROM %I t WHERE t.id = $1', v_root_view) INTO v_row USING v_doc_id;
532
+ IF v_row IS NOT NULL THEN
533
+ v_touched := jsonb_build_array(jsonb_build_object('coll', v_def.root_collection, 'id', v_doc_id, 'after', v_row, 'carried', TRUE,
534
+ 'before', COALESCE((SELECT jsonb_agg(n) FROM unnest(_delta_holders(v_def.root_collection, v_row, p_doc_name)) AS n
535
+ WHERE COALESCE((_delta_find_doc(n)).implied, FALSE)
536
+ AND _delta_resolve_scope(_delta_find_doc(n), n)->>'mode' = 'single'), '[]'::jsonb)));
537
+ END IF;
538
+ END IF;
539
+ END IF;
540
+
541
+ -- An empty write is asked what each op is (below): a single document whose
542
+ -- root is not there is not found, as on SQLite -- unless implied, or a walk.
543
+ IF jsonb_array_length(p_ops) = 0 AND NOT v_is_list AND NOT COALESCE(p_walk, FALSE) AND NOT COALESCE(v_def.implied, FALSE) THEN
544
+ EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I t WHERE t.id = $1 AND %s)', v_root_view, v_scope->>'where') INTO v_exists USING v_doc_id;
545
+ IF NOT v_exists THEN
546
+ RAISE EXCEPTION 'document not found: % (its root, %, is not there)',
547
+ p_doc_name, _delta_build_path(v_def.root_collection, v_doc_id::text)
548
+ USING ERRCODE = 'P0002';
549
+ END IF;
550
+ END IF;
551
+
552
+ FOR v_op IN SELECT jsonb_array_elements(p_ops)
553
+ LOOP
554
+ -- A single document is its root row and what hangs from it. One whose
555
+ -- root is not there -- taken out, through it or another, or never there --
556
+ -- opens as not found, and takes no writes (todo #59): asked before each
557
+ -- op, so an add under a root that is gone makes no orphan. But a write
558
+ -- through a document that opened (its root there as the write began, the
559
+ -- first op's answer; an implied one always opens, todo #34) may add that
560
+ -- root back after taking it out, and
561
+ -- write on through it: a remove and an add of the root in one write is
562
+ -- one row, walked as one (_delta_walk_key). A walk is not asked: an undo
563
+ -- of the root's removal puts it back (todo #43), and a walk of such a
564
+ -- document starts from the root absent, guarded by its plan
565
+ -- (_delta_walk_plan), as SQLite's and ./json's are.
566
+ IF NOT v_is_list AND NOT COALESCE(p_walk, FALSE) THEN -- a walk not said (null) is none
567
+ -- there, and meeting the whole of its name's scope (a column beside its id), as delta_open reads it
568
+ EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I t WHERE t.id = $1 AND %s)', v_root_view, v_scope->>'where') INTO v_exists USING v_doc_id;
569
+ v_opened := COALESCE(v_opened, v_exists OR COALESCE(v_def.implied, FALSE));
570
+ v_back := FALSE;
571
+ IF NOT v_exists AND v_opened AND v_op->>'op' = 'add' THEN
572
+ v_parts := _delta_split_path(v_op->>'path');
573
+ v_back := array_length(v_parts, 1) = 2 AND v_parts[1] = v_def.root_collection
574
+ AND v_parts[2] ~ '^[0-9]+$' AND v_parts[2]::numeric = v_doc_id;
575
+ END IF;
576
+ IF NOT v_exists AND NOT v_back THEN
577
+ RAISE EXCEPTION 'document not found: % (its root, %, is not there)',
578
+ p_doc_name, _delta_build_path(v_def.root_collection, v_doc_id::text)
579
+ USING ERRCODE = 'P0002';
580
+ END IF;
581
+ END IF;
582
+
583
+ v_parts := _delta_split_path(v_op->>'path');
584
+ v_coll_key := v_parts[1];
585
+
586
+ SELECT * INTO v_coll FROM _delta_collections
587
+ WHERE collection_key = v_coll_key;
588
+ IF NOT FOUND THEN
589
+ RAISE EXCEPTION 'unknown collection: %', v_coll_key USING ERRCODE = '22023';
590
+ END IF;
591
+ -- a walk puts back what the ledger recorded, whole
592
+ -- (the columns this process declares, _delta_cols; a walk's, every column the row was stored with too)
593
+ v_cols := CASE WHEN COALESCE(p_walk, FALSE)
594
+ THEN COALESCE((SELECT jsonb_object_agg(c, '{"nullable": true}'::jsonb) FROM unnest(_delta_stored_columns(v_coll.table_name)) AS c
595
+ WHERE c NOT IN ('id', 'valid_from', 'valid_to') AND c IS DISTINCT FROM v_coll.parent_fk), '{}'::jsonb)
596
+ || _delta_cols(v_coll_key, v_coll.columns_def)
597
+ ELSE _delta_held_columns(v_def, v_coll_key, _delta_cols(v_coll_key, v_coll.columns_def)) END;
598
+
599
+ -- Scope guard: an op may only target the doc's root or an included
600
+ -- collection. Defence-in-depth — a client that opened doc A must not write
601
+ -- to an unrelated collection through it. (The TS validateOps enforces the
602
+ -- same pre-flight, but this guards every path into delta_apply, including
603
+ -- SQL-side composition via delta_apply_as.)
604
+ -- A walk may put back, or take again, a row outside the document that its
605
+ -- entry's cascade reached (the schema's, not the include's): its plan
606
+ -- guards it against the tables (_delta_walk_plan).
607
+ v_outside := v_coll_key IS DISTINCT FROM v_def.root_collection
608
+ AND NOT (v_coll_key = ANY(COALESCE(v_def.include, ARRAY[]::text[])));
609
+ IF v_outside AND NOT (COALESCE(p_walk, FALSE) AND array_length(v_parts, 1) = 2 AND v_op->>'op' IN ('add', 'remove')) THEN
610
+ RAISE EXCEPTION
611
+ 'op collection "%" is not part of doc "%" (root: %, include: %)',
612
+ v_coll_key, v_def.prefix, v_def.root_collection, v_def.include
613
+ USING ERRCODE = '22023';
614
+ END IF;
615
+
616
+ v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
617
+
618
+ -- ---------------------------------------------------------------
619
+ -- Root replace: replace /<root> (partial row merge)
620
+ -- replace /<root>/field (single field shorthand)
621
+ -- Single-row docs only.
622
+ -- ---------------------------------------------------------------
623
+ IF NOT v_is_list AND v_coll_key = v_def.root_collection
624
+ AND array_length(v_parts, 1) <= 2 AND v_op->>'op' = 'replace'
625
+ AND (array_length(v_parts, 1) = 1
626
+ OR (array_length(v_parts, 1) = 2 AND v_parts[2] ~ '^\d+$' IS FALSE)) THEN
627
+
628
+ -- 2-segment /root/field: wrap into partial row. A row's id is not a
629
+ -- field: its path (here, the document's name) says it.
630
+ IF array_length(v_parts, 1) = 2 THEN
631
+ IF v_parts[2] = 'id' THEN
632
+ RAISE EXCEPTION 'Unknown field: id (a row keeps the id its path names)' USING ERRCODE = '22023';
633
+ END IF;
634
+ v_op := jsonb_set(v_op, '{value}', jsonb_build_object(v_parts[2], v_op->'value'));
635
+ END IF;
636
+ PERFORM _delta_assert_fields(v_coll_key, v_cols, v_coll.parent_fk, v_op->'value');
637
+ v_blank := CASE WHEN array_length(v_parts, 1) = 1 THEN _delta_whole(v_cols, v_op->'value') ELSE '{}'::jsonb END;
638
+
639
+ -- Read + lock
640
+ IF v_coll.temporal THEN
641
+ EXECUTE format(
642
+ 'SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1 AND valid_to IS NULL FOR UPDATE',
643
+ v_coll.table_name
644
+ ) INTO v_row USING v_doc_id;
645
+ ELSE
646
+ EXECUTE format(
647
+ 'SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1 FOR UPDATE',
648
+ v_coll.table_name
649
+ ) INTO v_row USING v_doc_id;
650
+ END IF;
651
+
652
+ IF v_row IS NULL THEN
653
+ RAISE EXCEPTION 'root row not found: %', v_def.root_collection USING ERRCODE = 'P0002';
654
+ END IF;
655
+ PERFORM _delta_assert_parent_held(v_def, p_doc_name, v_coll.parent_fk, v_coll.parent_collection, v_row, v_op->'value');
656
+ v_before := _delta_holders(v_coll_key, _delta_strip_temporal(v_row), p_doc_name);
657
+
658
+ -- `/<root>` is the row whole (_delta_whole); `/<root>/<field>` that field.
659
+ -- The row keeps its id: one in the value is the document's, as an add's
660
+ -- is the path's (it renumbered the row).
661
+ v_new_row := v_row || v_blank || (v_op->'value') || jsonb_build_object('id', v_doc_id);
662
+
663
+ IF v_coll.temporal THEN
664
+ -- One version per write: a version this write made (its valid_from is the
665
+ -- write's NOW()) gives way to the next, rather than closing at the same
666
+ -- instant and colliding with it on (id, valid_from).
667
+ EXECUTE format(
668
+ 'DELETE FROM %I WHERE id = $1 AND valid_to IS NULL AND valid_from = $2',
669
+ v_coll.table_name
670
+ ) USING v_doc_id, v_ts;
671
+ EXECUTE format(
672
+ 'UPDATE %I SET valid_to = $2 WHERE id = $1 AND valid_to IS NULL',
673
+ v_coll.table_name
674
+ ) USING v_doc_id, v_ts;
675
+
676
+ v_new_row := v_new_row || jsonb_build_object('valid_from', v_ts, 'valid_to', NULL);
677
+
678
+ EXECUTE format(
679
+ 'INSERT INTO %I AS t SELECT * FROM jsonb_populate_record(null::%I, $1) RETURNING to_jsonb(t.*)',
680
+ v_coll.table_name, v_coll.table_name
681
+ ) INTO v_new_row USING v_new_row;
682
+
683
+ v_new_row := _delta_strip_temporal(v_new_row);
684
+ ELSE
685
+ EXECUTE format('DELETE FROM %I WHERE id = $1', v_coll.table_name) USING v_doc_id;
686
+ EXECUTE format(
687
+ 'INSERT INTO %I AS t SELECT * FROM jsonb_populate_record(null::%I, $1) RETURNING to_jsonb(t.*)',
688
+ v_coll.table_name, v_coll.table_name
689
+ ) INTO v_new_row USING v_new_row;
690
+ END IF;
691
+
692
+ -- The root written through its document stays in its scope, as a list's
693
+ -- rows do (below): one written out of the rest of its name's scope -- a
694
+ -- seat of fo-seat-of:1:3 to table 7 -- is refused, and the write undone
695
+ -- (0.10.0 review).
696
+ IF NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_doc_id) THEN
697
+ RAISE EXCEPTION 'row not found: %/% -- the write would take it out of %',
698
+ v_coll_key, v_doc_id, p_doc_name
699
+ USING ERRCODE = 'P0002';
700
+ END IF;
701
+
702
+ -- A replace straight after a replace of the same row is one run: answered,
703
+ -- logged and told once, as the run leaves it (who held it before, the
704
+ -- run's first). SQLite's applyOps keeps the same rule.
705
+ v_told_path := _delta_build_path(v_def.root_collection);
706
+ IF v_broadcast_ops->-1->>'op' = 'replace' AND v_broadcast_ops->-1->>'path' = v_told_path THEN
707
+ v_broadcast_ops := jsonb_set(v_broadcast_ops, '{-1,value}', v_new_row);
708
+ v_touched := jsonb_set(v_touched, ARRAY[v_run_at::text, 'after'], v_new_row);
709
+ ELSE
710
+ v_broadcast_ops := v_broadcast_ops || jsonb_build_array(
711
+ jsonb_build_object('op', 'replace', 'path', v_told_path, 'value', v_new_row)
712
+ );
713
+ v_touched := v_touched || jsonb_build_array(jsonb_build_object(
714
+ 'coll', v_coll_key, 'id', v_doc_id, 'before', to_jsonb(v_before), 'after', v_new_row, 'was', CASE WHEN v_coll.temporal THEN _delta_strip_temporal(v_row) ELSE v_row END));
715
+ v_run_at := jsonb_array_length(v_touched) - 1;
716
+ v_run_carried := FALSE;
717
+ END IF;
718
+ CONTINUE;
719
+ END IF;
720
+
721
+ -- ---------------------------------------------------------------
722
+ -- Add row: add /<collection>/<id>
723
+ -- ---------------------------------------------------------------
724
+ IF array_length(v_parts, 1) = 2 AND v_op->>'op' = 'add' THEN
725
+ -- ... and it holds one root row, the one its name names: it adds no
726
+ -- other (todo #52). A row it could not read would be told to no one.
727
+ -- Its own it adds only when it is gone (above: taken out earlier in this
728
+ -- write, or by a walk, an undo of its removal); there, the live check
729
+ -- below answers 409.
730
+ IF NOT v_is_list AND v_coll_key = v_def.root_collection
731
+ AND (v_parts[2] !~ '^[0-9]+$' OR v_parts[2]::numeric <> v_doc_id) THEN
732
+ RAISE EXCEPTION 'invalid op: add % -- document % holds one %, its root: add it through a document that lists them',
733
+ v_op->>'path', p_doc_name, v_def.root_collection
734
+ USING ERRCODE = '22023';
735
+ END IF;
736
+ PERFORM _delta_assert_fields(v_coll_key, v_cols, v_coll.parent_fk, v_op->'value');
737
+ v_id_text := v_parts[2];
738
+ -- Auto-generate ID from sequence if path ends with '-'
739
+ IF v_id_text = '-' THEN
740
+ EXECUTE format('SELECT nextval(%L)', 'seq_' || v_coll.table_name) INTO v_id;
741
+ ELSE
742
+ v_id := _delta_row_id(v_coll_key, v_id_text);
743
+ END IF;
744
+ -- An add makes a new row, under an id the serial has not handed out
745
+ -- (todo #71): one it has (minted, or passed for an add that named it) is
746
+ -- refused, whether a row holds it now or none does -- or RLS hides it --
747
+ -- so the answer says nothing of rows the writer may not read. But for
748
+ -- ids the writer has read: a walk puts back the rows its entry took, and
749
+ -- a write may add back a row it has itself written (a single document's
750
+ -- root taken out and put back). A single document's own root is no
751
+ -- other: whether RLS hides it, its name does not say. SQLite's applyOps
752
+ -- keeps the same rule.
753
+ --
754
+ -- A temporal key is (id, valid_from), so nothing in the table stops a
755
+ -- second live version of an id (todo #16): a mint can meet a row put in
756
+ -- by hand, and two writers through two documents can name one id at
757
+ -- once. A lock on the id, held to commit, and the checks under it do: a
758
+ -- second add waits for the first, then sees the serial past it (and its
759
+ -- row: each statement reads what is committed) and is refused (SQLSTATE
760
+ -- 23505, 409 on the wire), as a plain table's key refuses it.
761
+ IF v_coll.temporal THEN
762
+ PERFORM pg_advisory_xact_lock(hashtext('delta-row:' || v_coll.table_name), hashtext(v_id::text));
763
+ END IF;
764
+ v_exists := FALSE;
765
+ IF v_id_text <> '-' AND NOT COALESCE(p_walk, FALSE)
766
+ AND NOT v_touched @> jsonb_build_array(jsonb_build_object('coll', v_coll_key, 'id', v_id)) THEN
767
+ v_exists := NOT _delta_serial_new(v_coll.table_name, v_id);
768
+ END IF;
769
+ IF NOT v_exists AND (v_id_text <> '-' OR v_coll.temporal) THEN
770
+ EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I WHERE id = $1)', v_view) INTO v_exists USING v_id;
771
+ END IF;
772
+ IF v_exists THEN
773
+ RAISE EXCEPTION 'id already taken: % -- add to % for a new id',
774
+ _delta_build_path(v_coll_key, v_id::text), _delta_build_path(v_coll_key, '-')
775
+ USING ERRCODE = '23505';
776
+ END IF;
777
+ -- The path names the row, whatever the value says.
778
+ v_new_row := (v_op->'value') || jsonb_build_object('id', v_id);
779
+
780
+ -- Set FK: for list-mode root adds, apply scope equality values, each as
781
+ -- the scope's condition reads it (_delta_scope_row);
782
+ -- for child collections in single-mode, set FK to root id. In list-mode
783
+ -- child adds (e.g. a product-catalog doc adding a `parts` row) there is
784
+ -- no doc-id to inject, so we trust the parent_fk supplied in the op value.
785
+ IF v_is_list AND v_coll_key = v_def.root_collection THEN
786
+ v_new_row := v_new_row || _delta_scope_row(_delta_cols(v_coll_key, v_coll.columns_def), v_scope->'values');
787
+ ELSIF v_coll.parent_collection = v_def.root_collection AND v_coll.parent_fk IS NOT NULL
788
+ AND v_doc_id IS NOT NULL THEN
789
+ v_new_row := v_new_row || jsonb_build_object(v_coll.parent_fk, v_doc_id);
790
+ END IF;
791
+
792
+ -- A direct child's FK was just injected above, so it is in scope by
793
+ -- construction. A grandchild's arrives verbatim from the client — without
794
+ -- this check it grafts the new row onto ANOTHER doc's parent. A single
795
+ -- document's root row is not under its parent: its value names it. A
796
+ -- list's rows each name theirs, a root row's child too: under a root a
797
+ -- scoped list does not hold, it is refused, as SQLite's parentHeld does.
798
+ IF v_coll.parent_collection IS NOT NULL AND NOT v_outside
799
+ AND (v_is_list OR (v_coll.parent_collection IS DISTINCT FROM v_def.root_collection
800
+ AND v_coll_key IS DISTINCT FROM v_def.root_collection))
801
+ AND NOT _delta_row_in_scope(
802
+ v_def, p_doc_name, v_coll.parent_collection,
803
+ (v_new_row->>v_coll.parent_fk)::BIGINT) THEN
804
+ RAISE EXCEPTION 'row not found: %/%',
805
+ v_coll.parent_collection, COALESCE(v_new_row->>v_coll.parent_fk, '')
806
+ USING ERRCODE = 'P0002';
807
+ END IF;
808
+ -- ... and whatever the document, the parent it names must be there (a list names it only by key)
809
+ IF v_coll.parent_fk IS NOT NULL AND v_new_row->>v_coll.parent_fk IS NOT NULL
810
+ AND NOT _delta_row_there(v_coll.parent_collection, (v_new_row->>v_coll.parent_fk)::BIGINT) THEN
811
+ RAISE EXCEPTION 'row not found: %/%', v_coll.parent_collection, v_new_row->>v_coll.parent_fk
812
+ USING ERRCODE = 'P0002';
813
+ END IF;
814
+
815
+ -- ... and a single document's own root, added back in the write that took
816
+ -- it out, goes back under the parent it had: another would be a move, and
817
+ -- the document holds no parent of its root (0.10.0 review).
818
+ IF NOT v_is_list AND v_coll_key = v_def.root_collection AND v_coll.parent_fk IS NOT NULL
819
+ AND v_root_was IS NOT NULL
820
+ AND (v_new_row->>v_coll.parent_fk)::BIGINT IS DISTINCT FROM (v_root_was->>v_coll.parent_fk)::BIGINT THEN
821
+ RAISE EXCEPTION 'row not found: %/%',
822
+ v_coll.parent_collection, COALESCE(v_new_row->>v_coll.parent_fk, '')
823
+ USING ERRCODE = 'P0002';
824
+ END IF;
825
+
826
+ -- A required column (not nullable, no declared default) the value leaves
827
+ -- out is the writer's mistake: refuse it (SQLSTATE 23502, 400 on the
828
+ -- wire) rather than store '' / 0 / false for it.
829
+ SELECT string_agg(col_key, ', ' ORDER BY col_key) INTO v_missing
830
+ FROM jsonb_each(_delta_cols(v_coll_key, v_coll.columns_def)) AS x(col_key, col_def)
831
+ WHERE NOT v_new_row ? col_key
832
+ AND NOT COALESCE((col_def->>'nullable')::boolean, false)
833
+ AND NOT col_def ? 'default';
834
+ IF v_missing IS NOT NULL THEN
835
+ RAISE EXCEPTION 'Required field missing: % (give it a value, or declare a default or make it nullable in the schema)', v_missing
836
+ USING ERRCODE = '23502';
837
+ END IF;
838
+
839
+ -- Declared column defaults, for what the value leaves out
840
+ SELECT v_new_row || COALESCE(jsonb_object_agg(col_key, col_def->'default'), '{}'::jsonb)
841
+ INTO v_new_row
842
+ FROM jsonb_each(_delta_cols(v_coll_key, v_coll.columns_def)) AS x(col_key, col_def)
843
+ WHERE NOT v_new_row ? col_key AND col_def ? 'default';
844
+
845
+ IF v_coll.temporal THEN
846
+ -- One moment per write: a version this write made and took back (its
847
+ -- valid_from and valid_to both the write's NOW()) never was outside it,
848
+ -- so an add of the same id takes its place rather than colliding with it
849
+ -- on (id, valid_from). SQLite's insertCollectionRow keeps the same rule.
850
+ EXECUTE format(
851
+ 'DELETE FROM %I WHERE id = $1 AND valid_from = $2 AND valid_to = $2',
852
+ v_coll.table_name
853
+ ) USING v_id, v_ts;
854
+ v_new_row := v_new_row || jsonb_build_object('valid_from', v_ts, 'valid_to', NULL);
855
+ END IF;
856
+
857
+ -- Every write here tells the row as stored (RETURNING), not as sent: a
858
+ -- value its column casts (a scope's '1' into an integer, a date into a
859
+ -- timestamptz) is told as a later open reads it, so the broadcast, the
860
+ -- ledger's entry and undo's guard agree with the table.
861
+ EXECUTE _delta_insert_sql(v_coll.table_name, v_new_row) || ' RETURNING to_jsonb(t.*)' INTO v_new_row USING v_new_row;
862
+
863
+ -- An id the add named moves the serial past it, so a later /- does not meet it.
864
+ IF v_id_text <> '-' AND v_id > COALESCE((v_passed->>v_coll.table_name)::BIGINT, 0) THEN
865
+ v_passed := v_passed || jsonb_build_object(v_coll.table_name, v_id);
866
+ END IF;
867
+
868
+ -- A root row added through a document is one it holds: a list's, given
869
+ -- its bindings, must meet the rest of its conditions too (a name like
870
+ -- `So`, a price up to 5), and a single document's own root added back,
871
+ -- the whole of its name's scope. Else it would be made where the writer
872
+ -- cannot read it -- told [] itself, and an add to the lists it does meet
873
+ -- -- so it is refused, and the write undone (0.10.0 review, todo #66).
874
+ IF v_coll_key = v_def.root_collection
875
+ AND NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_id) THEN
876
+ RAISE EXCEPTION 'row not found: %/% -- the add is not one % holds',
877
+ v_coll_key, v_id, p_doc_name
878
+ USING ERRCODE = 'P0002';
879
+ END IF;
880
+
881
+ -- Strip temporal columns from broadcast
882
+ IF v_coll.temporal THEN
883
+ v_new_row := _delta_strip_temporal(v_new_row);
884
+ END IF;
885
+
886
+ v_broadcast_ops := v_broadcast_ops || jsonb_build_array(
887
+ jsonb_build_object('op', 'add', 'path', _delta_build_path(v_coll_key, v_id::text), 'value', v_new_row)
888
+ );
889
+ v_touched := v_touched || jsonb_build_array(jsonb_build_object(
890
+ 'coll', v_coll_key, 'id', v_id, 'before', '[]'::jsonb, 'after', v_new_row));
891
+ CONTINUE;
892
+ END IF;
893
+
894
+ -- ---------------------------------------------------------------
895
+ -- Remove row: remove /<collection>/<id> (+ cascades)
896
+ -- ---------------------------------------------------------------
897
+ IF array_length(v_parts, 1) = 2 AND v_op->>'op' = 'remove' THEN
898
+ v_id := _delta_row_id(v_coll_key, v_parts[2]);
899
+ -- _delta_cascade_remove addresses rows by id alone, so without this gate a
900
+ -- client could name any id and delete a sibling doc's row.
901
+ IF NOT v_outside AND NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_id) THEN
902
+ RAISE EXCEPTION 'row not found: %/%', v_coll_key, v_id
903
+ USING ERRCODE = 'P0002';
904
+ END IF;
905
+ -- who holds the row and every row the cascade takes, asked before any is gone
906
+ v_befores := '{}'::jsonb;
907
+ FOR v_r IN SELECT jsonb_array_elements(_delta_cascade_rows(v_coll_key, v_id)) LOOP
908
+ v_befores := v_befores || jsonb_build_object(
909
+ (v_r->>'coll') || '/' || (v_r->>'id'), to_jsonb(_delta_holders(v_r->>'coll', v_r->'row', p_doc_name)));
910
+ IF NOT v_is_list AND v_r->>'coll' = v_def.root_collection THEN v_root_was := v_r->'row'; END IF;
911
+ END LOOP;
912
+ v_removed := _delta_cascade_remove(v_coll_key, v_id);
913
+ -- A remove of a row that is not there is a 404, as on SQLite and ./json
914
+ -- (the same tables, kept in a JSON file; A6): the scope gate above lets any id of a collection a
915
+ -- document holds whole (one with no parent, a list's include) through,
916
+ -- and the cascade removes nothing (or RLS hid the row from this identity).
917
+ IF jsonb_array_length(v_removed) = 0 THEN
918
+ RAISE EXCEPTION 'row not found: %/%', v_coll_key, v_id
919
+ USING ERRCODE = 'P0002';
920
+ END IF;
921
+ v_broadcast_ops := v_broadcast_ops || v_removed;
922
+ FOR v_r IN SELECT jsonb_array_elements(v_removed) LOOP
923
+ v_rp := _delta_split_path(v_r->>'path');
924
+ v_touched := v_touched || jsonb_build_array(jsonb_build_object(
925
+ 'coll', v_rp[1], 'id', v_rp[2]::BIGINT,
926
+ 'before', COALESCE(v_befores->(v_rp[1] || '/' || v_rp[2]), '[]'::jsonb), 'after', NULL));
927
+ END LOOP;
928
+ CONTINUE;
929
+ END IF;
930
+
931
+ -- ---------------------------------------------------------------
932
+ -- Row replace: replace /<collection>/<id> (partial row merge)
933
+ -- Field replace: replace /<collection>/<id>/field (single field shorthand)
934
+ -- ---------------------------------------------------------------
935
+ IF (array_length(v_parts, 1) = 2 OR array_length(v_parts, 1) = 3) AND v_op->>'op' = 'replace' THEN
936
+ v_id := _delta_row_id(v_coll_key, v_parts[2]);
937
+
938
+ -- 3-segment: wrap single field into partial row value. A row's id is
939
+ -- not a field: its path says it. What the op says is asked before its
940
+ -- row (400 before 404), as for an add, and as SQLite's validateOps asks.
941
+ IF array_length(v_parts, 1) = 3 THEN
942
+ IF v_parts[3] = 'id' THEN
943
+ RAISE EXCEPTION 'Unknown field: id (a row keeps the id its path names)' USING ERRCODE = '22023';
944
+ END IF;
945
+ v_op := jsonb_set(v_op, '{value}', jsonb_build_object(v_parts[3], v_op->'value'));
946
+ END IF;
947
+ PERFORM _delta_assert_fields(v_coll_key, v_cols, v_coll.parent_fk, v_op->'value');
948
+ v_blank := CASE WHEN array_length(v_parts, 1) = 2 THEN _delta_whole(v_cols, v_op->'value') ELSE '{}'::jsonb END;
949
+
950
+ -- Same gate as remove: the row is addressed by bare id, so it must belong
951
+ -- to this doc. (The single-mode root branch above never reaches here — it
952
+ -- targets v_doc_id directly.)
953
+ IF NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_id) THEN
954
+ RAISE EXCEPTION 'row not found: %/%', v_coll_key, v_id
955
+ USING ERRCODE = 'P0002';
956
+ END IF;
957
+
958
+ IF v_coll.temporal THEN
959
+ EXECUTE format(
960
+ 'SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1 AND valid_to IS NULL FOR UPDATE',
961
+ v_coll.table_name
962
+ ) INTO v_row USING v_id;
963
+ ELSE
964
+ EXECUTE format(
965
+ 'SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1 FOR UPDATE',
966
+ v_coll.table_name
967
+ ) INTO v_row USING v_id;
968
+ END IF;
969
+
970
+ IF v_row IS NULL THEN
971
+ RAISE EXCEPTION 'row not found: %/%', v_coll_key, v_id USING ERRCODE = 'P0002';
972
+ END IF;
973
+ PERFORM _delta_assert_parent_held(v_def, p_doc_name, v_coll.parent_fk, v_coll.parent_collection, v_row, v_op->'value');
974
+ v_before := _delta_holders(v_coll_key, _delta_strip_temporal(v_row), p_doc_name);
975
+
976
+ -- For single-item docs updating the root entity, broadcast as /collection
977
+ -- so the client replaces the direct object (not a Record entry)
978
+ IF NOT v_is_list AND v_coll_key = v_def.root_collection AND v_id = v_doc_id THEN
979
+ v_told_path := _delta_build_path(v_coll_key);
980
+ ELSE
981
+ v_told_path := _delta_build_path(v_coll_key, v_id::text);
982
+ END IF;
983
+
984
+ -- A row moved to another parent takes the rows under it along (todo #62):
985
+ -- who holds each is asked before it moves, once a run, and each is told
986
+ -- after the row, where it arrives or leaves (endRun in sqlite.ts).
987
+ v_hung := '[]'::jsonb;
988
+ IF NOT (v_broadcast_ops->-1->>'op' = 'replace' AND v_broadcast_ops->-1->>'path' = v_told_path) THEN
989
+ v_run_carried := FALSE;
990
+ END IF;
991
+ IF NOT v_run_carried AND (v_op->'value') ? v_coll.parent_fk
992
+ AND (v_op->'value'->>v_coll.parent_fk) IS DISTINCT FROM (v_row->>v_coll.parent_fk) THEN
993
+ SELECT COALESCE(jsonb_agg(jsonb_build_object('coll', r->'coll', 'id', r->'id', 'after', r->'row', 'carried', TRUE,
994
+ 'before', to_jsonb(_delta_holders(r->>'coll', r->'row', p_doc_name))) ORDER BY n), '[]'::jsonb)
995
+ INTO v_hung FROM jsonb_array_elements(_delta_hanging_rows(v_coll_key, v_id)) WITH ORDINALITY AS e(r, n);
996
+ END IF;
997
+
998
+ -- `/<coll>/<id>` is the row whole (_delta_whole); `/<coll>/<id>/<field>`
999
+ -- that field. The row keeps its id: one in the value is the path's, as
1000
+ -- an add's is (it renumbered the row).
1001
+ v_new_row := v_row || v_blank || (v_op->'value') || jsonb_build_object('id', v_id);
1002
+
1003
+ IF v_coll.temporal THEN
1004
+ -- One version per write: a version this write made (its valid_from is the
1005
+ -- write's NOW()) gives way to the next, rather than closing at the same
1006
+ -- instant and colliding with it on (id, valid_from).
1007
+ EXECUTE format(
1008
+ 'DELETE FROM %I WHERE id = $1 AND valid_to IS NULL AND valid_from = $2',
1009
+ v_coll.table_name
1010
+ ) USING v_id, v_ts;
1011
+ EXECUTE format(
1012
+ 'UPDATE %I SET valid_to = $2 WHERE id = $1 AND valid_to IS NULL',
1013
+ v_coll.table_name
1014
+ ) USING v_id, v_ts;
1015
+
1016
+ v_new_row := v_new_row || jsonb_build_object('valid_from', v_ts, 'valid_to', NULL);
1017
+
1018
+ EXECUTE format(
1019
+ 'INSERT INTO %I AS t SELECT * FROM jsonb_populate_record(null::%I, $1) RETURNING to_jsonb(t.*)',
1020
+ v_coll.table_name, v_coll.table_name
1021
+ ) INTO v_new_row USING v_new_row;
1022
+ ELSE
1023
+ -- Non-temporal: UPDATE with merged row
1024
+ EXECUTE format(
1025
+ 'DELETE FROM %I WHERE id = $1', v_coll.table_name
1026
+ ) USING v_id;
1027
+ EXECUTE format(
1028
+ 'INSERT INTO %I AS t SELECT * FROM jsonb_populate_record(null::%I, $1) RETURNING to_jsonb(t.*)',
1029
+ v_coll.table_name, v_coll.table_name
1030
+ ) INTO v_new_row USING v_new_row;
1031
+ END IF;
1032
+
1033
+ -- A list's root row written through the list stays in its scope, as an
1034
+ -- add through it is given its bindings: a field, a row or a merge that
1035
+ -- takes it out -- into another owner's list by its key, or out of a
1036
+ -- condition -- is refused, and the write undone (todo #6's review).
1037
+ -- Asked of the row as written, RLS or none; and of a single document's
1038
+ -- root named by its id, as above (0.10.0 review).
1039
+ IF v_coll_key = v_def.root_collection
1040
+ AND NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_id) THEN
1041
+ RAISE EXCEPTION 'row not found: %/% -- the write would take it out of %',
1042
+ v_coll_key, v_id, p_doc_name
1043
+ USING ERRCODE = 'P0002';
1044
+ END IF;
1045
+
1046
+ -- Strip temporal from broadcast
1047
+ IF v_coll.temporal THEN
1048
+ v_new_row := _delta_strip_temporal(v_new_row);
1049
+ END IF;
1050
+
1051
+ -- a replace straight after a replace of the same row: one run, told once (as the root's, above)
1052
+ IF v_broadcast_ops->-1->>'op' = 'replace' AND v_broadcast_ops->-1->>'path' = v_told_path THEN
1053
+ v_broadcast_ops := jsonb_set(v_broadcast_ops, '{-1,value}', v_new_row);
1054
+ v_touched := jsonb_set(v_touched, ARRAY[v_run_at::text, 'after'], v_new_row);
1055
+ ELSE
1056
+ v_broadcast_ops := v_broadcast_ops || jsonb_build_array(
1057
+ jsonb_build_object('op', 'replace', 'path', v_told_path, 'value', v_new_row)
1058
+ );
1059
+ v_touched := v_touched || jsonb_build_array(jsonb_build_object(
1060
+ 'coll', v_coll_key, 'id', v_id, 'before', to_jsonb(v_before), 'after', v_new_row, 'was', CASE WHEN v_coll.temporal THEN _delta_strip_temporal(v_row) ELSE v_row END));
1061
+ v_run_at := jsonb_array_length(v_touched) - 1;
1062
+ END IF;
1063
+ v_run_carried := v_run_carried OR jsonb_array_length(v_hung) > 0;
1064
+ v_touched := v_touched || v_hung;
1065
+ CONTINUE;
1066
+ END IF;
1067
+
1068
+ RAISE EXCEPTION 'invalid op: % %', v_op->>'op', v_op->>'path' USING ERRCODE = '22023';
1069
+ END LOOP;
1070
+
1071
+ -- Told: the writer's document and every other that holds a row changed,
1072
+ -- each what changed for it. The answer (and the ledger) keep the ops as
1073
+ -- written, so an undo walks back exactly what was done -- and so does the
1074
+ -- writer's entry in the log, once, for the custom documents (todo #44).
1075
+ v_version := _delta_tell(p_doc_name, v_touched, v_broadcast_ops);
1076
+
1077
+ -- The ids this write named move their serials past them, once it is done: a
1078
+ -- serial does not roll back, so a write refused part way burns none (only a
1079
+ -- failure at commit can).
1080
+ FOR v_r IN SELECT jsonb_build_object('t', key, 'id', value) FROM jsonb_each(v_passed) LOOP
1081
+ PERFORM _delta_serial_past(v_r->>'t', (v_r->>'id')::BIGINT);
1082
+ END LOOP;
1083
+ RETURN jsonb_build_object('version', v_version, 'ops', v_broadcast_ops);
1084
+ END;
1085
+ $$ LANGUAGE plpgsql;
1086
+
1087
+ CREATE OR REPLACE FUNCTION delta_apply(p_doc_name TEXT, p_ops JSONB)
1088
+ RETURNS JSONB LANGUAGE sql AS $$ SELECT delta_apply(p_doc_name, p_ops, FALSE); $$;