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.
- package/README.md +16 -0
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +12 -5
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- package/src/postgres/auth-jwt-sql.ts +32 -0
- package/src/postgres/auth-jwt.ts +209 -0
- package/src/postgres/auth.ts +50 -0
- package/src/postgres/bootstrap.ts +55 -0
- package/src/postgres/carry.ts +40 -0
- package/src/postgres/codegen.ts +172 -0
- package/src/postgres/index.ts +58 -0
- package/src/postgres/listener.ts +706 -0
- package/src/postgres/migrate.ts +143 -0
- package/src/postgres/pglite.ts +142 -0
- package/src/postgres/registry.ts +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -0
- package/src/sql/001a-delta-meta.sql +204 -0
- package/src/sql/001b-delta-scope.sql +619 -0
- package/src/sql/001c-delta-read.sql +506 -0
- package/src/sql/001d-delta-write.sql +1088 -0
- package/src/sql/001e-delta-ops.sql +142 -0
- package/src/sql/001f-delta-as.sql +41 -0
- package/src/sql/001g-delta-ledger.sql +564 -0
- 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;
|