dzql 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Delta Postgres — schema wrappers + Postgres-specific utilities.
3
+ *
4
+ * The shared schema vocabulary (ColumnDef, TableDef, Schema, ResolvedTable,
5
+ * DocDef, defineSchema, defineDoc) lives in `../../schema` and is identical
6
+ * across the Postgres and SQLite backends. This module re-exports those
7
+ * types and adds the Postgres helpers that hit the live database
8
+ * (time-travel, ops-log pruning, op validation).
9
+ */
10
+ import type { Pool } from "pg";
11
+ import { type DeltaOp, joinPath, splitPath } from "@blueshed/delta/core";
12
+ import {
13
+ holdsField,
14
+ type Schema,
15
+ type DocDef,
16
+ type ValidationError,
17
+ isIsoTime,
18
+ } from "@blueshed/delta/backend";
19
+ import { pastSafeId, resolveScope } from "@blueshed/delta/backend";
20
+
21
+ export type {
22
+ ColumnType,
23
+ ColumnDef,
24
+ ColumnShorthand,
25
+ TableDef,
26
+ Schema,
27
+ ResolvedTable,
28
+ DocDef,
29
+ ValidationError,
30
+ } from "@blueshed/delta/backend";
31
+ export { defineSchema, defineDoc } from "@blueshed/delta/backend";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Postgres-specific doc helpers — all thin wrappers over stored functions.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export async function loadDocAt(pool: Pool, docName: string, at: string | Date): Promise<any | null> {
38
+ const ts = at instanceof Date ? at.toISOString() : at;
39
+ const { rows } = await pool.query("SELECT delta_open_at($1, $2) AS doc", [docName, ts]);
40
+ return rows[0]?.doc ?? null;
41
+ }
42
+
43
+ export async function pruneOpsLog(pool: Pool, keepInterval = "1 hour"): Promise<number> {
44
+ const { rows } = await pool.query("SELECT delta_prune_ops($1::interval) AS count", [keepInterval]);
45
+ return Number(rows[0]?.count ?? 0);
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // validateOps — what delta_apply refuses as a mistake (400), said ahead.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Validate delta ops against the schema, as `delta_apply` (001d) reads them,
54
+ * before they reach the database: it refuses each write this does (a 400), and
55
+ * takes each this takes -- but for what only the rows can say (a row not there,
56
+ * 404; already there, 409) and a value of its type its column cannot hold (a
57
+ * timestamp that is no date), which are the database's. Returns an array of errors (empty = valid).
58
+ *
59
+ * `doc`: the document's name, which says its mode and its scope's values, as
60
+ * `delta_apply` reads them (`_delta_resolve_scope`; `resolveScope` is its
61
+ * twin) -- the definition alone cannot: `all-courses:` is every course, and
62
+ * `all-courses:1` course 1 (todo #57). Or say them: `list`, the document is in
63
+ * list mode; `values`, its scope's equality bindings, which a list-mode add of
64
+ * a root row is given, so they count as given -- the options SQLite's
65
+ * validateOps takes, and they win over the name's. Given neither, the mode is
66
+ * not known, and each shape is taken as the mode that has it takes it:
67
+ * `replace /<root>` and `replace /<root>/<field>` as a single document's root
68
+ * row, `add /<root>/-` as a list's new row.
69
+ */
70
+ export function validateOps(schema: Schema, def: DocDef, ops: DeltaOp[], opts: { doc?: string; list?: boolean; values?: Record<string, string> } = {}): ValidationError[] {
71
+ let rootId: string | undefined; // a single document's own root, where its name says it
72
+ if (opts.doc !== undefined) {
73
+ if (!opts.doc.startsWith(def.prefix)) throw new Error(`validateOps: ${opts.doc} is not a document of ${def.prefix}`);
74
+ const scope = resolveScope(def, opts.doc.slice(def.prefix.length));
75
+ opts = { list: opts.list ?? scope.mode === "list", values: opts.values ?? scope.values };
76
+ rootId = scope.id;
77
+ }
78
+ const errors: ValidationError[] = [];
79
+ for (const op of ops) {
80
+ const fail = (message: string, path = op.path) => errors.push({ path: String(path), message });
81
+ let parts: string[];
82
+ try { parts = splitPath(op.path); }
83
+ catch (err: any) { fail(err.message); continue; }
84
+ const collKey = parts[0];
85
+ if (!collKey) { fail("Empty path"); continue; }
86
+ if (collKey !== def.root && !def.include.includes(collKey)) { fail(`Unknown collection: ${collKey}`); continue; }
87
+ const table = schema.tables[collKey];
88
+ if (!table) { fail(`No table for collection: ${collKey}`); continue; }
89
+
90
+ const fk = table.parent?.fkColumn;
91
+ const value = (op as any).value;
92
+ const isObject = value !== null && typeof value === "object" && !Array.isArray(value);
93
+ // A value's keys (_delta_assert_fields): its columns the document holds (todo #70), the row's id (the path's wins) and parent key, a temporal row's validity.
94
+ const held = (k: string) => holdsField(def, schema, collKey, k);
95
+ const known = (k: string) => (Object.hasOwn(table.columns, k) && held(k)) || k === "id" || k === fk || k === "valid_from" || k === "valid_to";
96
+ // What a written field may hold, beyond its column's cast: a column that is
97
+ // not nullable is never null (the table's NOT NULL); a parent key is an id.
98
+ const fieldErr = (field: string, v: unknown): string | null => {
99
+ if (field === fk) {
100
+ if (v === null || v === undefined) return `${field} cannot be null`;
101
+ return Number.isInteger(v) || (typeof v === "string" && /^\s*[+-]?\d+\s*$/.test(v)) ? null : `${field} must be an id: an integer`;
102
+ }
103
+ const col = Object.hasOwn(table.columns, field) ? table.columns[field]! : undefined;
104
+ if (!col || v === null || v === undefined) return col && !col.nullable && v === null ? `${field} cannot be null` : null;
105
+ // a value of its column's type, not cast (_delta_assert_fields)
106
+ const of: Record<string, [boolean, string]> = {
107
+ text: [typeof v === "string", "a string"], integer: [Number.isInteger(v), "an integer"], real: [typeof v === "number", "a number"],
108
+ boolean: [typeof v === "boolean", "a boolean"], timestamptz: [isIsoTime(v) || v instanceof Date, "an ISO-8601 time"],
109
+ };
110
+ const [ok, what] = of[col.type] ?? [true, ""];
111
+ return ok ? null : `${field} must be ${what}`;
112
+ };
113
+ // A whole-row value: an object of known keys, each field as it may be written.
114
+ const rowErrs = (row: Record<string, unknown>, verb: "add" | "replace") => {
115
+ for (const k of Object.keys(row)) if (!known(k)) fail(`Unknown field: ${k}`);
116
+ for (const [k, v] of Object.entries(row)) {
117
+ const err = verb === "add" && k === fk && v == null ? null : fieldErr(k, v); // an add's parent key, left null, is its document's, or found in scope
118
+ if (err) fail(err);
119
+ }
120
+ };
121
+ // A row's value, added or replaced whole (RFC 6902: a replace of the row is the row): a column
122
+ // that is neither nullable nor has a default must be given -- by the value, or, for a list-mode
123
+ // root row's add, by the scope, as delta_apply checks after it stamps them (#39, #3).
124
+ const required = (row: Record<string, unknown>, given: Record<string, unknown> = {}, add = false) => { // a replace sets the columns it holds
125
+ for (const [col, colDef] of Object.entries(table.columns)) {
126
+ if (!colDef.nullable && colDef.default === undefined && !Object.hasOwn(row, col) && !Object.hasOwn(given, col) && (add || held(col))) {
127
+ fail(`Required field missing: ${col} (give it a value, or declare a default or make it nullable in the schema)`);
128
+ }
129
+ }
130
+ };
131
+ // A row's id, as the path names it: the number its digits name, up to 2^53 - 1 (_delta_row_id).
132
+ const idErr = (seg: string): string | null =>
133
+ !/^[0-9]+$/.test(seg) ? `Row id "${seg}" in ${op.path} is not a number: Postgres mints row ids -- add to ${joinPath(collKey, "-")} and read the id from the echo`
134
+ : pastSafeId(seg) ? `Row id ${seg} in ${op.path} is past 2^53 - 1, which no number holds exactly -- add to ${joinPath(collKey, "-")} and read the id from the echo`
135
+ : null;
136
+
137
+ // A single document's root: `replace /<root>` is the root row, whole, and
138
+ // `replace /<root>/<field>` (a segment that is not an id) writes one field
139
+ // -- a column, or the parent key: the row moves (#37).
140
+ if (collKey === def.root && !opts.list && op.op === "replace" && (parts.length === 1 || (parts.length === 2 && !/^\d+$/.test(parts[1]!)))) {
141
+ if (parts[1] === "id") fail("Unknown field: id (a row keeps the id its path names)");
142
+ else if (parts.length === 2) rowErrs({ [parts[1]!]: value }, "replace");
143
+ else if (!isObject) fail("Replace value must be an object");
144
+ else { rowErrs(value, "replace"); required(value); }
145
+ continue;
146
+ }
147
+
148
+ // A row: add /<coll>/<id or ->, remove /<coll>/<id>, replace /<coll>/<id>[/<field>].
149
+ const rowOp = (op.op === "add" || op.op === "remove") ? parts.length === 2 : op.op === "replace" && (parts.length === 2 || parts.length === 3);
150
+ if (!rowOp) { fail(`Invalid op: ${op.op} ${op.path}`); continue; }
151
+ // a single document holds one root row, the one its name names: it adds no other (#52) --
152
+ // when the mode is known; given none, a root row's add is a list's, and is taken. Its own
153
+ // it may add back, by an undo of its removal (#43): the database says whether it is gone.
154
+ const other = parts[1] === "-" || (rootId !== undefined && /^[0-9]+$/.test(parts[1]!) && Number(parts[1]) !== Number(rootId));
155
+ if (op.op === "add" && collKey === def.root && opts.list === false && other) { fail(`Invalid op: add ${op.path} -- a single document holds one ${collKey}, its root`); continue; }
156
+ const idError = op.op === "add" && parts[1] === "-" ? null : idErr(parts[1]!);
157
+ if (idError) { fail(idError); continue; }
158
+ if (op.op === "remove") continue;
159
+ if (parts.length === 3) {
160
+ const field = parts[2]!;
161
+ if (!known(field) || field === "id") { fail(`Unknown field: ${field}`); continue; } // a row keeps the id its path names
162
+ const err = fieldErr(field, value);
163
+ if (err) fail(err);
164
+ continue;
165
+ }
166
+ if (!isObject) { fail(`${op.op === "add" ? "Add" : "Replace"} value must be an object`); continue; }
167
+ rowErrs(value, op.op as "add" | "replace");
168
+ required(value, op.op === "add" && opts.list && collKey === def.root ? opts.values : {}, op.op === "add");
169
+ }
170
+ return errors;
171
+ }
@@ -0,0 +1,42 @@
1
+ /** SQL helpers for codegen.ts (the schema's DDL) and carry.ts (export and import). */
2
+ import type { ColumnDef, Schema } from "./schema";
3
+
4
+ /**
5
+ * Emit a single-quoted SQL string literal with embedded quotes escaped.
6
+ * Use for ANY value interpolated into a `'…'` literal position so an
7
+ * apostrophe in a name can't terminate the literal or inject SQL.
8
+ */
9
+ export function lit(value: string): string {
10
+ return `'${value.replace(/'/g, "''")}'`;
11
+ }
12
+
13
+ export function columnSqlType(def: ColumnDef): string {
14
+ switch (def.type) {
15
+ case "text": return "TEXT";
16
+ case "json": return "JSONB";
17
+ case "integer": return "BIGINT";
18
+ case "boolean": return "BOOLEAN";
19
+ case "real": return "DOUBLE PRECISION";
20
+ case "timestamptz": return "TIMESTAMPTZ";
21
+ }
22
+ }
23
+
24
+ export function sqlDefault(value: unknown): string {
25
+ if (typeof value === "string") return lit(value);
26
+ if (typeof value === "number") return String(value);
27
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
28
+ return "NULL";
29
+ }
30
+
31
+ export function findCascadeOn(collKey: string, schema: Schema): { fk: string; collection: string }[] {
32
+ const result: { fk: string; collection: string }[] = [];
33
+ for (const [otherKey, otherTable] of Object.entries(schema.tables)) {
34
+ if (otherKey === collKey) continue;
35
+ for (const ref of otherTable.referencedBy) {
36
+ if (ref.collection === collKey) {
37
+ result.push({ fk: ref.fkColumn, collection: otherKey });
38
+ }
39
+ }
40
+ }
41
+ return result;
42
+ }
@@ -0,0 +1,204 @@
1
+ -- =========================================================================
2
+ -- Delta-doc: metadata tables
3
+ -- =========================================================================
4
+ -- Runtime schema registry populated by TypeScript (createTables / registerDocs).
5
+ -- =========================================================================
6
+
7
+ CREATE TABLE IF NOT EXISTS _delta_collections (
8
+ collection_key TEXT PRIMARY KEY,
9
+ table_name TEXT NOT NULL,
10
+ columns_def JSONB NOT NULL DEFAULT '{}',
11
+ parent_collection TEXT,
12
+ parent_fk TEXT,
13
+ temporal BOOLEAN NOT NULL DEFAULT TRUE,
14
+ cascade_on JSONB NOT NULL DEFAULT '[]'
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS _delta_docs (
18
+ prefix TEXT PRIMARY KEY,
19
+ root_collection TEXT NOT NULL,
20
+ include TEXT[] NOT NULL DEFAULT '{}',
21
+ scope JSONB NOT NULL DEFAULT '{}'
22
+ );
23
+
24
+ -- An implied document is there before its root row is (todo #34): it opens
25
+ -- empty, and its first write makes the row -- as on SQLite and ./json.
26
+ ALTER TABLE _delta_docs ADD COLUMN IF NOT EXISTS implied BOOLEAN NOT NULL DEFAULT FALSE;
27
+
28
+ -- The columns a document holds of a collection it names (todo #70): collection
29
+ -- key -> its column names. The row's id and parent key it holds always; a
30
+ -- collection not named, whole. It is opened, told and written by those alone.
31
+ ALTER TABLE _delta_docs ADD COLUMN IF NOT EXISTS held JSONB NOT NULL DEFAULT '{}';
32
+
33
+ CREATE TABLE IF NOT EXISTS _delta_versions (
34
+ doc_name TEXT PRIMARY KEY,
35
+ version BIGINT NOT NULL DEFAULT 0
36
+ );
37
+
38
+ -- The documents open now (todo #73): a row for each name a listener has
39
+ -- subscribers for, and which listener. A write's fan-out asks these names, not
40
+ -- every name ever opened. A listener is alive while its connection holds its
41
+ -- lock (001c, _delta_holder_live); a dead one's rows are cleared.
42
+ CREATE SEQUENCE IF NOT EXISTS _delta_holder_ids AS integer;
43
+ CREATE TABLE IF NOT EXISTS _delta_held (
44
+ doc_name TEXT NOT NULL,
45
+ holder INTEGER NOT NULL,
46
+ PRIMARY KEY (doc_name, holder)
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS _delta_ops_log (
50
+ id BIGSERIAL PRIMARY KEY,
51
+ doc_name TEXT NOT NULL,
52
+ version BIGINT NOT NULL,
53
+ ops JSONB NOT NULL,
54
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
55
+ );
56
+
57
+ CREATE INDEX IF NOT EXISTS idx_delta_ops_log_fetch
58
+ ON _delta_ops_log (doc_name, version);
59
+
60
+ -- The write itself, once, for the custom documents that watch its rows (todo
61
+ -- #44): on the writer's entry, the ops as delta_apply applied them -- each row
62
+ -- it changed as it now is, or its remove -- so a custom document hears a write
63
+ -- once, as on SQLite, and not once for each document it was told to (where a
64
+ -- row that left a document is a remove, which is not a delete). Kept only where
65
+ -- they differ from what the writer was told: NULL is heard as told, so an
66
+ -- entry whose ops are the write, and one logged some other way (a direct
67
+ -- _delta_bump_and_notify), carry none. '[]' on the other documents' entries of
68
+ -- the same write.
69
+ ALTER TABLE _delta_ops_log ADD COLUMN IF NOT EXISTS applied JSONB;
70
+
71
+ -- =========================================================================
72
+ -- Pure helpers — composed by every read/write/ops function below
73
+ -- =========================================================================
74
+
75
+ -- Build an RFC 6901 JSON Pointer, each segment escaped (~ as ~0, / as ~1).
76
+ -- _delta_build_path('sites', '42') -> '/sites/42'
77
+ -- _delta_build_path('venues') -> '/venues'
78
+ CREATE OR REPLACE FUNCTION _delta_build_path(p_collection TEXT, p_id TEXT DEFAULT NULL)
79
+ RETURNS text LANGUAGE sql IMMUTABLE AS $$
80
+ SELECT '/' || replace(replace(p_collection, '~', '~0'), '/', '~1')
81
+ || CASE WHEN p_id IS NULL THEN '' ELSE '/' || replace(replace(p_id, '~', '~0'), '/', '~1') END;
82
+ $$;
83
+
84
+ -- Split an RFC 6901 JSON Pointer into its unescaped segments -- the grammar of
85
+ -- splitPath in src/core.ts: '' is the whole document, every other path starts
86
+ -- with '/', an empty segment is a key, and a '~' not followed by 0 or 1 is an
87
+ -- error. SQLSTATE 22023 (invalid_parameter_value) answers 400 on the wire.
88
+ -- _delta_split_path('/sites/42') -> ARRAY['sites','42']
89
+ -- _delta_split_path('/a~1b/c~0d') -> ARRAY['a/b','c~d']
90
+ -- _delta_split_path('sites') -> raises
91
+ CREATE OR REPLACE FUNCTION _delta_split_path(p_path TEXT)
92
+ RETURNS text[] LANGUAGE plpgsql IMMUTABLE AS $$
93
+ BEGIN
94
+ IF p_path = '' THEN RETURN ARRAY[]::text[]; END IF;
95
+ IF p_path IS NULL OR left(p_path, 1) <> '/' THEN
96
+ RAISE EXCEPTION 'Invalid JSON Pointer "%": a path starts with "/" ("" is the whole document)', p_path
97
+ USING ERRCODE = '22023';
98
+ END IF;
99
+ IF p_path ~ '~([^01]|$)' THEN
100
+ RAISE EXCEPTION 'Invalid JSON Pointer "%": "~" is written "~0" and "/" is written "~1"', p_path
101
+ USING ERRCODE = '22023';
102
+ END IF;
103
+ RETURN ARRAY(
104
+ SELECT replace(replace(seg, '~1', '/'), '~0', '~')
105
+ FROM unnest(regexp_split_to_array(substr(p_path, 2), '/')) WITH ORDINALITY AS t(seg, n)
106
+ ORDER BY n
107
+ );
108
+ END;
109
+ $$;
110
+
111
+ -- A row id from a path segment. This backend keeps BIGINT ids it mints from
112
+ -- seq_<table>, so anything else is a client error (SQLSTATE 22P02 → 400) that
113
+ -- names the fix. So is a number past 2^53 - 1: a row is read out as JSON, and
114
+ -- no JavaScript number holds such an id exactly, so the row would be told under
115
+ -- another (the SQLite backend refuses it too). Digits name their number, however
116
+ -- many there are ("007" is 7).
117
+ CREATE OR REPLACE FUNCTION _delta_row_id(p_collection TEXT, p_segment TEXT)
118
+ RETURNS bigint LANGUAGE plpgsql IMMUTABLE AS $$
119
+ BEGIN
120
+ IF p_segment IS NULL OR p_segment !~ '^[0-9]+$' THEN
121
+ RAISE EXCEPTION 'row id "%" in /%/% is not a number: Postgres mints row ids -- add to /%/- and read the id from the echo',
122
+ p_segment, p_collection, p_segment, p_collection
123
+ USING ERRCODE = '22P02';
124
+ END IF;
125
+ IF p_segment::numeric > 9007199254740991 THEN
126
+ RAISE EXCEPTION 'row id % in /%/% is past 2^53 - 1, which no number holds exactly -- add to /%/- and read the id from the echo',
127
+ p_segment, p_collection, p_segment, p_collection
128
+ USING ERRCODE = '22P02';
129
+ END IF;
130
+ RETURN p_segment::bigint;
131
+ END;
132
+ $$;
133
+
134
+ -- Refuse a value that is not an object, names a field the collection does
135
+ -- not have, or gives a column a value not of its type (SQLSTATE 22023 → 400).
136
+ -- It used to be merged into the broadcast row, acked, and dropped (or cast) by
137
+ -- jsonb_populate_record: stored otherwise than told. id, the parent key and
138
+ -- the temporal columns are the row's own.
139
+ CREATE OR REPLACE FUNCTION _delta_assert_fields(
140
+ p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB
141
+ ) RETURNS void LANGUAGE plpgsql IMMUTABLE AS $$
142
+ DECLARE
143
+ v_unknown TEXT;
144
+ BEGIN
145
+ IF p_value IS NULL OR jsonb_typeof(p_value) <> 'object' THEN
146
+ RAISE EXCEPTION 'a row value for % must be an object', p_collection USING ERRCODE = '22023';
147
+ END IF;
148
+ SELECT string_agg(k, ', ' ORDER BY k) INTO v_unknown
149
+ FROM jsonb_object_keys(p_value) AS k
150
+ WHERE NOT COALESCE(p_columns, '{}'::jsonb) ? k
151
+ AND k NOT IN ('id', 'valid_from', 'valid_to')
152
+ AND k IS DISTINCT FROM p_parent_fk;
153
+ IF v_unknown IS NOT NULL THEN
154
+ RAISE EXCEPTION 'Unknown field: % (not a column of %)', v_unknown, p_collection USING ERRCODE = '22023';
155
+ END IF;
156
+ -- the parent key, given, is a whole number or text; text that is no number's digits is
157
+ -- refused when the column casts it (22P02, 400), as SQLite refuses a key that names no row
158
+ IF p_parent_fk IS NOT NULL AND p_value ? p_parent_fk AND jsonb_typeof(p_value->p_parent_fk) <> 'null'
159
+ AND NOT (jsonb_typeof(p_value->p_parent_fk) = 'string'
160
+ OR (jsonb_typeof(p_value->p_parent_fk) = 'number' AND (p_value->>p_parent_fk)::numeric = trunc((p_value->>p_parent_fk)::numeric))) THEN
161
+ RAISE EXCEPTION '% must be an id: an integer', p_parent_fk USING ERRCODE = '22023';
162
+ END IF;
163
+ -- each column's value of its type, as SQLite's validateOps asks: not cast (5 into a text column is "5")
164
+ SELECT string_agg(format('%s must be %s', k, CASE c->>'type' WHEN 'text' THEN 'a string' WHEN 'integer' THEN 'an integer'
165
+ WHEN 'real' THEN 'a number' WHEN 'boolean' THEN 'a boolean' ELSE 'an ISO-8601 time' END), '; ' ORDER BY k) INTO v_unknown
166
+ FROM jsonb_each(p_value) AS v(k, val), LATERAL (SELECT p_columns->k AS c) col
167
+ WHERE c IS NOT NULL AND jsonb_typeof(val) <> 'null' AND NOT CASE c->>'type'
168
+ WHEN 'text' THEN jsonb_typeof(val) = 'string'
169
+ WHEN 'integer' THEN jsonb_typeof(val) = 'number' AND val::text::numeric = trunc(val::text::numeric)
170
+ WHEN 'real' THEN jsonb_typeof(val) = 'number'
171
+ WHEN 'boolean' THEN jsonb_typeof(val) = 'boolean'
172
+ -- ISO-8601, as isIsoTime (src/schema.ts) reads it; a day off the calendar is refused by the cast (22008, 400)
173
+ WHEN 'timestamptz' THEN jsonb_typeof(val) = 'string' AND (val #>> '{}') ~ '^\d{4}-\d{2}-\d{2}([T ]([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,6})?)?(Z|[+-]([01]\d|2[0-3])(:?[0-5]\d)?)?)?$'
174
+ ELSE TRUE END;
175
+ IF v_unknown IS NOT NULL THEN
176
+ RAISE EXCEPTION '%', v_unknown USING ERRCODE = '22023';
177
+ END IF;
178
+ END;
179
+ $$;
180
+
181
+ -- Pick the source relation for a collection.
182
+ -- temporal + no timestamp → current_<table> view
183
+ -- temporal + timestamp → base <table> (caller filters with _delta_temporal_where)
184
+ -- non-temporal → base <table>
185
+ CREATE OR REPLACE FUNCTION _delta_source_view(
186
+ p_table_name TEXT, p_temporal BOOLEAN, p_at TIMESTAMPTZ DEFAULT NULL
187
+ ) RETURNS text LANGUAGE sql IMMUTABLE AS $$
188
+ SELECT CASE
189
+ WHEN p_temporal AND p_at IS NULL THEN 'current_' || p_table_name
190
+ ELSE p_table_name
191
+ END;
192
+ $$;
193
+
194
+ -- Temporal WHERE fragment for embedding into format()-built dynamic SQL.
195
+ -- Inlines the timestamp as a %L literal so callers don't need extra param binding.
196
+ -- Aliases the row with `p_alias` (default `t`).
197
+ CREATE OR REPLACE FUNCTION _delta_temporal_where(
198
+ p_at TIMESTAMPTZ, p_alias TEXT DEFAULT 't'
199
+ ) RETURNS text LANGUAGE sql IMMUTABLE AS $$
200
+ SELECT format(
201
+ '%s.valid_from <= %L AND (%s.valid_to IS NULL OR %s.valid_to > %L)',
202
+ p_alias, p_at, p_alias, p_alias, p_at
203
+ );
204
+ $$;