@gonvex/expo-sqlite 0.5.2-staging.13 → 0.5.2-staging.14

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 CHANGED
@@ -11,3 +11,9 @@ const client = new GonvexClient(url, {
11
11
  localReplica: { storage: expoSQLite(database) },
12
12
  });
13
13
  ```
14
+
15
+ The SDK reads cold rows by primary key or a scalar secondary index. Unindexed
16
+ reads use 256-row pages and retain only the requested result window. The adapter
17
+ keeps normalized rows and query memberships on disk; it does not hydrate the
18
+ whole database to execute a Reducer. Incoming projections update only their fields.
19
+ Schema upgrades, cursor changes, row authority, and tombstones are transactional.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReplicaWindow, LocalReplicaStorage, ReplicaScope, ReplicaRow, ReplicaSnapshot, ReplicaTransaction } from "@gonvex/client";
2
+ import { type ReplicaReadCoverage, type ReplicaReducerReadView } from "@gonvex/client";
2
3
  export interface ExpoSQLiteDatabase {
3
4
  execAsync(sql: string): Promise<void>;
4
5
  runAsync(sql: string, ...params: unknown[]): Promise<unknown>;
@@ -17,14 +18,29 @@ export declare class ExpoSQLiteLocalReplicaStorage implements LocalReplicaStorag
17
18
  constructor(database: ExpoSQLiteDatabase);
18
19
  private initialize;
19
20
  private migrateSchema;
21
+ private rebuildLookup;
20
22
  load(scope?: ReplicaScope): Promise<ReplicaSnapshot | undefined>;
23
+ loadWorkingSet(scope: string, budget: {
24
+ maxRows: number;
25
+ maxBytes: number;
26
+ }): Promise<ReplicaSnapshot | undefined>;
27
+ loadEntityRows(scope: string, entity: string, ids: readonly string[]): Promise<{
28
+ id: string;
29
+ row: ReplicaRow;
30
+ }[]>;
31
+ loadWindowRows(scope: string, signature: string): Promise<{
32
+ window: ReplicaWindow;
33
+ rows: ReplicaRow[];
34
+ } | undefined>;
35
+ withReadView<T>(scope: string, coverage: ReplicaReadCoverage, run: (view: ReplicaReducerReadView) => Promise<T>): Promise<T>;
21
36
  loadSession(scope: string): Promise<import("@gonvex/client").LocalReplicaSession | undefined>;
22
37
  saveSession(scope: string, session: import("@gonvex/client").LocalReplicaSession | undefined): Promise<void>;
23
38
  applyTransaction(transaction: ReplicaTransaction, _snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
24
39
  advanceWatermark(windows: readonly ReplicaWindow[], cursor: ReplicaSnapshot["cursor"], scope?: ReplicaScope): Promise<void>;
25
40
  replaceSnapshot(snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
26
- replaceWindow(window: ReplicaWindow, snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
27
- applyWindowDelta(window: ReplicaWindow, _delta: {
41
+ private writeRecords;
42
+ replaceWindow(window: ReplicaWindow, snapshot: ReplicaSnapshot, scope?: ReplicaScope, projection?: readonly ReplicaRow[]): Promise<void>;
43
+ applyWindowDelta(window: ReplicaWindow, delta: {
28
44
  upserts: ReplicaRow[];
29
45
  deleted: string[];
30
46
  }, snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { pagedReplicaReadView, mergeReplicaRecord, } from "@gonvex/client";
1
2
  const defaultReplicaScope = "default";
2
- const replicaSchemaVersion = 2;
3
+ const replicaSchemaVersion = 3;
3
4
  /**
4
5
  * Transactional, normalized SQLite persistence for Expo. Every server
5
6
  * transaction updates entities, Live Query memberships, and the replica
@@ -12,15 +13,17 @@ export class ExpoSQLiteLocalReplicaStorage {
12
13
  this.database = database;
13
14
  }
14
15
  initialize() {
15
- return this.initialized ??= this.migrateSchema();
16
+ return (this.initialized ??= this.migrateSchema());
16
17
  }
17
18
  async migrateSchema() {
19
+ const storedVersion = await this.database.getFirstAsync("PRAGMA user_version");
18
20
  const [entities, queries, meta] = await Promise.all([
19
21
  this.database.getAllAsync(`PRAGMA table_info(_gonvex_replica_entities)`),
20
22
  this.database.getAllAsync(`PRAGMA table_info(_gonvex_replica_queries)`),
21
23
  this.database.getAllAsync(`PRAGMA table_info(_gonvex_replica_meta)`),
22
24
  ]);
23
- const legacyEntities = entities.length > 0 && !entities.some((column) => column.name === "scope");
25
+ const legacyEntities = entities.length > 0 &&
26
+ !entities.some((column) => column.name === "scope");
24
27
  const legacyQueries = queries.length > 0 && !queries.some((column) => column.name === "scope");
25
28
  const legacyMeta = meta.length > 0 && !meta.some((column) => column.name === "scope");
26
29
  const createTables = `
