@appurist/offlinedb 1.0.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/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ export {
2
+ OfflineDbClient,
3
+ IndexedDbPersistence,
4
+ InMemoryPersistence,
5
+ LocalStoragePersistence,
6
+ createNeonHttpTransport
7
+ } from "./client.js";
8
+ export {
9
+ createOdbAuthClient,
10
+ createOdbDataClient,
11
+ createOdbSyncTransport
12
+ } from "./neon.js";
13
+ export {
14
+ defineTable
15
+ } from "./schema.js";
16
+ export {
17
+ createOfflinedbInstallSql,
18
+ createSyncedTableSql
19
+ } from "./sql.js";
package/src/neon.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Thin fetch-based wrappers for Neon Auth and the Neon Data API.
3
+ *
4
+ * The auth endpoint defaults below are inferred from Better Auth's
5
+ * documented email/password routes because Neon Auth is built on Better Auth.
6
+ * Consumers can override endpoint paths if their Neon project uses different
7
+ * URLs or proxy routing.
8
+ */
9
+
10
+ /**
11
+ * @param {{
12
+ * baseUrl: string,
13
+ * fetchImpl?: typeof fetch,
14
+ * endpoints?: Partial<{
15
+ * loginWithPassword: string,
16
+ * signUpWithPassword: string,
17
+ * logout: string
18
+ * }>
19
+ * }} options
20
+ */
21
+ export function createOdbAuthClient(options) {
22
+ const fetchImpl = options.fetchImpl ?? fetch;
23
+ const endpoints = {
24
+ loginWithPassword: "sign-in/email",
25
+ signUpWithPassword: "sign-up/email",
26
+ logout: "sign-out",
27
+ ...(options.endpoints ?? {})
28
+ };
29
+
30
+ return {
31
+ /**
32
+ * @param {{email: string, password: string, rememberMe?: boolean, callbackURL?: string}} args
33
+ */
34
+ async odbLoginWithPassword(args) {
35
+ return odbPostJson(fetchImpl, joinUrl(options.baseUrl, endpoints.loginWithPassword), args);
36
+ },
37
+
38
+ /**
39
+ * @param {{email: string, password: string, name?: string, callbackURL?: string, image?: string}} args
40
+ */
41
+ async odbSignUpWithPassword(args) {
42
+ return odbPostJson(fetchImpl, joinUrl(options.baseUrl, endpoints.signUpWithPassword), args);
43
+ },
44
+
45
+ async odbLogout() {
46
+ return odbPostJson(fetchImpl, joinUrl(options.baseUrl, endpoints.logout), {});
47
+ }
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @param {{
53
+ * baseUrl: string,
54
+ * fetchImpl?: typeof fetch,
55
+ * getAuthToken?: () => Promise<string | null> | string | null,
56
+ * headers?: HeadersInit
57
+ * }} options
58
+ */
59
+ export function createOdbDataClient(options) {
60
+ const fetchImpl = options.fetchImpl ?? fetch;
61
+
62
+ return {
63
+ /**
64
+ * @param {{path: string, method?: string, body?: unknown, headers?: HeadersInit}} args
65
+ */
66
+ async odbRequest(args) {
67
+ const token = await options.getAuthToken?.();
68
+ const response = await fetchImpl(joinUrl(options.baseUrl, args.path), {
69
+ method: args.method ?? "GET",
70
+ headers: {
71
+ ...(args.body != null ? { "content-type": "application/json" } : {}),
72
+ ...(options.headers ?? {}),
73
+ ...(args.headers ?? {}),
74
+ ...(token ? { authorization: `Bearer ${token}` } : {})
75
+ },
76
+ body: args.body == null ? undefined : JSON.stringify(args.body)
77
+ });
78
+
79
+ if (!response.ok) {
80
+ throw new Error(`Neon Data API request failed with status ${response.status}`);
81
+ }
82
+
83
+ return readJsonIfPossible(response);
84
+ },
85
+
86
+ /**
87
+ * PostgREST-style RPC call.
88
+ *
89
+ * @param {string} fn
90
+ * @param {Record<string, unknown>=} args
91
+ */
92
+ async odbRpc(fn, args = {}) {
93
+ return this.odbRequest({
94
+ path: `rpc/${fn}`,
95
+ method: "POST",
96
+ body: args
97
+ });
98
+ }
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Direct Neon-native sync transport.
104
+ *
105
+ * It uses PostgREST-style RPC calls against database functions rather than a
106
+ * custom app-owned sync service.
107
+ *
108
+ * @param {{
109
+ * dataClient: ReturnType<typeof createOdbDataClient>,
110
+ * mutateFunctionName?: ((table: string) => string) | string,
111
+ * pullFunctionName?: string
112
+ * }} options
113
+ */
114
+ export function createOdbSyncTransport(options) {
115
+ const mutateFunctionName =
116
+ typeof options.mutateFunctionName === "function"
117
+ ? options.mutateFunctionName
118
+ : (table) => options.mutateFunctionName ?? `mutate_${table}`;
119
+ const pullFunctionName = options.pullFunctionName ?? "pull_changes";
120
+
121
+ return {
122
+ async sync(request) {
123
+ const accepted = [];
124
+ const conflicts = [];
125
+
126
+ for (const mutation of request.mutations) {
127
+ const result = normalizeRpcResult(
128
+ await options.dataClient.odbRpc(mutateFunctionName(mutation.table), {
129
+ p_local_id: mutation.localId,
130
+ p_row_key: mutation.key,
131
+ p_base_revision: mutation.baseRevision,
132
+ p_values: mutation.values
133
+ })
134
+ );
135
+
136
+ if (result?.ok === false) {
137
+ conflicts.push({
138
+ localId: mutation.localId,
139
+ code: result.code ?? "revision_conflict",
140
+ serverRow: result.row ?? null
141
+ });
142
+ continue;
143
+ }
144
+
145
+ accepted.push({
146
+ localId: result?.localId ?? mutation.localId,
147
+ globalId: Number(result?.globalId ?? 0),
148
+ row: result?.row ?? null
149
+ });
150
+ }
151
+
152
+ const pull = normalizeRpcResult(
153
+ await options.dataClient.odbRpc(pullFunctionName, {
154
+ p_last_global_id: request.lastGlobalId,
155
+ p_tables: request.tables?.length ? request.tables : null
156
+ })
157
+ ) ?? { changes: [], lastGlobalId: request.lastGlobalId };
158
+
159
+ return {
160
+ accepted,
161
+ conflicts,
162
+ changes: (pull.changes ?? []).map((entry) => ({
163
+ table: entry.table,
164
+ key: entry.key,
165
+ row: entry.row ?? null
166
+ })),
167
+ lastGlobalId: Number(pull.lastGlobalId ?? request.lastGlobalId ?? 0)
168
+ };
169
+ }
170
+ };
171
+ }
172
+
173
+ async function odbPostJson(fetchImpl, url, body) {
174
+ const response = await fetchImpl(url, {
175
+ method: "POST",
176
+ headers: {
177
+ "content-type": "application/json"
178
+ },
179
+ body: JSON.stringify(body)
180
+ });
181
+
182
+ if (!response.ok) {
183
+ throw new Error(`Auth request failed with status ${response.status}`);
184
+ }
185
+
186
+ return readJsonIfPossible(response);
187
+ }
188
+
189
+ async function readJsonIfPossible(response) {
190
+ const contentType = response.headers.get("content-type") ?? "";
191
+ if (contentType.includes("application/json")) {
192
+ return response.json();
193
+ }
194
+
195
+ return response.text();
196
+ }
197
+
198
+ function normalizeRpcResult(value) {
199
+ if (Array.isArray(value)) {
200
+ return value[0] ?? null;
201
+ }
202
+
203
+ return value ?? null;
204
+ }
205
+
206
+ function joinUrl(baseUrl, path) {
207
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
208
+ return new URL(path, normalizedBase);
209
+ }
package/src/schema.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @typedef {{
3
+ * name: string,
4
+ * primaryKey?: string,
5
+ * ownerColumn?: string,
6
+ * revisionColumn?: string,
7
+ * updatedAtColumn?: string,
8
+ * localIdColumn?: string,
9
+ * globalIdColumn?: string
10
+ * }} TableDefinitionInput
11
+ */
12
+
13
+ /**
14
+ * @param {TableDefinitionInput} input
15
+ */
16
+ export function defineTable(input) {
17
+ if (!input || typeof input !== "object") {
18
+ throw new Error("Table definition must be an object.");
19
+ }
20
+
21
+ const name = assertIdentifier(input.name, "table name");
22
+ return Object.freeze({
23
+ kind: "table",
24
+ name,
25
+ primaryKey: assertIdentifier(input.primaryKey ?? "id", "primary key"),
26
+ ownerColumn: assertIdentifier(input.ownerColumn ?? "owner_id", "owner column"),
27
+ revisionColumn: assertIdentifier(input.revisionColumn ?? "revision", "revision column"),
28
+ updatedAtColumn: assertIdentifier(input.updatedAtColumn ?? "updated_at", "updated_at column"),
29
+ localIdColumn: assertIdentifier(input.localIdColumn ?? "local_id", "local id column"),
30
+ globalIdColumn: assertIdentifier(input.globalIdColumn ?? "global_id", "global id column")
31
+ });
32
+ }
33
+
34
+ function assertIdentifier(value, label) {
35
+ if (typeof value !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
36
+ throw new Error(`Invalid ${label}: ${String(value)}`);
37
+ }
38
+
39
+ return value;
40
+ }
package/src/sql.js ADDED
@@ -0,0 +1,335 @@
1
+ import { defineTable } from "./schema.js";
2
+
3
+ /**
4
+ * @param {{schema?: string}=} options
5
+ */
6
+ export function createOfflinedbInstallSql(options = {}) {
7
+ const schema = assertIdentifier(options.schema ?? "offlinedb", "schema");
8
+
9
+ return `
10
+ CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schema)};
11
+
12
+ CREATE TABLE IF NOT EXISTS ${qualified(schema, "global_mutation")} (
13
+ global_id BIGSERIAL PRIMARY KEY,
14
+ table_name TEXT NOT NULL,
15
+ row_key TEXT NOT NULL,
16
+ owner_id TEXT,
17
+ local_id TEXT,
18
+ revision BIGINT NOT NULL,
19
+ accepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
20
+ operation TEXT NOT NULL,
21
+ row_data JSONB
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS ${qualified(schema, "applied_mutation")} (
25
+ user_id TEXT NOT NULL,
26
+ local_id TEXT NOT NULL,
27
+ global_id BIGINT,
28
+ table_name TEXT NOT NULL,
29
+ row_key TEXT NOT NULL,
30
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
31
+ PRIMARY KEY (user_id, local_id)
32
+ );
33
+
34
+ CREATE OR REPLACE FUNCTION ${qualified(schema, "current_user_id")}()
35
+ RETURNS TEXT
36
+ LANGUAGE SQL
37
+ STABLE
38
+ AS $$
39
+ SELECT COALESCE(
40
+ NULLIF(current_setting('request.jwt.claim.sub', true), ''),
41
+ NULLIF(current_setting('request.jwt.claims.sub', true), '')
42
+ )::TEXT
43
+ $$;
44
+
45
+ CREATE OR REPLACE FUNCTION ${qualified(schema, "record_change")}()
46
+ RETURNS TRIGGER
47
+ LANGUAGE plpgsql
48
+ AS $$
49
+ DECLARE
50
+ next_owner_id TEXT;
51
+ next_revision BIGINT;
52
+ next_key TEXT;
53
+ next_payload JSONB;
54
+ next_local_id TEXT;
55
+ BEGIN
56
+ IF TG_OP = 'DELETE' THEN
57
+ next_owner_id := OLD.owner_id;
58
+ next_revision := OLD.revision;
59
+ next_key := OLD.id::TEXT;
60
+ next_payload := NULL;
61
+ next_local_id := OLD.local_id;
62
+ ELSE
63
+ next_owner_id := NEW.owner_id;
64
+ next_revision := NEW.revision;
65
+ next_key := NEW.id::TEXT;
66
+ next_payload := to_jsonb(NEW);
67
+ next_local_id := NEW.local_id;
68
+ END IF;
69
+
70
+ INSERT INTO ${qualified(schema, "global_mutation")} (
71
+ global_id,
72
+ table_name,
73
+ row_key,
74
+ owner_id,
75
+ local_id,
76
+ revision,
77
+ operation,
78
+ row_data
79
+ )
80
+ VALUES (
81
+ CASE WHEN TG_OP = 'DELETE' THEN OLD.global_id ELSE NEW.global_id END,
82
+ TG_TABLE_NAME,
83
+ next_key,
84
+ next_owner_id,
85
+ next_local_id,
86
+ next_revision,
87
+ lower(TG_OP),
88
+ next_payload
89
+ );
90
+
91
+ IF TG_OP = 'DELETE' THEN
92
+ RETURN OLD;
93
+ END IF;
94
+
95
+ RETURN NEW;
96
+ END;
97
+ $$;
98
+
99
+ CREATE OR REPLACE FUNCTION ${qualified(schema, "pull_changes")}(
100
+ p_last_global_id BIGINT DEFAULT 0,
101
+ p_tables TEXT[] DEFAULT NULL
102
+ )
103
+ RETURNS JSONB
104
+ LANGUAGE SQL
105
+ STABLE
106
+ AS $$
107
+ WITH selected AS (
108
+ SELECT
109
+ gm.global_id,
110
+ gm.table_name,
111
+ gm.row_key,
112
+ gm.row_data
113
+ FROM ${qualified(schema, "global_mutation")} gm
114
+ WHERE gm.global_id > COALESCE(p_last_global_id, 0)
115
+ AND (p_tables IS NULL OR gm.table_name = ANY(p_tables))
116
+ ORDER BY gm.global_id ASC
117
+ )
118
+ SELECT jsonb_build_object(
119
+ 'changes',
120
+ COALESCE(
121
+ jsonb_agg(
122
+ jsonb_build_object(
123
+ 'globalId', selected.global_id,
124
+ 'table', selected.table_name,
125
+ 'key', selected.row_key,
126
+ 'row', selected.row_data
127
+ )
128
+ ORDER BY selected.global_id
129
+ ),
130
+ '[]'::jsonb
131
+ ),
132
+ 'lastGlobalId',
133
+ COALESCE(MAX(selected.global_id), COALESCE(p_last_global_id, 0))
134
+ )
135
+ FROM selected
136
+ $$;
137
+ `.trim();
138
+ }
139
+
140
+ /**
141
+ * @param {{table: ReturnType<typeof defineTable> | string, schema?: string, metadataSchema?: string}} options
142
+ */
143
+ export function createSyncedTableSql(options) {
144
+ const schema = assertIdentifier(options.schema ?? "public", "schema");
145
+ const metadataSchema = assertIdentifier(options.metadataSchema ?? "offlinedb", "metadata schema");
146
+ const table = typeof options.table === "string" ? defineTable({ name: options.table }) : options.table;
147
+ const tableName = qualified(schema, table.name);
148
+ const mutateFunctionName = qualified(metadataSchema, `mutate_${table.name}`);
149
+ const touchTriggerName = quoteIdentifier(`offlinedb_touch_${table.name}`);
150
+ const changeTriggerName = quoteIdentifier(`offlinedb_record_${table.name}`);
151
+
152
+ return `
153
+ ALTER TABLE ${tableName}
154
+ ADD COLUMN IF NOT EXISTS ${quoteIdentifier(table.ownerColumn)} TEXT,
155
+ ADD COLUMN IF NOT EXISTS ${quoteIdentifier(table.revisionColumn)} BIGINT NOT NULL DEFAULT 0,
156
+ ADD COLUMN IF NOT EXISTS ${quoteIdentifier(table.updatedAtColumn)} TIMESTAMPTZ NOT NULL DEFAULT NOW(),
157
+ ADD COLUMN IF NOT EXISTS ${quoteIdentifier(table.localIdColumn)} TEXT,
158
+ ADD COLUMN IF NOT EXISTS ${quoteIdentifier(table.globalIdColumn)} BIGINT NOT NULL DEFAULT 0;
159
+
160
+ UPDATE ${tableName}
161
+ SET ${quoteIdentifier(table.ownerColumn)} = COALESCE(${quoteIdentifier(table.ownerColumn)}, ${qualified(metadataSchema, "current_user_id")}())
162
+ WHERE ${quoteIdentifier(table.ownerColumn)} IS NULL;
163
+
164
+ ALTER TABLE ${tableName}
165
+ ALTER COLUMN ${quoteIdentifier(table.ownerColumn)} SET NOT NULL;
166
+
167
+ CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${table.name}_owner_global_id_idx`)}
168
+ ON ${tableName} (${quoteIdentifier(table.ownerColumn)}, ${quoteIdentifier(table.globalIdColumn)});
169
+
170
+ CREATE OR REPLACE FUNCTION ${qualified(metadataSchema, `touch_${table.name}`)}()
171
+ RETURNS TRIGGER
172
+ LANGUAGE plpgsql
173
+ AS $$
174
+ BEGIN
175
+ NEW.${table.updatedAtColumn} := NOW();
176
+ IF TG_OP = 'INSERT' THEN
177
+ IF COALESCE(NEW.${table.globalIdColumn}, 0) <= 0 THEN
178
+ NEW.${table.globalIdColumn} := nextval(pg_get_serial_sequence('${qualified(metadataSchema, "global_mutation")}', 'global_id'));
179
+ END IF;
180
+ ELSE
181
+ IF COALESCE(NEW.${table.globalIdColumn}, 0) <= COALESCE(OLD.${table.globalIdColumn}, 0) THEN
182
+ NEW.${table.globalIdColumn} := nextval(pg_get_serial_sequence('${qualified(metadataSchema, "global_mutation")}', 'global_id'));
183
+ END IF;
184
+ END IF;
185
+ IF TG_OP = 'INSERT' THEN
186
+ NEW.${table.revisionColumn} := COALESCE(NEW.${table.revisionColumn}, 0) + 1;
187
+ ELSE
188
+ NEW.${table.revisionColumn} := OLD.${table.revisionColumn} + 1;
189
+ END IF;
190
+ RETURN NEW;
191
+ END;
192
+ $$;
193
+
194
+ DROP TRIGGER IF EXISTS ${touchTriggerName} ON ${tableName};
195
+ CREATE TRIGGER ${touchTriggerName}
196
+ BEFORE INSERT OR UPDATE ON ${tableName}
197
+ FOR EACH ROW
198
+ EXECUTE FUNCTION ${qualified(metadataSchema, `touch_${table.name}`)}();
199
+
200
+ DROP TRIGGER IF EXISTS ${changeTriggerName} ON ${tableName};
201
+ CREATE TRIGGER ${changeTriggerName}
202
+ AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
203
+ FOR EACH ROW
204
+ EXECUTE FUNCTION ${qualified(metadataSchema, "record_change")}();
205
+
206
+ ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;
207
+
208
+ DROP POLICY IF EXISTS ${quoteIdentifier(`${table.name}_owner_select`)} ON ${tableName};
209
+ CREATE POLICY ${quoteIdentifier(`${table.name}_owner_select`)} ON ${tableName}
210
+ FOR SELECT
211
+ USING (${quoteIdentifier(table.ownerColumn)} = ${qualified(metadataSchema, "current_user_id")}());
212
+
213
+ DROP POLICY IF EXISTS ${quoteIdentifier(`${table.name}_owner_insert`)} ON ${tableName};
214
+ CREATE POLICY ${quoteIdentifier(`${table.name}_owner_insert`)} ON ${tableName}
215
+ FOR INSERT
216
+ WITH CHECK (${quoteIdentifier(table.ownerColumn)} = ${qualified(metadataSchema, "current_user_id")}());
217
+
218
+ DROP POLICY IF EXISTS ${quoteIdentifier(`${table.name}_owner_write`)} ON ${tableName};
219
+ CREATE POLICY ${quoteIdentifier(`${table.name}_owner_write`)} ON ${tableName}
220
+ FOR UPDATE
221
+ USING (${quoteIdentifier(table.ownerColumn)} = ${qualified(metadataSchema, "current_user_id")}())
222
+ WITH CHECK (${quoteIdentifier(table.ownerColumn)} = ${qualified(metadataSchema, "current_user_id")}());
223
+
224
+ DROP POLICY IF EXISTS ${quoteIdentifier(`${table.name}_owner_delete`)} ON ${tableName};
225
+ CREATE POLICY ${quoteIdentifier(`${table.name}_owner_delete`)} ON ${tableName}
226
+ FOR DELETE
227
+ USING (${quoteIdentifier(table.ownerColumn)} = ${qualified(metadataSchema, "current_user_id")}());
228
+
229
+ CREATE OR REPLACE FUNCTION ${mutateFunctionName}(
230
+ p_local_id TEXT,
231
+ p_row_key TEXT,
232
+ p_base_revision BIGINT,
233
+ p_values JSONB
234
+ )
235
+ RETURNS JSONB
236
+ LANGUAGE plpgsql
237
+ SECURITY INVOKER
238
+ AS $$
239
+ DECLARE
240
+ existing_row ${tableName}%ROWTYPE;
241
+ next_row ${tableName}%ROWTYPE;
242
+ set_clause TEXT;
243
+ upsert_sql TEXT;
244
+ BEGIN
245
+ SELECT *
246
+ INTO existing_row
247
+ FROM ${tableName}
248
+ WHERE ${table.primaryKey}::TEXT = p_row_key
249
+ FOR UPDATE;
250
+
251
+ IF FOUND AND existing_row.${table.revisionColumn} <> p_base_revision THEN
252
+ RETURN jsonb_build_object(
253
+ 'ok', false,
254
+ 'code', 'revision_conflict',
255
+ 'row', to_jsonb(existing_row)
256
+ );
257
+ END IF;
258
+
259
+ INSERT INTO ${qualified(metadataSchema, "applied_mutation")} (user_id, local_id, table_name, row_key)
260
+ VALUES (${qualified(metadataSchema, "current_user_id")}(), p_local_id, ${quoteText(table.name)}, p_row_key)
261
+ ON CONFLICT (user_id, local_id) DO NOTHING;
262
+
263
+ SELECT string_agg(
264
+ format('%1$I = EXCLUDED.%1$I', column_name),
265
+ ', '
266
+ ORDER BY ordinal_position
267
+ )
268
+ INTO set_clause
269
+ FROM information_schema.columns
270
+ WHERE table_schema = ${quoteText(schema)}
271
+ AND table_name = ${quoteText(table.name)}
272
+ AND column_name <> ${quoteText(table.primaryKey)};
273
+
274
+ upsert_sql := format(
275
+ 'INSERT INTO %1$s SELECT * FROM jsonb_populate_record(NULL::%1$s, $1) ON CONFLICT (%2$I) DO UPDATE SET %3$s RETURNING *',
276
+ ${quoteText(tableName)},
277
+ ${quoteText(table.primaryKey)},
278
+ set_clause
279
+ );
280
+
281
+ EXECUTE upsert_sql
282
+ USING jsonb_set(
283
+ jsonb_set(
284
+ jsonb_set(
285
+ p_values,
286
+ ${quoteText(`{${table.localIdColumn}}`)},
287
+ to_jsonb(p_local_id)
288
+ ),
289
+ ${quoteText(`{${table.ownerColumn}}`)},
290
+ to_jsonb(COALESCE(p_values ->> ${quoteText(table.ownerColumn)}, ${qualified(metadataSchema, "current_user_id")}()))
291
+ ),
292
+ ${quoteText(`{${table.globalIdColumn}}`)},
293
+ COALESCE(
294
+ p_values -> ${quoteText(table.globalIdColumn)},
295
+ '0'::jsonb
296
+ )
297
+ )
298
+ INTO next_row;
299
+
300
+ UPDATE ${qualified(metadataSchema, "applied_mutation")}
301
+ SET global_id = next_row.${table.globalIdColumn}
302
+ WHERE user_id = ${qualified(metadataSchema, "current_user_id")}()
303
+ AND local_id = p_local_id;
304
+
305
+ RETURN jsonb_build_object(
306
+ 'ok', true,
307
+ 'localId', p_local_id,
308
+ 'globalId', next_row.${table.globalIdColumn},
309
+ 'row', to_jsonb(next_row)
310
+ );
311
+ END;
312
+ $$;
313
+ `.trim();
314
+ }
315
+
316
+ function assertIdentifier(value, label) {
317
+ if (typeof value !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
318
+ throw new Error(`Invalid ${label}: ${String(value)}`);
319
+ }
320
+
321
+ return value;
322
+ }
323
+
324
+ function quoteIdentifier(value) {
325
+ return `"${value.replaceAll('"', '""')}"`;
326
+ }
327
+
328
+ function quoteText(value) {
329
+ return `'${value.replaceAll("'", "''")}'`;
330
+ }
331
+
332
+ function qualified(schema, name) {
333
+ return `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`;
334
+ }
335
+