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