@@ -36,9 +39,34 @@ export class ExpoSQLiteLocalReplicaStorage {
36
39
  scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
37
40
  PRIMARY KEY (scope, key)
38
41
  );
42
+ CREATE TABLE IF NOT EXISTS _gonvex_replica_lookup (
43
+ scope TEXT NOT NULL, entity TEXT NOT NULL, column_name TEXT NOT NULL,
44
+ kind TEXT NOT NULL, scalar BLOB NOT NULL, id TEXT NOT NULL,
45
+ PRIMARY KEY(scope,entity,column_name,kind,scalar,id)
46
+ );
47
+ CREATE TRIGGER IF NOT EXISTS _gonvex_replica_lookup_insert AFTER INSERT ON _gonvex_replica_entities BEGIN
48
+ INSERT INTO _gonvex_replica_lookup SELECT NEW.scope,NEW.entity,j.key,
49
+ CASE WHEN j.type IN ('integer','real') THEN 'number' WHEN j.type IN ('true','false') THEN 'boolean' ELSE 'string' END,j.atom,NEW.id
50
+ FROM json_each(NEW.value) j WHERE j.type IN ('integer','real','true','false') OR (j.type='text' AND length(j.atom)<=256);
51
+ END;
52
+ CREATE TRIGGER IF NOT EXISTS _gonvex_replica_lookup_update AFTER UPDATE ON _gonvex_replica_entities BEGIN
53
+ DELETE FROM _gonvex_replica_lookup WHERE scope=OLD.scope AND entity=OLD.entity AND id=OLD.id;
54
+ INSERT INTO _gonvex_replica_lookup SELECT NEW.scope,NEW.entity,j.key,
55
+ CASE WHEN j.type IN ('integer','real') THEN 'number' WHEN j.type IN ('true','false') THEN 'boolean' ELSE 'string' END,j.atom,NEW.id
56
+ FROM json_each(NEW.value) j WHERE j.type IN ('integer','real','true','false') OR (j.type='text' AND length(j.atom)<=256);
57
+ END;
58
+ CREATE TRIGGER IF NOT EXISTS _gonvex_replica_lookup_delete AFTER DELETE ON _gonvex_replica_entities BEGIN
59
+ DELETE FROM _gonvex_replica_lookup WHERE scope=OLD.scope AND entity=OLD.entity AND id=OLD.id;
60
+ END;
61
+ CREATE INDEX IF NOT EXISTS _gonvex_replica_lookup_row ON _gonvex_replica_lookup(scope,entity,id);
39
62
  `;
40
63
  if (!legacyEntities && !legacyQueries && !legacyMeta) {
41
- await this.database.execAsync(`PRAGMA user_version = ${replicaSchemaVersion};${createTables}`);
64
+ await this.database.withTransactionAsync(async () => {
65
+ await this.database.execAsync(createTables);
66
+ if ((storedVersion?.user_version ?? 0) < 3)
67
+ await this.rebuildLookup();
68
+ await this.database.execAsync(`PRAGMA user_version = ${replicaSchemaVersion}`);
69
+ });
42
70
  return;
43
71
  }
44
72
  // The v1 tables had no namespace at all. Keep their contents available to
@@ -73,16 +101,23 @@ export class ExpoSQLiteLocalReplicaStorage {
73
101
  await this.database.execAsync(`DROP TABLE _gonvex_replica_queries_legacy_v1`);
74
102
  if (legacyMeta)
75
103
  await this.database.execAsync(`DROP TABLE _gonvex_replica_meta_legacy_v1`);
104
+ await this.rebuildLookup();
76
105
  await this.database.execAsync(`PRAGMA user_version = ${replicaSchemaVersion}`);
77
106
  });
78
107
  }
108
+ async rebuildLookup() {
109
+ await this.database.execAsync(`INSERT OR REPLACE INTO _gonvex_replica_lookup
110
+ SELECT e.scope,e.entity,j.key,CASE WHEN j.type IN ('integer','real') THEN 'number' WHEN j.type IN ('true','false') THEN 'boolean' ELSE 'string' END,j.atom,e.id
111
+ FROM _gonvex_replica_entities e,json_each(e.value) j WHERE j.type IN ('integer','real','true','false') OR (j.type='text' AND length(j.atom)<=256)`);
112
+ }
79
113
  async load(scope = defaultReplicaScope) {
80
114
  await this.initialize();
81
115
  const cursor = await this.database.getFirstAsync(`SELECT scope, key, value FROM _gonvex_replica_meta WHERE scope = ? AND key = 'cursor'`, scope);
82
116
  const entities = {};
83
117
  const entityRecords = await this.database.getAllAsync(`SELECT scope, entity, id, value FROM _gonvex_replica_entities WHERE scope = ? ORDER BY entity, id`, scope);
84
118
  for (const row of entityRecords) {
85
- (entities[row.entity] ??= {})[row.id] = JSON.parse(row.value);
119
+ if (row.value !== "null")
120
+ (entities[row.entity] ??= {})[row.id] = JSON.parse(row.value);
86
121
  }
87
122
  const liveQueries = {};
88
123
  const queryRecords = await this.database.getAllAsync(`SELECT scope, signature, value FROM _gonvex_replica_queries WHERE scope = ? ORDER BY signature`, scope);
@@ -91,7 +126,148 @@ export class ExpoSQLiteLocalReplicaStorage {
91
126
  }
92
127
  if (!cursor && entityRecords.length === 0 && queryRecords.length === 0)
93
128
  return undefined;
94
- return { cursor: cursor ? JSON.parse(cursor.value) : undefined, entities, liveQueries };
129
+ return {
130
+ cursor: cursor ? JSON.parse(cursor.value) : undefined,
131
+ entities,
132
+ liveQueries,
133
+ };
134
+ }
135
+ async loadWorkingSet(scope, budget) {
136
+ await this.initialize();
137
+ let snapshot;
138
+ await this.database.withTransactionAsync(async () => {
139
+ const cursor = await this.database.getFirstAsync(`SELECT scope, key, value FROM _gonvex_replica_meta WHERE scope = ? AND key = 'cursor'`, scope);
140
+ const queries = await this.database.getAllAsync(`SELECT scope, signature, value FROM _gonvex_replica_queries WHERE scope = ? ORDER BY signature`, scope);
141
+ const entities = {};
142
+ let count = 0, bytes = 0, afterEntity = "", afterId = "", finished = false;
143
+ while (count < budget.maxRows && !finished) {
144
+ const records = await this.database.getAllAsync(`SELECT scope, entity, id, value FROM _gonvex_replica_entities WHERE scope = ? AND (entity > ? OR (entity = ? AND id > ?)) ORDER BY entity, id LIMIT ?`, scope, afterEntity, afterEntity, afterId, Math.min(256, budget.maxRows - count));
145
+ if (!records.length)
146
+ break;
147
+ for (const record of records) {
148
+ if (record.value === "null")
149
+ continue;
150
+ const size = record.value.length * 2 + 256;
151
+ if (bytes + size > budget.maxBytes) {
152
+ finished = true;
153
+ break;
154
+ }
155
+ (entities[record.entity] ??= {})[record.id] = JSON.parse(record.value);
156
+ bytes += size;
157
+ count++;
158
+ }
159
+ const last = records.at(-1);
160
+ afterEntity = last.entity;
161
+ afterId = last.id;
162
+ }
163
+ if (cursor || queries.length || count)
164
+ snapshot = {
165
+ cursor: cursor ? JSON.parse(cursor.value) : undefined,
166
+ entities,
167
+ liveQueries: Object.fromEntries(queries.map((row) => [
168
+ row.signature,
169
+ normalizeWindow(JSON.parse(row.value)),
170
+ ])),
171
+ };
172
+ });
173
+ return snapshot;
174
+ }
175
+ async loadEntityRows(scope, entity, ids) {
176
+ await this.initialize();
177
+ const result = [];
178
+ for (let offset = 0; offset < ids.length; offset += 256) {
179
+ const batch = ids.slice(offset, offset + 256);
180
+ const rows = await this.database.getAllAsync(`SELECT scope, entity, id, value FROM _gonvex_replica_entities WHERE scope = ? AND entity = ? AND id IN (${batch.map(() => "?").join(",")})`, scope, entity, ...batch);
181
+ const byId = new Map(rows.map((row) => [row.id, row]));
182
+ for (const id of batch) {
183
+ const row = byId.get(id);
184
+ if (row && row.value !== "null")
185
+ result.push({ id, row: JSON.parse(row.value) });
186
+ }
187
+ }
188
+ return result;
189
+ }
190
+ async loadWindowRows(scope, signature) {
191
+ await this.initialize();
192
+ let result;
193
+ await this.database.withTransactionAsync(async () => {
194
+ const record = await this.database.getFirstAsync(`SELECT scope, signature, value FROM _gonvex_replica_queries WHERE scope = ? AND signature = ?`, scope, signature);
195
+ if (!record)
196
+ return;
197
+ const window = normalizeWindow(JSON.parse(record.value));
198
+ result = {
199
+ window,
200
+ rows: (await this.loadEntityRows(scope, window.entity, window.ids)).map((entry) => entry.row),
201
+ };
202
+ });
203
+ return result;
204
+ }
205
+ async withReadView(scope, coverage, run) {
206
+ await this.initialize();
207
+ let result;
208
+ await this.database.withTransactionAsync(async () => {
209
+ result = await run(pagedReplicaReadView(coverage, async (read, after) => {
210
+ const key = coverage[read.table]?.key ?? "_id";
211
+ const predicate = read.where;
212
+ const exact = predicate &&
213
+ "column" in predicate &&
214
+ !predicate.transform &&
215
+ predicate.column === key &&
216
+ predicate.op === "eq" &&
217
+ typeof predicate.value === "string"
218
+ ? predicate.value
219
+ : undefined;
220
+ const restriction = (function equality(value) {
221
+ if (!value)
222
+ return;
223
+ if ("and" in value) {
224
+ const candidates = value.and
225
+ .map(equality)
226
+ .filter((candidate) => candidate !== undefined);
227
+ return (candidates.find((candidate) => candidate.column === key) ??
228
+ candidates[0]);
229
+ }
230
+ if ("or" in value || value.transform)
231
+ return;
232
+ const values = value.op === "eq"
233
+ ? [value.value]
234
+ : value.op === "in"
235
+ ? [...value.values]
236
+ : undefined;
237
+ // Null is deliberately absent from the scalar index. Fall back to a
238
+ // bounded scan when it can match, including mixed IN predicates.
239
+ if (values?.every((value) => value !== null &&
240
+ (typeof value === "number" ||
241
+ typeof value === "boolean" ||
242
+ (typeof value === "string" && value.length <= 256))))
243
+ return { column: value.column, values };
244
+ })(predicate);
245
+ let records;
246
+ if (exact === undefined && restriction) {
247
+ const values = restriction.values.map((value) => [
248
+ typeof value,
249
+ typeof value === "boolean" ? Number(value) : value,
250
+ ]);
251
+ records = values.length
252
+ ? await this.database.getAllAsync(`SELECT e.scope,e.entity,e.id,e.value FROM _gonvex_replica_entities e WHERE e.scope=? AND e.entity=? AND e.id>? AND e.value<>'null' AND e.id IN (SELECT l.id FROM json_each(?) j JOIN _gonvex_replica_lookup l ON l.scope=? AND l.entity=? AND l.column_name=? AND l.kind=json_extract(j.value,'$[0]') AND l.scalar=json_extract(j.value,'$[1]')) ORDER BY e.id LIMIT 256`, scope, read.table, after ?? "", JSON.stringify(values), scope, read.table, restriction.column)
253
+ : [];
254
+ }
255
+ else
256
+ records =
257
+ exact !== undefined
258
+ ? after
259
+ ? []
260
+ : await this.database.getAllAsync(`SELECT scope, entity, id, value FROM _gonvex_replica_entities WHERE scope = ? AND entity = ? AND id = ?`, scope, read.table, exact)
261
+ : await this.database.getAllAsync(`SELECT scope, entity, id, value FROM _gonvex_replica_entities WHERE scope = ? AND entity = ? AND id > ? AND value <> 'null' ORDER BY id LIMIT 256`, scope, read.table, after ?? "");
262
+ return records
263
+ .filter((record) => record.value !== "null")
264
+ .map((record) => ({
265
+ id: record.id,
266
+ row: JSON.parse(record.value),
267
+ }));
268
+ }));
269
+ });
270
+ return result;
95
271
  }
96
272
  async loadSession(scope) {
97
273
  await this.initialize();
@@ -109,25 +285,31 @@ export class ExpoSQLiteLocalReplicaStorage {
109
285
  await this.initialize();
110
286
  await this.database.withTransactionAsync(async () => {
111
287
  const previous = await this.database.getFirstAsync(`SELECT scope, key, value FROM _gonvex_replica_meta WHERE scope = ? AND key = 'cursor'`, scope);
112
- if (previous && JSON.parse(previous.value).epoch !== transaction.cursor.epoch) {
288
+ if (previous &&
289
+ JSON.parse(previous.value).epoch !== transaction.cursor.epoch) {
290
+ await this.database.runAsync(`DELETE FROM _gonvex_replica_meta WHERE scope = ? AND key LIKE 'row:%'`, scope);
113
291
  await this.database.runAsync(`DELETE FROM _gonvex_replica_entities WHERE scope = ?`, scope);
114
292
  await this.database.runAsync(`DELETE FROM _gonvex_replica_queries WHERE scope = ?`, scope);
115
293
  }
294
+ const grouped = new Map();
116
295
  for (const change of transaction.changes) {
117
- if (change.operation === "delete") {
118
- await this.database.runAsync(`DELETE FROM _gonvex_replica_entities WHERE scope = ? AND entity = ? AND id = ?`, scope, change.entity, change.id);
119
- }
120
- else {
121
- await this.database.runAsync(`INSERT INTO _gonvex_replica_entities (scope, entity, id, value) VALUES (?, ?, ?, ?)
122
- ON CONFLICT(scope, entity, id) DO UPDATE SET value = excluded.value`, scope, change.entity, change.id, JSON.stringify(change.newValue));
123
- }
296
+ const rows = grouped.get(change.entity) ?? [];
297
+ rows.push({
298
+ id: change.id,
299
+ row: change.operation === "delete" ? null : change.newValue,
300
+ });
301
+ grouped.set(change.entity, rows);
124
302
  }
303
+ for (const [entity, rows] of grouped)
304
+ await this.writeRecords(scope, entity, rows, transaction.cursor);
125
305
  for (const membership of transaction.memberships ?? []) {
126
306
  await this.database.runAsync(`INSERT INTO _gonvex_replica_queries (scope, signature, value) VALUES (?, ?, ?)
