@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/docs/API.md
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
This document describes the current public API exported by `@appurist/offlinedb`.
|
|
4
|
+
|
|
5
|
+
Current exports from [src/index.js](C:/dev/appurist/offline/offlinedb/src/index.js):
|
|
6
|
+
|
|
7
|
+
- `OfflineDbClient`
|
|
8
|
+
- `IndexedDbPersistence`
|
|
9
|
+
- `InMemoryPersistence`
|
|
10
|
+
- `LocalStoragePersistence`
|
|
11
|
+
- `createNeonHttpTransport`
|
|
12
|
+
- `createOdbAuthClient`
|
|
13
|
+
- `createOdbDataClient`
|
|
14
|
+
- `createOdbSyncTransport`
|
|
15
|
+
- `defineTable`
|
|
16
|
+
- `createOfflinedbInstallSql`
|
|
17
|
+
- `createSyncedTableSql`
|
|
18
|
+
|
|
19
|
+
All Neon URLs used by these APIs are developer-supplied configuration.
|
|
20
|
+
The library does not embed project-specific Neon Auth or Data API endpoints.
|
|
21
|
+
|
|
22
|
+
## `OfflineDbClient`
|
|
23
|
+
|
|
24
|
+
Browser-first offline replication client.
|
|
25
|
+
|
|
26
|
+
### `OfflineDbClient.open(options)`
|
|
27
|
+
|
|
28
|
+
Initializes the provided persistence layer and returns a client instance.
|
|
29
|
+
If `persistence` is omitted, `open(...)` uses the `"indexeddb"` built-in automatically.
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
const client = await OfflineDbClient.open({
|
|
33
|
+
persistence: "indexeddb",
|
|
34
|
+
databaseName: "app-cache",
|
|
35
|
+
transport,
|
|
36
|
+
tables
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
#### `options`
|
|
41
|
+
|
|
42
|
+
- `persistence`
|
|
43
|
+
Optional persistence selector or persistence object.
|
|
44
|
+
Supported built-in selectors are:
|
|
45
|
+
- `"indexeddb"`
|
|
46
|
+
- `"localstorage"`
|
|
47
|
+
- `"memory"`
|
|
48
|
+
You may also pass an object implementing the persistence contract documented below.
|
|
49
|
+
- `databaseName`
|
|
50
|
+
Optional database/storage namespace used by the built-in persistence implementations.
|
|
51
|
+
Defaults to `offlinedb`.
|
|
52
|
+
- `indexedDb`
|
|
53
|
+
Optional IndexedDB factory override used by the `"indexeddb"` built-in.
|
|
54
|
+
- `localStorage`
|
|
55
|
+
Optional localStorage override used by the `"localstorage"` built-in.
|
|
56
|
+
- `transport`
|
|
57
|
+
Object with a `sync(request)` method returning a sync result.
|
|
58
|
+
- `tables`
|
|
59
|
+
Array of table definitions created by `defineTable(...)`.
|
|
60
|
+
- `now`
|
|
61
|
+
Optional clock function returning a `Date`.
|
|
62
|
+
Used when creating optimistic local writes.
|
|
63
|
+
- `idFactory`
|
|
64
|
+
Optional local-id generator.
|
|
65
|
+
Defaults to `crypto.randomUUID()` when available.
|
|
66
|
+
|
|
67
|
+
### Instance methods
|
|
68
|
+
|
|
69
|
+
#### `await client.get(table, key)`
|
|
70
|
+
|
|
71
|
+
Returns one locally stored row or `null`.
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
const task = await client.get("tasks", "task-1");
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `table`
|
|
78
|
+
Registered table name.
|
|
79
|
+
- `key`
|
|
80
|
+
Primary key value as a string.
|
|
81
|
+
|
|
82
|
+
#### `await client.list(table)`
|
|
83
|
+
|
|
84
|
+
Returns all locally stored rows for one registered table.
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
const tasks = await client.list("tasks");
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### `await client.mutate(args)`
|
|
91
|
+
|
|
92
|
+
Creates an optimistic local change, applies it to the local replica, and appends it to the pending queue.
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const mutation = await client.mutate({
|
|
96
|
+
table: "tasks",
|
|
97
|
+
key: "task-1",
|
|
98
|
+
values: {
|
|
99
|
+
owner_id: "user-1",
|
|
100
|
+
title: "Write docs"
|
|
101
|
+
},
|
|
102
|
+
metadata: {
|
|
103
|
+
source: "ui"
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
##### `args`
|
|
109
|
+
|
|
110
|
+
- `table`
|
|
111
|
+
Registered table name.
|
|
112
|
+
- `key`
|
|
113
|
+
Primary key value for the target row.
|
|
114
|
+
- `values`
|
|
115
|
+
Full or partial row object to merge into the current local row.
|
|
116
|
+
The client also injects:
|
|
117
|
+
- the table primary key field
|
|
118
|
+
- the next optimistic revision
|
|
119
|
+
- the updated-at column
|
|
120
|
+
- the new local id
|
|
121
|
+
- `global_id = 0`
|
|
122
|
+
- `metadata`
|
|
123
|
+
Optional metadata preserved in the queued change object.
|
|
124
|
+
|
|
125
|
+
##### Return value
|
|
126
|
+
|
|
127
|
+
Returns a pending change object with:
|
|
128
|
+
|
|
129
|
+
- `localId`
|
|
130
|
+
- `globalId`
|
|
131
|
+
- `clientSequence`
|
|
132
|
+
- `table`
|
|
133
|
+
- `key`
|
|
134
|
+
- `baseRevision`
|
|
135
|
+
- `values`
|
|
136
|
+
- `occurredAt`
|
|
137
|
+
- `metadata`
|
|
138
|
+
|
|
139
|
+
#### `await client.sync(options?)`
|
|
140
|
+
|
|
141
|
+
Pushes pending local changes and pulls remote changes through the configured transport.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const result = await client.sync();
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
##### `options`
|
|
148
|
+
|
|
149
|
+
- `tables`
|
|
150
|
+
Optional list of registered table names to include in this sync pass.
|
|
151
|
+
When omitted, the client syncs all registered tables.
|
|
152
|
+
|
|
153
|
+
##### Request sent to transport
|
|
154
|
+
|
|
155
|
+
The client calls:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
await transport.sync({
|
|
159
|
+
mutations,
|
|
160
|
+
tables,
|
|
161
|
+
lastGlobalId
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Where:
|
|
166
|
+
|
|
167
|
+
- `mutations`
|
|
168
|
+
The current pending local-change queue, sorted by `clientSequence`.
|
|
169
|
+
- `tables`
|
|
170
|
+
Optional table-name filter for the current sync pass.
|
|
171
|
+
- `lastGlobalId`
|
|
172
|
+
The client's last applied global id.
|
|
173
|
+
|
|
174
|
+
##### Expected transport response
|
|
175
|
+
|
|
176
|
+
The transport must return:
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
{
|
|
180
|
+
accepted: [
|
|
181
|
+
{
|
|
182
|
+
localId: "m1",
|
|
183
|
+
globalId: 12
|
|
184
|
+
}
|
|
185
|
+
],
|
|
186
|
+
conflicts: [
|
|
187
|
+
{
|
|
188
|
+
localId: "m2",
|
|
189
|
+
code: "revision_conflict",
|
|
190
|
+
serverRow: { ... } // or null
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
changes: [
|
|
194
|
+
{
|
|
195
|
+
table: "tasks",
|
|
196
|
+
key: "task-1",
|
|
197
|
+
row: { ... } // or null to delete locally
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
lastGlobalId: 12
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
##### Sync behavior
|
|
205
|
+
|
|
206
|
+
- Accepted local ids are removed from the pending queue.
|
|
207
|
+
- Conflicted local ids are removed from the pending queue.
|
|
208
|
+
- If a conflict includes `serverRow`, that row replaces the local optimistic row.
|
|
209
|
+
- Incoming `changes` are applied to the local replica.
|
|
210
|
+
- A `null` row in `changes` deletes the local row.
|
|
211
|
+
- The returned `lastGlobalId` becomes the client's new high-water mark.
|
|
212
|
+
|
|
213
|
+
#### `await client.exportState()`
|
|
214
|
+
|
|
215
|
+
Returns the persistence snapshot from the configured persistence layer.
|
|
216
|
+
With the built-in persistence implementations this includes:
|
|
217
|
+
|
|
218
|
+
- `clientSequence`
|
|
219
|
+
- `lastGlobalId`
|
|
220
|
+
- `pending`
|
|
221
|
+
- `rows`
|
|
222
|
+
|
|
223
|
+
## Persistence Contract
|
|
224
|
+
|
|
225
|
+
The client currently expects a persistence object with these async methods:
|
|
226
|
+
|
|
227
|
+
- `initialize()`
|
|
228
|
+
- `nextClientSequence()`
|
|
229
|
+
- `getRow(table, key)`
|
|
230
|
+
- `listRows(table)`
|
|
231
|
+
- `saveRow(table, key, row)`
|
|
232
|
+
- `deleteRow(table, key)`
|
|
233
|
+
- `appendPendingMutation(mutation)`
|
|
234
|
+
- `getPendingMutations()`
|
|
235
|
+
- `acknowledgeMutations(localIds)`
|
|
236
|
+
- `getLastGlobalId()`
|
|
237
|
+
- `setLastGlobalId(lastGlobalId)`
|
|
238
|
+
- `exportState()`
|
|
239
|
+
|
|
240
|
+
The behavior expected from that contract is:
|
|
241
|
+
|
|
242
|
+
- rows are stored by table name and row key
|
|
243
|
+
- pending mutations are returned sorted by `clientSequence`
|
|
244
|
+
- `acknowledgeMutations()` removes queued mutation ids
|
|
245
|
+
- `setLastGlobalId()` replaces the current high-water mark
|
|
246
|
+
- `exportState()` returns a serializable snapshot of the persistence layer
|
|
247
|
+
|
|
248
|
+
## `IndexedDbPersistence`
|
|
249
|
+
|
|
250
|
+
Browser persistence implementation backing the `"indexeddb"` built-in.
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
const persistence = new IndexedDbPersistence({
|
|
254
|
+
databaseName: "app-cache"
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Constructor options
|
|
259
|
+
|
|
260
|
+
- `databaseName`
|
|
261
|
+
Optional IndexedDB database name.
|
|
262
|
+
Defaults to `offlinedb`.
|
|
263
|
+
- `indexedDb`
|
|
264
|
+
Optional IndexedDB factory override.
|
|
265
|
+
|
|
266
|
+
### Behavior
|
|
267
|
+
|
|
268
|
+
- wraps an in-memory working set and persists snapshots into IndexedDB
|
|
269
|
+
- creates a single object store named `state`
|
|
270
|
+
- stores the full persistence snapshot under the key `snapshot`
|
|
271
|
+
- loads the snapshot during `initialize()`
|
|
272
|
+
- persists after every mutating operation
|
|
273
|
+
|
|
274
|
+
`IndexedDbPersistence` is the default because it matches the browser-first, local-replica model of the library more closely than an in-memory-only store.
|
|
275
|
+
|
|
276
|
+
## `LocalStoragePersistence`
|
|
277
|
+
|
|
278
|
+
Simple browser persistence implementation backing the `"localstorage"` built-in.
|
|
279
|
+
|
|
280
|
+
```js
|
|
281
|
+
const persistence = new LocalStoragePersistence({
|
|
282
|
+
storageKey: "app-cache:snapshot"
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Constructor options
|
|
287
|
+
|
|
288
|
+
- `storageKey`
|
|
289
|
+
Optional localStorage key used for the serialized snapshot.
|
|
290
|
+
Defaults to `offlinedb:snapshot`.
|
|
291
|
+
- `localStorage`
|
|
292
|
+
Optional localStorage-compatible override.
|
|
293
|
+
|
|
294
|
+
### Behavior
|
|
295
|
+
|
|
296
|
+
- wraps an in-memory working set
|
|
297
|
+
- serializes the full snapshot into one localStorage entry
|
|
298
|
+
- loads the snapshot during `initialize()`
|
|
299
|
+
- persists after every mutating operation
|
|
300
|
+
|
|
301
|
+
This is simpler than IndexedDB but less suitable for larger local replicas.
|
|
302
|
+
|
|
303
|
+
## `InMemoryPersistence`
|
|
304
|
+
|
|
305
|
+
Reference in-memory persistence implementation used by the current tests and examples.
|
|
306
|
+
|
|
307
|
+
```js
|
|
308
|
+
const persistence = new InMemoryPersistence();
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Behavior
|
|
312
|
+
|
|
313
|
+
- keeps one global sync `lastGlobalId` in process memory
|
|
314
|
+
- keeps one `lastGlobalId` value in process memory
|
|
315
|
+
- keeps all rows and pending mutations in process memory
|
|
316
|
+
- does not survive page reloads or process restarts
|
|
317
|
+
- remains useful for tests, examples, and non-browser flows
|
|
318
|
+
|
|
319
|
+
## `createNeonHttpTransport(options)`
|
|
320
|
+
|
|
321
|
+
Creates a simple HTTP transport for a developer-supplied HTTP sync endpoint.
|
|
322
|
+
|
|
323
|
+
This helper still exists in the scaffold, but the intended Neon-native direction is direct Neon Auth and Data API calls via the `odb*` clients below rather than an app-owned sync service.
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
const transport = createNeonHttpTransport({
|
|
327
|
+
// Supplied by the app.
|
|
328
|
+
baseUrl: "https://example.neon.tech/sql",
|
|
329
|
+
getAuthToken: async () => sessionToken
|
|
330
|
+
});
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### `options`
|
|
334
|
+
|
|
335
|
+
- `baseUrl`
|
|
336
|
+
Base URL used to build the sync endpoint URL.
|
|
337
|
+
- `fetchImpl`
|
|
338
|
+
Optional fetch implementation.
|
|
339
|
+
Defaults to global `fetch`.
|
|
340
|
+
- `getAuthToken`
|
|
341
|
+
Optional function or value used to populate the `Authorization` header.
|
|
342
|
+
When it returns a token, the transport sends `Authorization: Bearer <token>`.
|
|
343
|
+
- `syncPath`
|
|
344
|
+
Optional relative path appended to `baseUrl`.
|
|
345
|
+
Defaults to `rpc/offlinedb_sync`.
|
|
346
|
+
|
|
347
|
+
### Behavior
|
|
348
|
+
|
|
349
|
+
- Sends `POST` with `content-type: application/json`
|
|
350
|
+
- Serializes the sync request body as JSON
|
|
351
|
+
- Throws when the HTTP response is not `2xx`
|
|
352
|
+
- Returns `await response.json()`
|
|
353
|
+
|
|
354
|
+
## `createOdbAuthClient(options)`
|
|
355
|
+
|
|
356
|
+
Creates direct Neon Auth wrappers using `odb*` method names.
|
|
357
|
+
|
|
358
|
+
```js
|
|
359
|
+
const auth = createOdbAuthClient({
|
|
360
|
+
baseUrl: appConfig.neonAuthUrl
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### `options`
|
|
365
|
+
|
|
366
|
+
- `baseUrl`
|
|
367
|
+
Developer-supplied Neon Auth base URL.
|
|
368
|
+
- `fetchImpl`
|
|
369
|
+
Optional fetch implementation.
|
|
370
|
+
- `endpoints`
|
|
371
|
+
Optional endpoint overrides for auth routes.
|
|
372
|
+
|
|
373
|
+
### Methods
|
|
374
|
+
|
|
375
|
+
- `odbLoginWithPassword({ email, password, rememberMe?, callbackURL? })`
|
|
376
|
+
- `odbSignUpWithPassword({ email, password, name?, callbackURL?, image? })`
|
|
377
|
+
- `odbLogout()`
|
|
378
|
+
|
|
379
|
+
## `createOdbDataClient(options)`
|
|
380
|
+
|
|
381
|
+
Creates direct Neon Data API wrappers using `odb*` method names.
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
const data = createOdbDataClient({
|
|
385
|
+
baseUrl: appConfig.neonDataUrl,
|
|
386
|
+
getAuthToken: async () => sessionToken
|
|
387
|
+
});
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### `options`
|
|
391
|
+
|
|
392
|
+
- `baseUrl`
|
|
393
|
+
Developer-supplied Neon Data API or SQL base URL.
|
|
394
|
+
- `fetchImpl`
|
|
395
|
+
Optional fetch implementation.
|
|
396
|
+
- `getAuthToken`
|
|
397
|
+
Optional async token provider.
|
|
398
|
+
- `headers`
|
|
399
|
+
Optional default headers.
|
|
400
|
+
|
|
401
|
+
### Methods
|
|
402
|
+
|
|
403
|
+
- `odbRequest({ path, method?, body?, headers? })`
|
|
404
|
+
- `odbRpc(functionName, args?)`
|
|
405
|
+
|
|
406
|
+
## `createOdbSyncTransport(options)`
|
|
407
|
+
|
|
408
|
+
Creates a direct-Neon sync transport that calls database RPC functions through `createOdbDataClient()`.
|
|
409
|
+
|
|
410
|
+
```js
|
|
411
|
+
const data = createOdbDataClient({
|
|
412
|
+
baseUrl: appConfig.neonDataUrl,
|
|
413
|
+
getAuthToken: async () => sessionToken
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const transport = createOdbSyncTransport({
|
|
417
|
+
dataClient: data
|
|
418
|
+
});
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### `options`
|
|
422
|
+
|
|
423
|
+
- `dataClient`
|
|
424
|
+
Data client created by `createOdbDataClient()`.
|
|
425
|
+
- `mutateFunctionName`
|
|
426
|
+
Optional function-name override or name resolver for per-table mutate RPCs.
|
|
427
|
+
Defaults to `mutate_<table>`.
|
|
428
|
+
- `pullFunctionName`
|
|
429
|
+
Optional pull RPC name.
|
|
430
|
+
Defaults to `pull_changes`.
|
|
431
|
+
|
|
432
|
+
### Behavior
|
|
433
|
+
|
|
434
|
+
- calls `mutate_<table>` once per pending local change
|
|
435
|
+
- then calls `pull_changes(lastGlobalId, tables?)`
|
|
436
|
+
- returns the normalized sync result expected by `OfflineDbClient`
|
|
437
|
+
|
|
438
|
+
This is the intended browser runtime path for the Neon-native version of the library.
|
|
439
|
+
|
|
440
|
+
## `defineTable(input)`
|
|
441
|
+
|
|
442
|
+
Creates a normalized table definition object.
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
const tasks = defineTable({
|
|
446
|
+
name: "tasks",
|
|
447
|
+
primaryKey: "id",
|
|
448
|
+
ownerColumn: "owner_id",
|
|
449
|
+
revisionColumn: "revision",
|
|
450
|
+
updatedAtColumn: "updated_at",
|
|
451
|
+
localIdColumn: "local_id",
|
|
452
|
+
globalIdColumn: "global_id"
|
|
453
|
+
});
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### `input`
|
|
457
|
+
|
|
458
|
+
- `name`
|
|
459
|
+
Required SQL-style identifier.
|
|
460
|
+
- `primaryKey`
|
|
461
|
+
Optional, defaults to `id`.
|
|
462
|
+
- `ownerColumn`
|
|
463
|
+
Optional, defaults to `owner_id`.
|
|
464
|
+
- `revisionColumn`
|
|
465
|
+
Optional, defaults to `revision`.
|
|
466
|
+
- `updatedAtColumn`
|
|
467
|
+
Optional, defaults to `updated_at`.
|
|
468
|
+
- `localIdColumn`
|
|
469
|
+
Optional, defaults to `local_id`.
|
|
470
|
+
- `globalIdColumn`
|
|
471
|
+
Optional, defaults to `global_id`.
|
|
472
|
+
|
|
473
|
+
### Return value
|
|
474
|
+
|
|
475
|
+
Returns a frozen object:
|
|
476
|
+
|
|
477
|
+
```js
|
|
478
|
+
{
|
|
479
|
+
kind: "table",
|
|
480
|
+
name,
|
|
481
|
+
primaryKey,
|
|
482
|
+
ownerColumn,
|
|
483
|
+
revisionColumn,
|
|
484
|
+
updatedAtColumn,
|
|
485
|
+
localIdColumn,
|
|
486
|
+
globalIdColumn
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
All identifiers must match the current validation rule:
|
|
491
|
+
|
|
492
|
+
```txt
|
|
493
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
## `createOfflinedbInstallSql(options?)`
|
|
497
|
+
|
|
498
|
+
Generates SQL for the shared metadata schema.
|
|
499
|
+
|
|
500
|
+
```js
|
|
501
|
+
const sql = createOfflinedbInstallSql();
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
```js
|
|
505
|
+
const sql = createOfflinedbInstallSql({
|
|
506
|
+
schema: "offlinedb"
|
|
507
|
+
});
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### `options`
|
|
511
|
+
|
|
512
|
+
- `schema`
|
|
513
|
+
Metadata schema name.
|
|
514
|
+
Defaults to `offlinedb`.
|
|
515
|
+
|
|
516
|
+
### Generated objects
|
|
517
|
+
|
|
518
|
+
The current output includes:
|
|
519
|
+
|
|
520
|
+
- metadata schema creation
|
|
521
|
+
- `global_mutation` table
|
|
522
|
+
- `applied_mutation` table
|
|
523
|
+
- `current_user_id()` function
|
|
524
|
+
- `record_change()` trigger function
|
|
525
|
+
|
|
526
|
+
### Current assumptions
|
|
527
|
+
|
|
528
|
+
- `current_user_id()` resolves the user from JWT claim settings in Postgres.
|
|
529
|
+
- `record_change()` expects synced tables to expose:
|
|
530
|
+
- `id`
|
|
531
|
+
- `local_id`
|
|
532
|
+
- `owner_id`
|
|
533
|
+
- `revision`
|
|
534
|
+
|
|
535
|
+
That expectation is important because the helper is intentionally narrow in this first scaffold.
|
|
536
|
+
|
|
537
|
+
## `createSyncedTableSql(options)`
|
|
538
|
+
|
|
539
|
+
Generates SQL to prepare one app table for `offlinedb`.
|
|
540
|
+
|
|
541
|
+
```js
|
|
542
|
+
const sql = createSyncedTableSql({
|
|
543
|
+
table: defineTable({ name: "tasks" })
|
|
544
|
+
});
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
```js
|
|
548
|
+
const sql = createSyncedTableSql({
|
|
549
|
+
schema: "public",
|
|
550
|
+
metadataSchema: "offlinedb",
|
|
551
|
+
table: defineTable({
|
|
552
|
+
name: "tasks",
|
|
553
|
+
primaryKey: "id"
|
|
554
|
+
})
|
|
555
|
+
});
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### `options`
|
|
559
|
+
|
|
560
|
+
- `table`
|
|
561
|
+
Required table definition object or table name string.
|
|
562
|
+
- `schema`
|
|
563
|
+
App-table schema name.
|
|
564
|
+
Defaults to `public`.
|
|
565
|
+
- `metadataSchema`
|
|
566
|
+
Metadata schema containing helper functions.
|
|
567
|
+
Defaults to `offlinedb`.
|
|
568
|
+
|
|
569
|
+
### Generated SQL responsibilities
|
|
570
|
+
|
|
571
|
+
The current generator emits SQL for:
|
|
572
|
+
|
|
573
|
+
- required sync metadata columns
|
|
574
|
+
- owner/global-id index
|
|
575
|
+
- revision and updated-at trigger function
|
|
576
|
+
- global-mutation capture trigger
|
|
577
|
+
- owner-write insert/update RLS policies
|
|
578
|
+
- a table-specific mutation RPC function named like `offlinedb.mutate_tasks`
|
|
579
|
+
|
|
580
|
+
### Current caveats
|
|
581
|
+
|
|
582
|
+
- The table must use a single primary key column.
|
|
583
|
+
- The generated mutation RPC expects a full next-row payload in `p_values`.
|
|
584
|
+
- Accepted writes are assigned a server-side `globalId`.
|
|
585
|
+
- Public-read policies are not yet generated automatically.
|
|
586
|
+
- The shared `record_change()` helper currently assumes column names compatible with the default owner/id/local_id/revision pattern.
|
|
587
|
+
|
|
588
|
+
## Current limitations of the public API
|
|
589
|
+
|
|
590
|
+
These are real current limits of the implementation:
|
|
591
|
+
|
|
592
|
+
- There is no built-in subscription API yet.
|
|
593
|
+
- There is no exported TypeScript typing surface.
|
|
594
|
+
- There is no finalized live Neon wire contract in this repo yet.
|
|
595
|
+
- Delete operations are only represented through remote `changes` entries with `row: null`; there is no dedicated local delete helper.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
`offlinedb` is a browser-first offline replication library for Neon Auth and Postgres RLS.
|
|
4
|
+
|
|
5
|
+
## Core model
|
|
6
|
+
|
|
7
|
+
- App tables remain ordinary Postgres tables.
|
|
8
|
+
- Every synced table has a non-null owner column.
|
|
9
|
+
- Security is enforced by Postgres RLS, not by a separate library ACL layer.
|
|
10
|
+
- Writes flow through library-owned SQL functions so revision checks and local-id/global-id assignment stay consistent.
|
|
11
|
+
- The default client model is a full local replica for all synced tables the user can read.
|
|
12
|
+
- Sync can be limited to selected tables as an optimization, but whole-replica sync is the default.
|
|
13
|
+
|
|
14
|
+
## Runtime flow
|
|
15
|
+
|
|
16
|
+
1. App code defines synced tables.
|
|
17
|
+
2. The client stores a local replica plus a pending local-change log.
|
|
18
|
+
3. Local writes apply optimistically to the replica and receive a client-assigned `localId`.
|
|
19
|
+
4. `sync()` sends pending changes plus the client's `lastGlobalId`.
|
|
20
|
+
5. Server-side SQL functions validate each change, assign accepted changes a global `globalId`, and record them in the global mutation log.
|
|
21
|
+
6. The remote side returns accepted local/global id mappings, conflicts, changed rows, and a new `lastGlobalId`.
|
|
22
|
+
7. The client updates the local replica, clears acknowledged local changes, and advances its global high-water mark.
|
|
23
|
+
|
|
24
|
+
## Security boundary
|
|
25
|
+
|
|
26
|
+
- Neon Auth session state identifies the current user.
|
|
27
|
+
- Database functions resolve the current user from JWT claims.
|
|
28
|
+
- RLS policies decide row visibility and writability.
|
|
29
|
+
- The library assumes owner-write behavior and optional public readability.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Decisions
|
|
2
|
+
|
|
3
|
+
## Chosen defaults
|
|
4
|
+
|
|
5
|
+
- Neon Auth is the initial auth target.
|
|
6
|
+
- Direct client-to-Neon access is the primary data path.
|
|
7
|
+
- The library is browser-first.
|
|
8
|
+
- The first data model is relational row/table sync, not a document abstraction.
|
|
9
|
+
- Every synced table must include a non-null owner column.
|
|
10
|
+
- Rows may be private or publicly readable, but only owners write in v1.
|
|
11
|
+
- Conflicts are explicit per-row revision conflicts.
|
|
12
|
+
- The library owns the mutation RPC path for synced writes.
|
|
13
|
+
- Sync is framed as local-id to global-id convergence against a global mutation log.
|
|
14
|
+
|
|
15
|
+
## Intentional non-goals for this pass
|
|
16
|
+
|
|
17
|
+
- Full Neon integration tests against a live database
|
|
18
|
+
- General-purpose SQL query caching
|
|
19
|
+
- Shared workspace role models beyond owner-centric tables
|
|
20
|
+
- Automatic conflict merging
|
package/docs/SQL.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SQL Helpers
|
|
2
|
+
|
|
3
|
+
`src/sql.js` currently generates two SQL bundles.
|
|
4
|
+
|
|
5
|
+
## `createOfflinedbInstallSql()`
|
|
6
|
+
|
|
7
|
+
Creates metadata objects under the `offlinedb` schema:
|
|
8
|
+
|
|
9
|
+
- `global_mutation`
|
|
10
|
+
- `applied_mutation`
|
|
11
|
+
- `current_user_id()`
|
|
12
|
+
- `record_change()`
|
|
13
|
+
|
|
14
|
+
## `createSyncedTableSql()`
|
|
15
|
+
|
|
16
|
+
Generates per-table SQL for:
|
|
17
|
+
|
|
18
|
+
- required sync metadata columns
|
|
19
|
+
- revision/update trigger
|
|
20
|
+
- global-mutation trigger
|
|
21
|
+
- owner-scoped RLS policies for `SELECT`, `INSERT`, `UPDATE`, and `DELETE`
|
|
22
|
+
- a table-specific mutation RPC function
|
|
23
|
+
|
|
24
|
+
## Caveats
|
|
25
|
+
|
|
26
|
+
This is the first scaffold, not the final migration system.
|
|
27
|
+
The generated SQL is intentionally narrow:
|
|
28
|
+
|
|
29
|
+
- it assumes owner-centric tables
|
|
30
|
+
- it assumes a single primary key column
|
|
31
|
+
- it generates a table-specific mutation function
|
|
32
|
+
- it expects the mutation payload to contain the full next row image
|
|
33
|
+
- it uses `local_id` and `global_id` columns to represent unsynced and globally accepted writes
|
|
34
|
+
- it does not yet generate public-read, admin-read, or admin-write policies automatically
|
|
35
|
+
- it does not configure grants, exposed schemas, CORS, or other Neon platform settings
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|