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,619 @@
1
+ -- =========================================================================
2
+ -- Delta-doc: doc lookup, scope resolution, temporal helpers
3
+ -- =========================================================================
4
+
5
+ -- ---------------------------------------------------------------------------
6
+ -- _delta_find_doc: match a doc name to its definition
7
+ -- ---------------------------------------------------------------------------
8
+
9
+ CREATE OR REPLACE FUNCTION _delta_find_doc(p_doc_name TEXT)
10
+ RETURNS _delta_docs AS $$
11
+ -- Exact prefix match (NOT LIKE): a prefix containing `_` or `%` would
12
+ -- otherwise be treated as a LIKE wildcard and over-match. Mirrors the
13
+ -- TypeScript resolver's startsWith.
14
+ SELECT * FROM _delta_docs
15
+ WHERE left(p_doc_name, length(prefix)) = prefix
16
+ ORDER BY length(prefix) DESC
17
+ LIMIT 1;
18
+ $$ LANGUAGE sql STABLE;
19
+
20
+ -- ---------------------------------------------------------------------------
21
+ -- _delta_resolve_scope: turn a doc name + def into WHERE clause + metadata
22
+ --
23
+ -- Scope binding syntax: { "column": "op:param" }
24
+ -- "=:name" → column = <value> (equality)
25
+ -- "<=:name" → column <= <value> (range)
26
+ -- ">=:name" → column >= <value> (range)
27
+ -- "like:name" → column ILIKE <value>% (prefix search)
28
+ -- "at:name" → reserved: temporal snapshot (not a WHERE condition)
29
+ -- ":name" → shorthand for "=:name"
30
+ --
31
+ -- Named params are resolved positionally from the colon-separated doc ID.
32
+ -- Empty resolved values are skipped (no filter applied for that entry).
33
+ --
34
+ -- A value is read as its column takes it, by one grammar on every backend (the
35
+ -- SQLite backend's scopeValue is its twin), and one it cannot take is refused
36
+ -- (22P02, a 400): an integer's is digits, signed, up to 2^53 - 1 (not 1_000 or
37
+ -- 0x10, which Postgres's own cast takes); a real's a finite decimal number (not
38
+ -- Infinity, NaN or 0x10), one a double holds; an id's or a parent key's a whole
39
+ -- number, or text no row here has (below); a timestamptz's a date, YYYY-MM-DD,
40
+ -- the instant midnight UTC begins it (the cast's own 22007 would be a 500). Other
41
+ -- types are left to the column's cast, which reads them as SQLite does.
42
+ --
43
+ -- Returns JSONB:
44
+ -- { "where": "SQL WHERE clause",
45
+ -- "values": { equality bindings for ADD ops },
46
+ -- "mode": "list" | "single",
47
+ -- "at": "2026-06-01" | null }
48
+ --
49
+ -- With p_refuse false a value refused is not raised: the answer is the scope
50
+ -- that holds nothing ("where" FALSE, list mode), with "refused" its message --
51
+ -- how the fan-out reads a name opened before a rule refused it (_delta_doc_holds).
52
+ -- ---------------------------------------------------------------------------
53
+
54
+ CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT, p_refuse BOOLEAN)
55
+ RETURNS JSONB AS $$
56
+ DECLARE
57
+ v_doc_id TEXT;
58
+ v_scope JSONB;
59
+ v_parts TEXT[];
60
+ v_key TEXT;
61
+ v_val TEXT;
62
+ v_op TEXT;
63
+ v_param TEXT;
64
+ v_resolved TEXT;
65
+ v_where TEXT := '';
66
+ v_values JSONB := '{}'::jsonb;
67
+ v_at TEXT := NULL;
68
+ v_mode TEXT;
69
+ v_param_map JSONB := '{}'::jsonb;
70
+ v_root_coll RECORD;
71
+ v_type TEXT;
72
+ v_refused TEXT;
73
+ v_cond TEXT;
74
+ v_date TEXT[];
75
+ BEGIN
76
+ v_doc_id := substring(p_doc_name FROM length(p_def.prefix) + 1);
77
+ v_scope := p_def.scope;
78
+
79
+ -- Fail-fast: every scope key must be a real column of the root collection.
80
+ -- Catches typos and the "dotted key" footgun (e.g. "kanban_boards.id")
81
+ -- before they silently produce an always-empty WHERE.
82
+ IF v_scope IS NOT NULL AND v_scope != '{}'::jsonb THEN
83
+ DECLARE
84
+ v_bad_key TEXT;
85
+ v_valid_keys_txt TEXT;
86
+ BEGIN
87
+ SELECT * INTO v_root_coll FROM _delta_collections
88
+ WHERE collection_key = p_def.root_collection;
89
+ IF NOT FOUND THEN
90
+ RAISE EXCEPTION 'doc "%" references unknown root collection: %',
91
+ p_def.prefix, p_def.root_collection
92
+ USING ERRCODE = 'P0001';
93
+ END IF;
94
+
95
+ -- "id" is implicit; parent_fk is implicit if set. `at:`-typed values
96
+ -- feed the temporal snapshot marker and don't go through the WHERE,
97
+ -- so their keys (typically `valid_from`) don't need to be real columns.
98
+ SELECT k INTO v_bad_key
99
+ FROM jsonb_each_text(v_scope) AS x(k, val)
100
+ WHERE k != 'id'
101
+ AND k IS DISTINCT FROM v_root_coll.parent_fk
102
+ AND NOT (_delta_cols(v_root_coll.collection_key, v_root_coll.columns_def) ? k)
103
+ AND val !~ '^at:'
104
+ LIMIT 1;
105
+
106
+ IF v_bad_key IS NOT NULL THEN
107
+ SELECT 'id'
108
+ || CASE WHEN v_root_coll.parent_fk IS NOT NULL
109
+ THEN ', ' || v_root_coll.parent_fk
110
+ ELSE '' END
111
+ || COALESCE(
112
+ (SELECT ', ' || string_agg(kk, ', ')
113
+ FROM jsonb_object_keys(_delta_cols(v_root_coll.collection_key, v_root_coll.columns_def)) kk),
114
+ '')
115
+ INTO v_valid_keys_txt;
116
+ RAISE EXCEPTION
117
+ 'scope key "%" is not a column of "%" (valid keys: %)',
118
+ v_bad_key, v_root_coll.collection_key, v_valid_keys_txt
119
+ USING ERRCODE = 'P0001';
120
+ END IF;
121
+ END;
122
+ END IF;
123
+
124
+ -- Empty scope: default behaviour
125
+ IF v_scope = '{}'::jsonb OR v_scope IS NULL THEN
126
+ IF v_doc_id = '' THEN
127
+ RETURN jsonb_build_object(
128
+ 'where', 'TRUE', 'values', '{}'::jsonb, 'mode', 'list', 'at', NULL
129
+ );
130
+ ELSE
131
+ RETURN jsonb_build_object(
132
+ 'where', format('id = %L', v_doc_id),
133
+ 'values', jsonb_build_object('id', v_doc_id::BIGINT),
134
+ 'mode', 'single',
135
+ 'at', NULL
136
+ );
137
+ END IF;
138
+ END IF;
139
+
140
+ -- Build positional param map from colon-separated doc ID parts.
141
+ -- Params named "id" always get position 1. Remaining params are assigned
142
+ -- in alphabetical order to subsequent positions.
143
+ v_parts := string_to_array(v_doc_id, ':');
144
+
145
+ SELECT jsonb_object_agg(p, to_jsonb(COALESCE(v_parts[pos], '')))
146
+ INTO v_param_map
147
+ FROM (
148
+ SELECT DISTINCT param AS p,
149
+ row_number() OVER (ORDER BY (param != 'id'), param) AS pos
150
+ FROM (
151
+ SELECT CASE
152
+ WHEN val ~ '^(at|like):' THEN substring(val FROM '[a-z]+:(.+)$')
153
+ WHEN val ~ '^[<>=!]+:' THEN substring(val FROM '[<>=!]+:(.+)$')
154
+ ELSE ltrim(val, ':')
155
+ END AS param
156
+ FROM jsonb_each_text(v_scope) AS x(key, val)
157
+ ) params
158
+ ) ordered;
159
+
160
+ -- Second pass: process each scope entry using the resolved param map
161
+ FOR v_key, v_val IN SELECT * FROM jsonb_each_text(v_scope)
162
+ LOOP
163
+ -- Parse operator and param name
164
+ IF v_val ~ '^(at):' THEN
165
+ v_op := 'at';
166
+ v_param := substring(v_val FROM 'at:(.+)$');
167
+ ELSIF v_val ~ '^(like):' THEN
168
+ v_op := 'like';
169
+ v_param := substring(v_val FROM 'like:(.+)$');
170
+ ELSIF v_val ~ '^[<>=!]+:' THEN
171
+ v_op := substring(v_val FROM '^([<>=!]+):');
172
+ v_param := substring(v_val FROM '[<>=!]+:(.+)$');
173
+ -- Defence in depth: the regex above only matches `<`, `>`, `=`, `!`
174
+ -- characters, and the scope value is app-controlled (from _delta_docs),
175
+ -- but we still whitelist the final operator so any future exposure of
176
+ -- the scope DSL to user input can't smuggle arbitrary SQL into
177
+ -- `format('%I %s %L', ...)`.
178
+ IF v_op NOT IN ('=', '>=', '<=', '>', '<', '!=') THEN
179
+ RAISE EXCEPTION
180
+ 'invalid scope operator "%" (valid: =, >=, <=, >, <, !=, like, at)',
181
+ v_op USING ERRCODE = 'P0001';
182
+ END IF;
183
+ ELSE
184
+ v_op := '=';
185
+ v_param := ltrim(v_val, ':');
186
+ END IF;
187
+
188
+ v_resolved := v_param_map ->> v_param;
189
+
190
+ -- Skip empty resolved values (no filter)
191
+ IF v_resolved IS NULL OR v_resolved = '' THEN CONTINUE; END IF;
192
+
193
+ -- The value as its column takes it: one its column cannot take is refused.
194
+ -- A time in the name (at:) is a date, as a time column's condition is.
195
+ v_cond := NULL;
196
+ IF v_op <> 'like' THEN
197
+ v_type := CASE WHEN v_op = 'at' THEN 'timestamptz' ELSE _delta_cols(v_root_coll.collection_key, v_root_coll.columns_def)->v_key->>'type' END;
198
+ IF v_op <> 'at' AND (v_key = 'id' OR v_key = v_root_coll.parent_fk) THEN
199
+ -- An id, as SQLite reads one: a whole number, signed, up to 2^53 - 1;
200
+ -- one not whole is none (1.5, 1e0); other text is an id SQLite keeps as
201
+ -- text (a session's token). No row here has one -- every id is a number --
202
+ -- so it compares as SQLite orders it, after every number: = > >= read no
203
+ -- row, != < <= every row. (Single mode's id is the root's: still a 400.)
204
+ IF v_resolved ~ '^\s*[+-]?[0-9]+\s*$' THEN
205
+ IF v_resolved !~ '^\s*[+-]?0*[0-9]{1,16}\s*$' THEN
206
+ v_refused := format('%s %s is past 2^53 - 1, which no number holds exactly', v_key, v_resolved);
207
+ ELSIF abs(v_resolved::numeric) > 9007199254740991 THEN
208
+ v_refused := format('%s %s is past 2^53 - 1, which no number holds exactly', v_key, v_resolved);
209
+ END IF;
210
+ ELSIF v_resolved ~* '^\s*[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)(e[+-]?[0-9]+)?\s*$' THEN
211
+ v_refused := format('%s must be an id -- a whole number, or text -- not "%s"', v_key, v_resolved);
212
+ ELSE
213
+ v_cond := CASE WHEN v_op IN ('!=', '<', '<=') THEN format('%I IS NOT NULL', v_key) ELSE 'FALSE' END;
214
+ END IF;
215
+ ELSIF v_type = 'integer' THEN
216
+ -- at most 16 digits past any zeros (2^53 - 1 has 16), so the cast that follows cannot fail
217
+ IF v_resolved !~ '^\s*[+-]?0*[0-9]{1,16}\s*$' THEN
218
+ v_refused := format('%s must be an integer, not "%s"', v_key, v_resolved);
219
+ ELSIF abs(v_resolved::numeric) > 9007199254740991 THEN
220
+ v_refused := format('%s must be an integer, not "%s"', v_key, v_resolved);
221
+ END IF;
222
+ ELSIF v_type = 'real' THEN
223
+ IF v_resolved !~* '^\s*[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)(e[+-]?[0-9]+)?\s*$' THEN
224
+ v_refused := format('%s must be a number, not "%s"', v_key, v_resolved);
225
+ ELSE
226
+ BEGIN
227
+ PERFORM v_resolved::float8;
228
+ EXCEPTION WHEN numeric_value_out_of_range THEN -- 1e400, 1e-400: past what a double holds
229
+ v_refused := format('%s must be a number, not "%s"', v_key, v_resolved);
230
+ END;
231
+ END IF;
232
+ ELSIF v_type = 'timestamptz' THEN
233
+ -- A date, YYYY-MM-DD (a name's ":" separates its values, so it holds no
234
+ -- time of day), read as the instant midnight UTC begins it, whatever
235
+ -- the session's TimeZone -- as SQLite reads it; not now, 20260101 or
236
+ -- Jan 1 2026, which Postgres's own cast takes. (The day a statement of
237
+ -- its own, so make_date is asked only of a month there is.)
238
+ v_date := regexp_match(v_resolved, '^\s*([0-9]{4})-([0-9]{2})-([0-9]{2})\s*$');
239
+ IF v_date IS NULL OR v_date[1]::int < 1 OR v_date[2]::int NOT BETWEEN 1 AND 12 THEN
240
+ v_refused := format('%s must be a date, YYYY-MM-DD, not "%s"', v_key, v_resolved);
241
+ ELSIF v_date[3]::int NOT BETWEEN 1 AND extract(day FROM make_date(v_date[1]::int, v_date[2]::int, 1) + interval '1 month - 1 day')::int THEN
242
+ v_refused := format('%s must be a date, YYYY-MM-DD, not "%s"', v_key, v_resolved);
243
+ ELSE
244
+ v_resolved := format('%s-%s-%s 00:00:00+00', v_date[1], v_date[2], v_date[3]);
245
+ END IF;
246
+ END IF;
247
+ IF v_refused IS NOT NULL THEN
248
+ IF p_refuse THEN RAISE EXCEPTION '%', v_refused USING ERRCODE = '22P02'; END IF;
249
+ RETURN jsonb_build_object('where', 'FALSE', 'values', '{}'::jsonb, 'mode', 'list', 'at', NULL, 'refused', v_refused);
250
+ END IF;
251
+ END IF;
252
+
253
+ -- Handle each operator
254
+ CASE v_op
255
+ WHEN 'at' THEN
256
+ v_at := v_resolved;
257
+
258
+ WHEN 'like' THEN
259
+ IF v_where != '' THEN v_where := v_where || ' AND '; END IF;
260
+ v_where := v_where || format('%I ILIKE %L', v_key, v_resolved || '%');
261
+
262
+ WHEN '=' THEN
263
+ IF v_where != '' THEN v_where := v_where || ' AND '; END IF;
264
+ v_where := v_where || COALESCE(v_cond, format('%I = %L', v_key, v_resolved));
265
+ v_values := v_values || jsonb_build_object(v_key, v_resolved);
266
+
267
+ ELSE
268
+ -- Range operators: >=, <=, >, <, !=
269
+ IF v_where != '' THEN v_where := v_where || ' AND '; END IF;
270
+ v_where := v_where || COALESCE(v_cond, format('%I %s %L', v_key, v_op, v_resolved));
271
+ END CASE;
272
+ END LOOP;
273
+
274
+ -- Default to no filter if nothing was added
275
+ IF v_where = '' THEN v_where := 'TRUE'; END IF;
276
+
277
+ -- Mode: single if we have an id equality, list otherwise
278
+ IF v_values ? 'id' THEN
279
+ v_mode := 'single';
280
+ ELSE
281
+ v_mode := 'list';
282
+ END IF;
283
+
284
+ RETURN jsonb_build_object('where', v_where, 'values', v_values, 'mode', v_mode, 'at', v_at);
285
+ END;
286
+ $$ LANGUAGE plpgsql STABLE;
287
+
288
+ -- The resolver as every caller but the fan-out asks it: a value refused is raised.
289
+ CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT)
290
+ RETURNS JSONB LANGUAGE sql STABLE AS $$
291
+ SELECT _delta_resolve_scope(p_def, p_doc_name, TRUE);
292
+ $$;
293
+
294
+ -- ---------------------------------------------------------------------------
295
+ -- _delta_scope_row: the scope's equality values (_delta_resolve_scope's
296
+ -- `values`, the name's text), as a list-mode add of a root row is given them:
297
+ -- as the scope's condition reads each (todo #46). The condition casts its text
298
+ -- to the column's type; jsonb_populate_record does the same with a JSON string
299
+ -- for every type but json, where it keeps the string whole -- a name's 5 stored
300
+ -- as "5", which `wishes = '5'` does not match. So a json column's value is the
301
+ -- JSON its text is (5 is 5, "5" the string); every other column's stays text,
302
+ -- for the column to cast. The SQLite backend's scopeValue reads it the same way.
303
+ -- ---------------------------------------------------------------------------
304
+
305
+ CREATE OR REPLACE FUNCTION _delta_scope_row(p_columns JSONB, p_values JSONB)
306
+ RETURNS JSONB LANGUAGE sql IMMUTABLE AS $$
307
+ SELECT COALESCE(jsonb_object_agg(
308
+ k, CASE WHEN p_columns->k->>'type' = 'json' THEN (v #>> '{}')::jsonb ELSE v END), '{}'::jsonb)
309
+ FROM jsonb_each(COALESCE(p_values, '{}'::jsonb)) AS x(k, v);
310
+ $$;
311
+
312
+ -- ---------------------------------------------------------------------------
313
+ -- _delta_row_in_scope: may this doc address this row?
314
+ --
315
+ -- Mirrors the READ filtering in _delta_load_collection / delta_open exactly,
316
+ -- so the rule is simply "you may write what you may read".
317
+ --
318
+ -- Reads were always scoped, but writes addressed rows by bare id: a client
319
+ -- holding `tenant:1` could name a row id belonging to `tenant:2` and remove or
320
+ -- overwrite it. delta_apply now gates every row-addressed write through here.
321
+ -- Returning FALSE for a row that exists but is invisible (including one hidden
322
+ -- by RLS) is deliberate — it keeps "forbidden" and "absent" indistinguishable
323
+ -- so the gate can't be used to probe for ids.
324
+ -- ---------------------------------------------------------------------------
325
+
326
+ CREATE OR REPLACE FUNCTION _delta_row_in_scope(
327
+ p_def _delta_docs,
328
+ p_doc_name TEXT,
329
+ p_collection_key TEXT,
330
+ p_id BIGINT
331
+ ) RETURNS BOOLEAN AS $$
332
+ DECLARE
333
+ v_scope JSONB;
334
+ v_is_list BOOLEAN;
335
+ v_root_id BIGINT;
336
+ v_coll RECORD;
337
+ v_cur_coll TEXT;
338
+ v_cur_id BIGINT;
339
+ v_view TEXT;
340
+ v_exists BOOLEAN;
341
+ v_depth INT := 0;
342
+ BEGIN
343
+ IF p_id IS NULL THEN RETURN FALSE; END IF;
344
+
345
+ v_scope := _delta_resolve_scope(p_def, p_doc_name);
346
+ v_is_list := (v_scope->>'mode') = 'list';
347
+
348
+ IF v_is_list THEN
349
+ -- List mode: `include`d collections are loaded in FULL by
350
+ -- _delta_load_collection_all (no FK filter), so every row of them is
351
+ -- readable and therefore writable. Only the root is narrowed, by the
352
+ -- resolved WHERE.
353
+ IF p_collection_key IS DISTINCT FROM p_def.root_collection THEN
354
+ RETURN TRUE;
355
+ END IF;
356
+ SELECT * INTO v_coll FROM _delta_collections
357
+ WHERE collection_key = p_collection_key;
358
+ IF NOT FOUND THEN RETURN FALSE; END IF;
359
+ v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
360
+ EXECUTE format(
361
+ 'SELECT EXISTS(SELECT 1 FROM %I t WHERE t.id = $1 AND %s)',
362
+ v_view, COALESCE(v_scope->>'where', 'TRUE')
363
+ ) INTO v_exists USING p_id;
364
+ RETURN v_exists;
365
+ END IF;
366
+
367
+ -- Single mode: walk the parent-FK chain up to the root collection, the same
368
+ -- traversal _delta_load_collection makes on the way down.
369
+ v_root_id := (v_scope->'values'->>'id')::BIGINT;
370
+ v_cur_coll := p_collection_key;
371
+ v_cur_id := p_id;
372
+
373
+ LOOP
374
+ v_depth := v_depth + 1;
375
+ IF v_depth > 32 THEN RETURN FALSE; END IF; -- malformed / cyclic parent chain
376
+
377
+ IF v_cur_coll = p_def.root_collection THEN
378
+ IF v_cur_id IS DISTINCT FROM v_root_id THEN RETURN FALSE; END IF;
379
+ -- ... and the root meets the rest of its name's scope -- a column beside
380
+ -- its id, as fo-guest:<id>:<email> names one -- as delta_open reads it
381
+ -- (0.10.0 review). A scope of the id alone asks nothing more.
382
+ IF NOT EXISTS (SELECT 1 FROM jsonb_object_keys(COALESCE(p_def.scope, '{}'::jsonb)) k WHERE k <> 'id') THEN
383
+ RETURN TRUE;
384
+ END IF;
385
+ SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = v_cur_coll;
386
+ IF NOT FOUND THEN RETURN FALSE; END IF;
387
+ EXECUTE format('SELECT EXISTS(SELECT 1 FROM %I t WHERE t.id = $1 AND %s)',
388
+ _delta_source_view(v_coll.table_name, v_coll.temporal), COALESCE(v_scope->>'where', 'TRUE'))
389
+ INTO v_exists USING v_root_id;
390
+ RETURN v_exists;
391
+ END IF;
392
+
393
+ SELECT * INTO v_coll FROM _delta_collections
394
+ WHERE collection_key = v_cur_coll;
395
+ IF NOT FOUND THEN RETURN FALSE; END IF;
396
+
397
+ -- Unparented collection: included, _delta_load_collection loads it whole,
398
+ -- so it is shared by every doc of this type and every row of it is in
399
+ -- scope. Met on the way up from a row, or not included (the parent of a
400
+ -- row being added), it ends a chain that never reaches the root: nothing
401
+ -- under it is in the document (todo #32).
402
+ IF v_coll.parent_collection IS NULL THEN
403
+ RETURN v_depth = 1 AND v_cur_coll = ANY(COALESCE(p_def.include, ARRAY[]::text[]));
404
+ END IF;
405
+
406
+ v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
407
+ EXECUTE format('SELECT t.%I FROM %I t WHERE t.id = $1', v_coll.parent_fk, v_view)
408
+ INTO v_cur_id USING v_cur_id;
409
+ IF v_cur_id IS NULL THEN RETURN FALSE; END IF; -- absent, or invisible to us
410
+
411
+ v_cur_coll := v_coll.parent_collection;
412
+ END LOOP;
413
+ END;
414
+ $$ LANGUAGE plpgsql STABLE;
415
+
416
+ -- ---------------------------------------------------------------------------
417
+ -- Who holds a row: the fan-out (todo #28)
418
+ --
419
+ -- A write is told to every document that holds a row it changed -- the one
420
+ -- written through, and every other opened document over the same rows -- as
421
+ -- the SQLite backend tells its open documents. These answer, from a row's
422
+ -- values (as it was before the write, or as it is after), which documents
423
+ -- hold it; delta_apply tells each what changed for it.
424
+ -- ---------------------------------------------------------------------------
425
+
426
+ -- _delta_chain_root: the id of the `p_root` row that `p_row` (of `p_coll`)
427
+ -- hangs from, following parent keys up through the tables as they stand;
428
+ -- NULL when the chain does not reach p_root (an unparented collection on the
429
+ -- way, or a row not there).
430
+ CREATE OR REPLACE FUNCTION _delta_chain_root(p_coll TEXT, p_row JSONB, p_root TEXT)
431
+ RETURNS BIGINT AS $$
432
+ DECLARE
433
+ v_coll RECORD;
434
+ v_cur TEXT;
435
+ v_id BIGINT;
436
+ v_view TEXT;
437
+ v_depth INT := 0;
438
+ BEGIN
439
+ IF p_row IS NULL THEN RETURN NULL; END IF;
440
+ IF p_coll = p_root THEN RETURN (p_row->>'id')::BIGINT; END IF;
441
+ SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
442
+ IF NOT FOUND OR v_coll.parent_collection IS NULL THEN RETURN NULL; END IF;
443
+ v_id := (p_row->>v_coll.parent_fk)::BIGINT;
444
+ v_cur := v_coll.parent_collection;
445
+ LOOP
446
+ v_depth := v_depth + 1;
447
+ IF v_depth > 32 OR v_id IS NULL THEN RETURN NULL; END IF; -- malformed chain, or a row not there
448
+ IF v_cur = p_root THEN RETURN v_id; END IF;
449
+ SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = v_cur;
450
+ IF NOT FOUND OR v_coll.parent_collection IS NULL THEN RETURN NULL; END IF;
451
+ v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
452
+ EXECUTE format('SELECT t.%I FROM %I t WHERE t.id = $1', v_coll.parent_fk, v_view)
453
+ INTO v_id USING v_id;
454
+ v_cur := v_coll.parent_collection;
455
+ END LOOP;
456
+ END;
457
+ $$ LANGUAGE plpgsql STABLE;
458
+
459
+ -- _delta_doc_holds: does the document `p_doc_name` (of `p_def`) hold `p_row`
460
+ -- of `p_coll`? Judged on the row's values, so a row as it was before a write
461
+ -- is judged as well as one as it is after -- the same rule delta_open reads by.
462
+ CREATE OR REPLACE FUNCTION _delta_doc_holds(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
463
+ RETURNS BOOLEAN AS $$
464
+ DECLARE
465
+ v_scope JSONB;
466
+ v_coll RECORD;
467
+ v_ok BOOLEAN;
468
+ BEGIN
469
+ IF p_row IS NULL THEN RETURN FALSE; END IF;
470
+ IF p_coll IS DISTINCT FROM p_def.root_collection
471
+ AND NOT (p_coll = ANY(COALESCE(p_def.include, ARRAY[]::text[]))) THEN
472
+ RETURN FALSE;
473
+ END IF;
474
+ -- a name its root's columns cannot take (one opened before a rule refused it) holds nothing
475
+ v_scope := _delta_resolve_scope(p_def, p_doc_name, FALSE);
476
+ IF v_scope ? 'refused' THEN RETURN FALSE; END IF;
477
+ SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
478
+ IF NOT FOUND THEN RETURN FALSE; END IF;
479
+ -- Named with a time past, it holds a temporal row as it stood then, which no
480
+ -- write now changes (a table that keeps no history it reads as it is).
481
+ IF v_coll.temporal AND (v_scope->>'at')::TIMESTAMPTZ <= now() THEN RETURN FALSE; END IF;
482
+
483
+ -- The root: the one row a single-mode document is named for, or the rows a
484
+ -- list-mode document's condition reads -- tested on the row's own values.
485
+ IF p_coll = p_def.root_collection THEN
486
+ IF (v_scope->>'mode') = 'single'
487
+ AND (p_row->>'id')::BIGINT IS DISTINCT FROM (v_scope->'values'->>'id')::BIGINT THEN
488
+ RETURN FALSE;
489
+ END IF;
490
+ EXECUTE format(
491
+ 'SELECT EXISTS (SELECT 1 FROM jsonb_populate_record(null::%I, $1) t WHERE %s)',
492
+ v_coll.table_name, COALESCE(v_scope->>'where', 'TRUE')
493
+ ) INTO v_ok USING p_row;
494
+ RETURN v_ok;
495
+ END IF;
496
+
497
+ -- An included collection: a list-mode document, or an unparented
498
+ -- collection, holds every row of it (loaded in full); else the row hangs
499
+ -- from this document's root.
500
+ IF (v_scope->>'mode') = 'list' OR v_coll.parent_collection IS NULL THEN RETURN TRUE; END IF;
501
+ RETURN _delta_chain_root(p_coll, p_row, p_def.root_collection)
502
+ IS NOT DISTINCT FROM (v_scope->'values'->>'id')::BIGINT;
503
+ END;
504
+ $$ LANGUAGE plpgsql STABLE;
505
+
506
+ -- _delta_holders: the documents that hold `p_row` of `p_coll` -- every one
507
+ -- open now (a name in _delta_held, todo #73), and the writer's own `p_writer`.
508
+ -- A single-mode document of an unscoped definition is found by its name,
509
+ -- prefix || the root the row hangs from; a scoped or list-mode one, or one
510
+ -- holding an unparented collection, by the open names of its prefix. So a
511
+ -- write costs what is open, not what was ever opened.
512
+ --
513
+ -- Before it asks, it takes its prefix's lock, shared, until the write commits:
514
+ -- an open of that prefix holds its name and waits for it (_delta_hold), so a
515
+ -- name held from now is seen here, and a write that asked before is read by
516
+ -- the open. VOLATILE, so the question is asked after the lock -- and so a
517
+ -- write runs at READ COMMITTED: a transaction that reads from one snapshot
518
+ -- (REPEATABLE READ, SERIALIZABLE) would not see a name held after it began,
519
+ -- and is refused (SQLSTATE 0A000).
520
+ --
521
+ -- SECURITY DEFINER (todo #33), with pg_temp last on its path: who holds a row
522
+ -- is worked out as the functions' owner, not the writer, so a parent row the writer's RLS hides (another's
523
+ -- list, under another's board) does not hide the document that holds it. It
524
+ -- answers document names, never a row; each document is told only what it
525
+ -- holds (a caller may ask it of a row it makes up, and learn which open
526
+ -- documents would hold it). It sees past RLS where its owner does: the role
527
+ -- that applied the framework, owning the tables without FORCE ROW LEVEL
528
+ -- SECURITY, or with BYPASSRLS; applied as the app's own role, it sees what
529
+ -- the writer sees, as before.
530
+ CREATE OR REPLACE FUNCTION _delta_holders(p_coll TEXT, p_row JSONB, p_writer TEXT)
531
+ RETURNS TEXT[] AS $$
532
+ DECLARE
533
+ v_def _delta_docs;
534
+ v_coll RECORD;
535
+ v_names TEXT[] := ARRAY[]::text[];
536
+ v_rid BIGINT;
537
+ v_all BOOLEAN;
538
+ v_cand TEXT;
539
+ BEGIN
540
+ IF current_setting('transaction_isolation') <> 'read committed' THEN
541
+ RAISE EXCEPTION 'a delta write runs at READ COMMITTED, not %: a document opened while it ran would not be told of it',
542
+ upper(current_setting('transaction_isolation')) USING ERRCODE = '0A000';
543
+ END IF;
544
+ IF p_row IS NULL THEN RETURN v_names; END IF;
545
+ SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
546
+ IF NOT FOUND THEN RETURN v_names; END IF;
547
+
548
+ FOR v_def IN
549
+ SELECT * FROM _delta_docs
550
+ WHERE root_collection = p_coll OR p_coll = ANY(include)
551
+ LOOP
552
+ v_all := v_def.scope <> '{}'::jsonb
553
+ OR (p_coll IS DISTINCT FROM v_def.root_collection AND v_coll.parent_collection IS NULL);
554
+ v_rid := CASE WHEN v_all THEN NULL ELSE _delta_chain_root(p_coll, p_row, v_def.root_collection) END;
555
+ PERFORM pg_advisory_xact_lock_shared(hashtext('_delta_hold'), hashtext(v_def.prefix));
556
+ FOR v_cand IN
557
+ SELECT doc_name FROM _delta_held
558
+ WHERE (v_all AND starts_with(doc_name, v_def.prefix))
559
+ OR (NOT v_all AND doc_name IN (v_def.prefix, v_def.prefix || v_rid::text))
560
+ UNION
561
+ SELECT p_writer WHERE p_writer IS NOT NULL AND starts_with(p_writer, v_def.prefix)
562
+ LOOP
563
+ IF NOT (v_cand = ANY(v_names))
564
+ AND (_delta_find_doc(v_cand)).prefix = v_def.prefix -- the longest prefix owns the name
565
+ AND _delta_doc_holds(v_def, v_cand, p_coll, p_row) THEN
566
+ v_names := v_names || v_cand;
567
+ END IF;
568
+ END LOOP;
569
+ END LOOP;
570
+ RETURN v_names;
571
+ END;
572
+ $$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
573
+ -- the search path it was applied with, and pg_temp last: a caller's temporary
574
+ -- tables never stand in for delta's own (pg_temp is searched first unless named)
575
+ DO $$ BEGIN
576
+ EXECUTE format('ALTER FUNCTION _delta_holders(TEXT, JSONB, TEXT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
577
+ END $$;
578
+
579
+ -- ---------------------------------------------------------------------------
580
+ -- _delta_strip_temporal: remove valid_from/valid_to/override from a JSONB row
581
+ -- ---------------------------------------------------------------------------
582
+
583
+ CREATE OR REPLACE FUNCTION _delta_strip_temporal(p_row JSONB)
584
+ RETURNS JSONB AS $$ SELECT p_row - 'valid_from' - 'valid_to' - 'override'; $$ LANGUAGE sql IMMUTABLE;
585
+
586
+ -- ---------------------------------------------------------------------------
587
+ -- The schema is the contract (reference/migrations.md): a process writes by
588
+ -- the columns its own schema declares. _delta_cols: a collection's columns as
589
+ -- the process writing declares them -- `delta.declared`, set for the call by its
590
+ -- listener (`{ <collection>: <columns_def> }`, createDocListener's `schema`) --
591
+ -- else as the database last declared them (_delta_collections, generateSql).
592
+ -- Two versions on one database each validate, default and replace by their
593
+ -- own, whichever started last. _delta_stored_columns: every column the table
594
+ -- has, by name -- a walk puts a row back as the ledger stored it, a column this
595
+ -- process does not declare too.
596
+ -- ---------------------------------------------------------------------------
597
+
598
+ CREATE OR REPLACE FUNCTION _delta_cols(p_collection_key TEXT, p_columns_def JSONB)
599
+ RETURNS JSONB AS $$
600
+ SELECT COALESCE(NULLIF(current_setting('delta.declared', true), '')::jsonb -> p_collection_key, p_columns_def);
601
+ $$ LANGUAGE sql STABLE;
602
+
603
+ CREATE OR REPLACE FUNCTION _delta_stored_columns(p_table TEXT)
604
+ RETURNS TEXT[] AS $$
605
+ SELECT COALESCE(array_agg(attname::text ORDER BY attnum), '{}')
606
+ FROM pg_attribute
607
+ WHERE attrelid = to_regclass(quote_ident(p_table)) AND attnum > 0 AND NOT attisdropped;
608
+ $$ LANGUAGE sql STABLE;
609
+
610
+ -- _delta_insert_sql: the INSERT of a new row that names only the columns the
611
+ -- row gives (its id, parent key, declared columns and validity; a row put back
612
+ -- by a walk, every column it was stored with), so a column it does not give --
613
+ -- one a migration added NOT NULL with a default -- takes its database default,
614
+ -- as SQLite's insertRow gives it. Run with the row as $1; RETURNING may follow.
615
+ CREATE OR REPLACE FUNCTION _delta_insert_sql(p_table TEXT, p_row JSONB)
616
+ RETURNS TEXT AS $$
617
+ SELECT format('INSERT INTO %1$I AS t (%2$s) SELECT %2$s FROM jsonb_populate_record(null::%1$I, $1)', p_table,
618
+ (SELECT string_agg(format('%I', k), ', ') FROM jsonb_object_keys(p_row) AS k));
619
+ $$ LANGUAGE sql STABLE;