127
307
  ON CONFLICT(scope, signature) DO UPDATE SET value = excluded.value`, scope, membership.signature, JSON.stringify(membership));
128
308
  }
129
309
  await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
130
- ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`, scope, JSON.stringify(transaction.cursor));
310
+ ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
311
+ WHERE json_extract(_gonvex_replica_meta.value, '$.epoch') <> json_extract(excluded.value, '$.epoch')
312
+ OR json_extract(_gonvex_replica_meta.value, '$.revision') <= json_extract(excluded.value, '$.revision')`, scope, JSON.stringify(transaction.cursor));
131
313
  });
132
314
  }
133
315
  async advanceWatermark(windows, cursor, scope = defaultReplicaScope) {
@@ -141,7 +323,9 @@ export class ExpoSQLiteLocalReplicaStorage {
141
323
  }
142
324
  if (cursor) {
143
325
  await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
144
- ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`, scope, JSON.stringify(cursor));
326
+ ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
327
+ WHERE json_extract(_gonvex_replica_meta.value, '$.epoch') <> json_extract(excluded.value, '$.epoch')
328
+ OR json_extract(_gonvex_replica_meta.value, '$.revision') <= json_extract(excluded.value, '$.revision')`, scope, JSON.stringify(cursor));
145
329
  }
146
330
  });
147
331
  }
@@ -150,7 +334,7 @@ export class ExpoSQLiteLocalReplicaStorage {
150
334
  await this.database.withTransactionAsync(async () => {
151
335
  await this.database.runAsync(`DELETE FROM _gonvex_replica_entities WHERE scope = ?`, scope);
152
336
  await this.database.runAsync(`DELETE FROM _gonvex_replica_queries WHERE scope = ?`, scope);
153
- await this.database.runAsync(`DELETE FROM _gonvex_replica_meta WHERE scope = ? AND key = 'cursor'`, scope);
337
+ await this.database.runAsync(`DELETE FROM _gonvex_replica_meta WHERE scope = ? AND (key = 'cursor' OR key LIKE 'row:%')`, scope);
154
338
  for (const [entity, rows] of Object.entries(snapshot.entities)) {
155
339
  for (const [id, value] of Object.entries(rows)) {
156
340
  await this.database.runAsync(`INSERT INTO _gonvex_replica_entities (scope, entity, id, value) VALUES (?, ?, ?, ?)`, scope, entity, id, JSON.stringify(value));
@@ -161,29 +345,96 @@ export class ExpoSQLiteLocalReplicaStorage {
161
345
  }
162
346
  if (snapshot.cursor) {
163
347
  await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
164
- ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`, scope, JSON.stringify(snapshot.cursor));
348
+ ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
349
+ WHERE json_extract(_gonvex_replica_meta.value, '$.epoch') <> json_extract(excluded.value, '$.epoch')
350
+ OR json_extract(_gonvex_replica_meta.value, '$.revision') <= json_extract(excluded.value, '$.revision')`, scope, JSON.stringify(snapshot.cursor));
165
351
  }
166
352
  });
167
353
  }
168
- async replaceWindow(window, snapshot, scope = defaultReplicaScope) {
354
+ async writeRecords(scope, entity, rows, cursor) {
355
+ const previous = new Map((await this.loadEntityRows(scope, entity, rows.map((row) => row.id))).map((entry) => [entry.id, entry.row]));
356
+ for (let offset = 0; offset < rows.length; offset += 100) {
357
+ const batch = rows.slice(offset, offset + 100);
358
+ const authorityKey = (id) => `row:${JSON.stringify([entity, id])}`;
359
+ const records = cursor
360
+ ? await this.database.getAllAsync(`SELECT scope,key,value FROM _gonvex_replica_meta WHERE scope=? AND key IN (${batch.map(() => "?").join(",")})`, scope, ...batch.map((entry) => authorityKey(entry.id)))
361
+ : [];
362
+ const authorities = new Map(records.map((record) => [
363
+ record.key,
364
+ JSON.parse(record.value),
365
+ ]));
366
+ const versions = batch.map(({ id, row }) => {
367
+ const old = previous.get(id) ?? null;
368
+ if (!cursor)
369
+ return {
370
+ id,
371
+ row: row === null ? null : { ...old, ...row },
372
+ authority: undefined,
373
+ };
374
+ const prior = authorities.get(authorityKey(id)) ?? {
375
+ epoch: cursor.epoch,
376
+ fields: Object.fromEntries(Object.keys(old ?? {}).map((key) => [key, 0])),
377
+ };
378
+ return {
379
+ id,
380
+ ...mergeReplicaRecord({ row: old, authority: prior }, row, cursor),
381
+ };
382
+ });
383
+ const params = versions.flatMap(({ id, row }) => [
384
+ scope,
385
+ entity,
386
+ id,
387
+ JSON.stringify(row),
388
+ ]);
389
+ await this.database.runAsync(`INSERT INTO _gonvex_replica_entities (scope,entity,id,value) VALUES ${batch.map(() => "(?,?,?,?)").join(",")} ON CONFLICT(scope,entity,id) DO UPDATE SET value=excluded.value`, ...params);
390
+ if (cursor)
391
+ await this.database.runAsync(`INSERT INTO _gonvex_replica_meta(scope,key,value) VALUES ${batch.map(() => "(?,?,?)").join(",")} ON CONFLICT(scope,key) DO UPDATE SET value=excluded.value`, ...versions.flatMap(({ id, authority }) => [
392
+ scope,
393
+ authorityKey(id),
394
+ JSON.stringify(authority),
395
+ ]));
396
+ }
397
+ }
398
+ async replaceWindow(window, snapshot, scope = defaultReplicaScope, projection) {
169
399
  await this.initialize();
170
400
  await this.database.withTransactionAsync(async () => {
171
- for (const [entity, rows] of Object.entries(snapshot.entities)) {
172
- for (const [id, value] of Object.entries(rows)) {
173
- await this.database.runAsync(`INSERT INTO _gonvex_replica_entities (scope, entity, id, value) VALUES (?, ?, ?, ?)
174
- ON CONFLICT(scope, entity, id) DO UPDATE SET value = excluded.value`, scope, entity, id, JSON.stringify(value));
175
- }
176
- }
401
+ const oldRecord = await this.database.getFirstAsync(`SELECT scope,signature,value FROM _gonvex_replica_queries WHERE scope=? AND signature=?`, scope, window.signature);
402
+ const oldWindow = oldRecord
403
+ ? JSON.parse(oldRecord.value)
404
+ : undefined;
405
+ if (oldWindow?.cursor &&
406
+ window.cursor &&
407
+ oldWindow.cursor.epoch === window.cursor.epoch &&
408
+ oldWindow.cursor.revision > window.cursor.revision)
409
+ return;
410
+ const rows = projection ??
411
+ window.ids
412
+ .map((id) => snapshot.entities[window.entity]?.[id])
413
+ .filter((row) => row !== undefined);
414
+ await this.writeRecords(scope, window.entity, rows.map((row) => ({ id: String(row[window.key]), row })), window.cursor);
177
415
  await this.database.runAsync(`INSERT INTO _gonvex_replica_queries (scope, signature, value) VALUES (?, ?, ?)
