@appurist/offlinedb 1.0.0 → 1.0.2

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.
Files changed (4) hide show
  1. package/package.json +40 -39
  2. package/src/client.js +826 -587
  3. package/src/neon.js +274 -209
  4. package/src/sql.js +2 -4
package/src/neon.js CHANGED
@@ -1,209 +1,274 @@
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
- }
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
+ * pullPageFunctionName?: string,
113
+ * pullPageSize?: number,
114
+ * pullPageMinSize?: number
115
+ * }} options
116
+ */
117
+ export function createOdbSyncTransport(options) {
118
+ const mutateFunctionName =
119
+ typeof options.mutateFunctionName === "function"
120
+ ? options.mutateFunctionName
121
+ : (table) => options.mutateFunctionName ?? `mutate_${table}`;
122
+ const pullFunctionName = options.pullFunctionName ?? "pull_changes";
123
+ const pullPageFunctionName = options.pullPageFunctionName ?? "pull_changes_page";
124
+ const pullPageSize = Number(options.pullPageSize ?? 500);
125
+ const pullPageMinSize = Number(options.pullPageMinSize ?? 25);
126
+
127
+ return {
128
+ async sync(request) {
129
+ const accepted = [];
130
+ const conflicts = [];
131
+
132
+ for (const mutation of request.mutations) {
133
+ const result = normalizeRpcResult(
134
+ await options.dataClient.odbRpc(mutateFunctionName(mutation.table), {
135
+ p_local_id: mutation.localId,
136
+ p_row_key: mutation.key,
137
+ p_base_revision: mutation.baseRevision,
138
+ p_values: mutation.values
139
+ })
140
+ );
141
+
142
+ if (result?.ok === false) {
143
+ conflicts.push({
144
+ localId: mutation.localId,
145
+ code: result.code ?? "revision_conflict",
146
+ serverRow: result.row ?? null
147
+ });
148
+ continue;
149
+ }
150
+
151
+ accepted.push({
152
+ localId: result?.localId ?? mutation.localId,
153
+ globalId: Number(result?.globalId ?? 0),
154
+ row: result?.row ?? null
155
+ });
156
+ }
157
+
158
+ const pull = await pullChangesPaged({
159
+ dataClient: options.dataClient,
160
+ pullFunctionName,
161
+ pullPageFunctionName,
162
+ request,
163
+ initialPageSize: pullPageSize,
164
+ minPageSize: pullPageMinSize
165
+ });
166
+
167
+ return {
168
+ accepted,
169
+ conflicts,
170
+ changes: (pull.changes ?? []).map((entry) => ({
171
+ table: entry.table,
172
+ key: entry.key,
173
+ row: entry.row ?? null
174
+ })),
175
+ lastGlobalId: Number(pull.lastGlobalId ?? request.lastGlobalId ?? 0)
176
+ };
177
+ }
178
+ };
179
+ }
180
+
181
+ async function pullChangesPaged(args) {
182
+ let pageSize = args.initialPageSize;
183
+ let lastGlobalId = Number(args.request.lastGlobalId ?? 0);
184
+ const changes = [];
185
+
186
+ while (true) {
187
+ let page;
188
+ try {
189
+ page = normalizeRpcResult(
190
+ await args.dataClient.odbRpc(args.pullPageFunctionName, {
191
+ p_last_global_id: lastGlobalId,
192
+ p_tables: args.request.tables?.length ? args.request.tables : null,
193
+ p_limit: pageSize
194
+ })
195
+ );
196
+ } catch (error) {
197
+ if (isMissingPagedFunction(error)) {
198
+ return normalizeRpcResult(
199
+ await args.dataClient.odbRpc(args.pullFunctionName, {
200
+ p_last_global_id: lastGlobalId,
201
+ p_tables: args.request.tables?.length ? args.request.tables : null
202
+ })
203
+ ) ?? { changes: [], lastGlobalId };
204
+ }
205
+ if (!isResponseTooLarge(error) || pageSize <= args.minPageSize) {
206
+ throw error;
207
+ }
208
+ pageSize = Math.max(args.minPageSize, Math.floor(pageSize / 2));
209
+ continue;
210
+ }
211
+
212
+ if (!page || !Array.isArray(page.changes)) {
213
+ page = normalizeRpcResult(
214
+ await args.dataClient.odbRpc(args.pullFunctionName, {
215
+ p_last_global_id: lastGlobalId,
216
+ p_tables: args.request.tables?.length ? args.request.tables : null
217
+ })
218
+ ) ?? { changes: [], lastGlobalId };
219
+ }
220
+
221
+ changes.push(...(page.changes ?? []));
222
+ const nextGlobalId = Number(page.lastGlobalId ?? lastGlobalId);
223
+ if (nextGlobalId <= lastGlobalId || page.hasMore !== true) {
224
+ return { changes, lastGlobalId: nextGlobalId };
225
+ }
226
+ lastGlobalId = nextGlobalId;
227
+ }
228
+ }
229
+
230
+ function isResponseTooLarge(error) {
231
+ return /response is too large|max is 10485760/i.test(error?.message ?? "");
232
+ }
233
+
234
+ function isMissingPagedFunction(error) {
235
+ return /status 404|function .*pull_changes_page|could not find.*pull_changes_page|schema cache/i.test(error?.message ?? "");
236
+ }
237
+
238
+ async function odbPostJson(fetchImpl, url, body) {
239
+ const response = await fetchImpl(url, {
240
+ method: "POST",
241
+ headers: {
242
+ "content-type": "application/json"
243
+ },
244
+ body: JSON.stringify(body)
245
+ });
246
+
247
+ if (!response.ok) {
248
+ throw new Error(`Auth request failed with status ${response.status}`);
249
+ }
250
+
251
+ return readJsonIfPossible(response);
252
+ }
253
+
254
+ async function readJsonIfPossible(response) {
255
+ const contentType = response.headers.get("content-type") ?? "";
256
+ if (contentType.includes("application/json")) {
257
+ return response.json();
258
+ }
259
+
260
+ return response.text();
261
+ }
262
+
263
+ function normalizeRpcResult(value) {
264
+ if (Array.isArray(value)) {
265
+ return value[0] ?? null;
266
+ }
267
+
268
+ return value ?? null;
269
+ }
270
+
271
+ function joinUrl(baseUrl, path) {
272
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
273
+ return new URL(path, normalizedBase);
274
+ }
package/src/sql.js CHANGED
@@ -63,12 +63,11 @@ BEGIN
63
63
  next_owner_id := NEW.owner_id;
64
64
  next_revision := NEW.revision;
65
65
  next_key := NEW.id::TEXT;
66
- next_payload := to_jsonb(NEW);
66
+ next_payload := to_jsonb(NEW) - 'global_id';
67
67
  next_local_id := NEW.local_id;
68
68
  END IF;
69
69
 
70
70
  INSERT INTO ${qualified(schema, "global_mutation")} (
71
- global_id,
72
71
  table_name,
73
72
  row_key,
74
73
  owner_id,
@@ -78,7 +77,6 @@ BEGIN
78
77
  row_data
79
78
  )
80
79
  VALUES (
81
- CASE WHEN TG_OP = 'DELETE' THEN OLD.global_id ELSE NEW.global_id END,
82
80
  TG_TABLE_NAME,
83
81
  next_key,
84
82
  next_owner_id,
@@ -331,5 +329,5 @@ function quoteText(value) {
331
329
 
332
330
  function qualified(schema, name) {
333
331
  return `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`;
334
- }
332
+ }
335
333