@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/README.md +163 -0
- package/docs/API.md +595 -0
- package/docs/ARCHITECTURE.md +29 -0
- package/docs/DECISIONS.md +20 -0
- package/docs/SQL.md +38 -0
- package/offlinedb.schema.json +10 -0
- package/package.json +39 -0
- package/scripts/apply-schema.mjs +54 -0
- package/scripts/print-schema-sql.mjs +30 -0
- package/src/client.js +587 -0
- package/src/index.js +19 -0
- package/src/neon.js +209 -0
- package/src/schema.js +40 -0
- package/src/sql.js +335 -0
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
|
+
|