178
416
  ON CONFLICT(scope, signature) DO UPDATE SET value = excluded.value`, scope, window.signature, JSON.stringify(normalizeWindow(window)));
417
+ const removed = oldWindow?.ids.filter((id) => !window.ids.includes(id)) ?? [];
418
+ if (removed.length) {
419
+ const windows = await this.database.getAllAsync(`SELECT scope,signature,value FROM _gonvex_replica_queries WHERE scope=?`, scope);
420
+ const retained = new Set(windows.flatMap((record) => {
421
+ const other = JSON.parse(record.value);
422
+ return other.entity === window.entity ? other.ids : [];
423
+ }));
424
+ await this.writeRecords(scope, window.entity, removed
425
+ .filter((id) => !retained.has(id))
426
+ .map((id) => ({ id, row: null })), window.cursor);
427
+ }
179
428
  if (snapshot.cursor) {
180
429
  await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
181
- ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`, scope, JSON.stringify(snapshot.cursor));
430
+ ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
431
+ WHERE json_extract(_gonvex_replica_meta.value, '$.epoch') <> json_extract(excluded.value, '$.epoch')
432
+ OR json_extract(_gonvex_replica_meta.value, '$.revision') <= json_extract(excluded.value, '$.revision')`, scope, JSON.stringify(snapshot.cursor));
182
433
  }
183
434
  });
184
435
  }
185
- async applyWindowDelta(window, _delta, snapshot, scope = defaultReplicaScope) {
186
- await this.replaceWindow(window, snapshot, scope);
436
+ async applyWindowDelta(window, delta, snapshot, scope = defaultReplicaScope) {
437
+ await this.replaceWindow(window, snapshot, scope, delta.upserts);
187
438
  }
188
439
  async removeWindow(signature, snapshot, scope = defaultReplicaScope) {
189
440
  await this.initialize();
@@ -191,7 +442,9 @@ export class ExpoSQLiteLocalReplicaStorage {
191
442
  await this.database.runAsync(`DELETE FROM _gonvex_replica_queries WHERE scope = ? AND signature = ?`, scope, signature);
192
443
  if (snapshot.cursor) {
193
444
  await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
194
- ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`, scope, JSON.stringify(snapshot.cursor));
445
+ ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
446
+ WHERE json_extract(_gonvex_replica_meta.value, '$.epoch') <> json_extract(excluded.value, '$.epoch')
447
+ OR json_extract(_gonvex_replica_meta.value, '$.revision') <= json_extract(excluded.value, '$.revision')`, scope, JSON.stringify(snapshot.cursor));
195
448
  }
196
449
  });
197
450
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonvex/expo-sqlite",
3
- "version": "0.5.2-staging.13",
3
+ "version": "0.5.2-staging.14",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -18,7 +18,7 @@
18
18
  "README.md"
19
19
  ],
20
20
  "dependencies": {
21
- "@gonvex/client": "0.5.2-staging.13"
21
+ "@gonvex/client": "0.5.2-staging.14"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "^6.0.3",