@gonvex/expo-sqlite 0.5.2-staging.2 → 0.5.2-staging.20
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 +32 -0
- package/dist/index.d.ts +21 -2
- package/dist/index.js +295 -29
- package/dist/outbox.d.ts +71 -0
- package/dist/outbox.js +207 -0
- package/package.json +2 -2
- package/dist/src/index.d.ts +0 -24
- package/dist/src/index.js +0 -135
package/README.md
CHANGED
|
@@ -11,3 +11,35 @@ 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.
|
|
20
|
+
|
|
21
|
+
## Durable reducer outbox
|
|
22
|
+
|
|
23
|
+
`expoSQLiteOutbox(database)` stores queued reducer intents one row per intent,
|
|
24
|
+
with every change in its own SQLite transaction and a persisted id sequence.
|
|
25
|
+
It implements the same `OutboxStore` contract as the browser storage, so the
|
|
26
|
+
SDK keeps owning ordering, retries and crash recovery.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
30
|
+
import { expoSQLite, expoSQLiteOutbox, migrateLegacyOutbox } from "@gonvex/expo-sqlite";
|
|
31
|
+
|
|
32
|
+
const database = await openDatabaseAsync("gonvex.db");
|
|
33
|
+
const outbox = expoSQLiteOutbox(database);
|
|
34
|
+
// One-time import of an older whole-list AsyncStorage outbox. Safe on every
|
|
35
|
+
// launch; the key is removed only after the import committed.
|
|
36
|
+
await migrateLegacyOutbox({ store: outbox, storage: AsyncStorage, key: "wh_gonvex_v2_reducer_outbox" });
|
|
37
|
+
|
|
38
|
+
const client = new GonvexClient(url, {
|
|
39
|
+
localReplica: { storage: expoSQLite(database) },
|
|
40
|
+
outbox: { store: outbox },
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run the migration before constructing the client. Imported entries keep their
|
|
45
|
+
ids and idempotency keys, so a replay is still deduplicated by the runtime.
|
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,16 +18,34 @@ 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>;
|
|
36
|
+
loadSession(scope: string): Promise<import("@gonvex/client").LocalReplicaSession | undefined>;
|
|
37
|
+
saveSession(scope: string, session: import("@gonvex/client").LocalReplicaSession | undefined): Promise<void>;
|
|
21
38
|
applyTransaction(transaction: ReplicaTransaction, _snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
|
|
22
39
|
advanceWatermark(windows: readonly ReplicaWindow[], cursor: ReplicaSnapshot["cursor"], scope?: ReplicaScope): Promise<void>;
|
|
23
40
|
replaceSnapshot(snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
private writeRecords;
|
|
42
|
+
replaceWindow(window: ReplicaWindow, snapshot: ReplicaSnapshot, scope?: ReplicaScope, projection?: readonly ReplicaRow[]): Promise<void>;
|
|
43
|
+
applyWindowDelta(window: ReplicaWindow, delta: {
|
|
26
44
|
upserts: ReplicaRow[];
|
|
27
45
|
deleted: string[];
|
|
28
46
|
}, snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
|
|
29
47
|
removeWindow(signature: string, snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
|
|
30
48
|
clear(scope?: ReplicaScope): Promise<void>;
|
|
31
49
|
}
|
|
50
|
+
export * from "./outbox.js";
|
|
32
51
|
export declare function expoSQLite(database: ExpoSQLiteDatabase): LocalReplicaStorage;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { pagedReplicaReadView, mergeReplicaRecord, } from "@gonvex/client";
|
|
1
2
|
const defaultReplicaScope = "default";
|
|
2
|
-
const replicaSchemaVersion =
|
|
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 &&
|
|
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.
|
|
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
|
-
(
|
|
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,31 +126,190 @@ export class ExpoSQLiteLocalReplicaStorage {
|
|
|
91
126
|
}
|
|
92
127
|
if (!cursor && entityRecords.length === 0 && queryRecords.length === 0)
|
|
93
128
|
return undefined;
|
|
94
|
-
return {
|
|
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;
|
|
271
|
+
}
|
|
272
|
+
async loadSession(scope) {
|
|
273
|
+
await this.initialize();
|
|
274
|
+
const row = await this.database.getFirstAsync(`SELECT scope, key, value FROM _gonvex_replica_meta WHERE scope = ? AND key = 'session'`, scope);
|
|
275
|
+
return row ? JSON.parse(row.value) : undefined;
|
|
276
|
+
}
|
|
277
|
+
async saveSession(scope, session) {
|
|
278
|
+
await this.initialize();
|
|
279
|
+
if (session)
|
|
280
|
+
await this.database.runAsync(`INSERT OR REPLACE INTO _gonvex_replica_meta (scope,key,value) VALUES (?, 'session', ?)`, scope, JSON.stringify(session));
|
|
281
|
+
else
|
|
282
|
+
await this.database.runAsync(`DELETE FROM _gonvex_replica_meta WHERE scope = ? AND key = 'session'`, scope);
|
|
95
283
|
}
|
|
96
284
|
async applyTransaction(transaction, _snapshot, scope = defaultReplicaScope) {
|
|
97
285
|
await this.initialize();
|
|
98
286
|
await this.database.withTransactionAsync(async () => {
|
|
99
287
|
const previous = await this.database.getFirstAsync(`SELECT scope, key, value FROM _gonvex_replica_meta WHERE scope = ? AND key = 'cursor'`, scope);
|
|
100
|
-
if (previous &&
|
|
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);
|
|
101
291
|
await this.database.runAsync(`DELETE FROM _gonvex_replica_entities WHERE scope = ?`, scope);
|
|
102
292
|
await this.database.runAsync(`DELETE FROM _gonvex_replica_queries WHERE scope = ?`, scope);
|
|
103
293
|
}
|
|
294
|
+
const grouped = new Map();
|
|
104
295
|
for (const change of transaction.changes) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
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);
|
|
112
302
|
}
|
|
303
|
+
for (const [entity, rows] of grouped)
|
|
304
|
+
await this.writeRecords(scope, entity, rows, transaction.cursor);
|
|
113
305
|
for (const membership of transaction.memberships ?? []) {
|
|
114
306
|
await this.database.runAsync(`INSERT INTO _gonvex_replica_queries (scope, signature, value) VALUES (?, ?, ?)
|
|
115
307
|
ON CONFLICT(scope, signature) DO UPDATE SET value = excluded.value`, scope, membership.signature, JSON.stringify(membership));
|
|
116
308
|
}
|
|
117
309
|
await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
|
|
118
|
-
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
|
|
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));
|
|
119
313
|
});
|
|
120
314
|
}
|
|
121
315
|
async advanceWatermark(windows, cursor, scope = defaultReplicaScope) {
|
|
@@ -129,7 +323,9 @@ export class ExpoSQLiteLocalReplicaStorage {
|
|
|
129
323
|
}
|
|
130
324
|
if (cursor) {
|
|
131
325
|
await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
|
|
132
|
-
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
|
|
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));
|
|
133
329
|
}
|
|
134
330
|
});
|
|
135
331
|
}
|
|
@@ -138,7 +334,7 @@ export class ExpoSQLiteLocalReplicaStorage {
|
|
|
138
334
|
await this.database.withTransactionAsync(async () => {
|
|
139
335
|
await this.database.runAsync(`DELETE FROM _gonvex_replica_entities WHERE scope = ?`, scope);
|
|
140
336
|
await this.database.runAsync(`DELETE FROM _gonvex_replica_queries WHERE scope = ?`, scope);
|
|
141
|
-
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);
|
|
142
338
|
for (const [entity, rows] of Object.entries(snapshot.entities)) {
|
|
143
339
|
for (const [id, value] of Object.entries(rows)) {
|
|
144
340
|
await this.database.runAsync(`INSERT INTO _gonvex_replica_entities (scope, entity, id, value) VALUES (?, ?, ?, ?)`, scope, entity, id, JSON.stringify(value));
|
|
@@ -149,29 +345,96 @@ export class ExpoSQLiteLocalReplicaStorage {
|
|
|
149
345
|
}
|
|
150
346
|
if (snapshot.cursor) {
|
|
151
347
|
await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
|
|
152
|
-
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
|
|
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));
|
|
153
351
|
}
|
|
154
352
|
});
|
|
155
353
|
}
|
|
156
|
-
async
|
|
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) {
|
|
157
399
|
await this.initialize();
|
|
158
400
|
await this.database.withTransactionAsync(async () => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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);
|
|
165
415
|
await this.database.runAsync(`INSERT INTO _gonvex_replica_queries (scope, signature, value) VALUES (?, ?, ?)
|
|
166
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
|
+
}
|
|
167
428
|
if (snapshot.cursor) {
|
|
168
429
|
await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
|
|
169
|
-
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
|
|
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));
|
|
170
433
|
}
|
|
171
434
|
});
|
|
172
435
|
}
|
|
173
|
-
async applyWindowDelta(window,
|
|
174
|
-
await this.replaceWindow(window, snapshot, scope);
|
|
436
|
+
async applyWindowDelta(window, delta, snapshot, scope = defaultReplicaScope) {
|
|
437
|
+
await this.replaceWindow(window, snapshot, scope, delta.upserts);
|
|
175
438
|
}
|
|
176
439
|
async removeWindow(signature, snapshot, scope = defaultReplicaScope) {
|
|
177
440
|
await this.initialize();
|
|
@@ -179,7 +442,9 @@ export class ExpoSQLiteLocalReplicaStorage {
|
|
|
179
442
|
await this.database.runAsync(`DELETE FROM _gonvex_replica_queries WHERE scope = ? AND signature = ?`, scope, signature);
|
|
180
443
|
if (snapshot.cursor) {
|
|
181
444
|
await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
|
|
182
|
-
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value
|
|
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));
|
|
183
448
|
}
|
|
184
449
|
});
|
|
185
450
|
}
|
|
@@ -192,6 +457,7 @@ export class ExpoSQLiteLocalReplicaStorage {
|
|
|
192
457
|
});
|
|
193
458
|
}
|
|
194
459
|
}
|
|
460
|
+
export * from "./outbox.js";
|
|
195
461
|
export function expoSQLite(database) {
|
|
196
462
|
return new ExpoSQLiteLocalReplicaStorage(database);
|
|
197
463
|
}
|
package/dist/outbox.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { OutboxStore, ReducerOutboxEntry } from "@gonvex/client";
|
|
2
|
+
import type { ExpoSQLiteDatabase } from "./index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Durable reducer outbox storage on Expo SQLite.
|
|
5
|
+
*
|
|
6
|
+
* One row per intent, keyed by the SDK's sequence id, with every mutation in
|
|
7
|
+
* its own SQLite transaction. Unlike a single JSON blob rewritten on each
|
|
8
|
+
* change, an update touches one row and a crash can never truncate the whole
|
|
9
|
+
* queue. Ids come from a persisted sequence, so a deleted entry's id is never
|
|
10
|
+
* reused across restarts.
|
|
11
|
+
*
|
|
12
|
+
* Pass the same database handle that backs the Local Replica or a dedicated
|
|
13
|
+
* one; the tables are prefixed with `_gonvex_outbox`.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ExpoSQLiteOutboxStore implements OutboxStore {
|
|
16
|
+
private readonly database;
|
|
17
|
+
readonly strictPersistence = false;
|
|
18
|
+
private initialized?;
|
|
19
|
+
private queue;
|
|
20
|
+
constructor(database: ExpoSQLiteDatabase);
|
|
21
|
+
load(scope?: string): Promise<ReducerOutboxEntry[]>;
|
|
22
|
+
put(entry: ReducerOutboxEntry): Promise<void>;
|
|
23
|
+
delete(id: number): Promise<void>;
|
|
24
|
+
clear(scope?: string): Promise<void>;
|
|
25
|
+
allocateId(): Promise<number>;
|
|
26
|
+
append(draft: Omit<ReducerOutboxEntry, "id">): Promise<ReducerOutboxEntry>;
|
|
27
|
+
update(id: number, change: (entry: ReducerOutboxEntry) => ReducerOutboxEntry): Promise<ReducerOutboxEntry | undefined>;
|
|
28
|
+
/**
|
|
29
|
+
* Import entries from a legacy whole-list JSON blob (for example the value
|
|
30
|
+
* an app kept under an AsyncStorage key). Runs in one transaction and is
|
|
31
|
+
* idempotent: an entry whose scope and idempotency key already exist is
|
|
32
|
+
* skipped, so re-running after a crash never duplicates an intent. Legacy
|
|
33
|
+
* ids are preserved to keep enqueue order; an id already taken by a
|
|
34
|
+
* different intent gets a fresh sequence id.
|
|
35
|
+
*/
|
|
36
|
+
importLegacy(legacy: string | readonly unknown[] | null | undefined): Promise<LegacyOutboxImport>;
|
|
37
|
+
private initialize;
|
|
38
|
+
/**
|
|
39
|
+
* Expo's withTransactionAsync is not exclusive against other statements on
|
|
40
|
+
* the same connection. Serialize this store's own work so two outbox
|
|
41
|
+
* transactions never interleave.
|
|
42
|
+
*/
|
|
43
|
+
private serial;
|
|
44
|
+
private transaction;
|
|
45
|
+
private reserveId;
|
|
46
|
+
private write;
|
|
47
|
+
}
|
|
48
|
+
export type LegacyOutboxImport = {
|
|
49
|
+
imported: number;
|
|
50
|
+
/** Entries already present (same scope and idempotency key). */
|
|
51
|
+
skipped: number;
|
|
52
|
+
/** Values that were not recognizable outbox entries. */
|
|
53
|
+
invalid: number;
|
|
54
|
+
};
|
|
55
|
+
export declare function expoSQLiteOutbox(database: ExpoSQLiteDatabase): ExpoSQLiteOutboxStore;
|
|
56
|
+
/** The subset of AsyncStorage the one-time migration needs. */
|
|
57
|
+
export type LegacyKeyValueStorage = {
|
|
58
|
+
getItem(key: string): Promise<string | null>;
|
|
59
|
+
removeItem(key: string): Promise<void>;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* One-time move of a legacy AsyncStorage outbox blob into SQLite. Run it
|
|
63
|
+
* before constructing the GonvexClient. The legacy key is removed only after
|
|
64
|
+
* the import committed; an unreadable blob throws and is left untouched so no
|
|
65
|
+
* intent is ever lost. Safe to call on every launch.
|
|
66
|
+
*/
|
|
67
|
+
export declare function migrateLegacyOutbox(options: {
|
|
68
|
+
store: ExpoSQLiteOutboxStore;
|
|
69
|
+
storage: LegacyKeyValueStorage;
|
|
70
|
+
key: string;
|
|
71
|
+
}): Promise<LegacyOutboxImport>;
|
package/dist/outbox.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const outboxStates = new Set(["pending", "inflight", "committed", "failed", "rejected"]);
|
|
2
|
+
/**
|
|
3
|
+
* Durable reducer outbox storage on Expo SQLite.
|
|
4
|
+
*
|
|
5
|
+
* One row per intent, keyed by the SDK's sequence id, with every mutation in
|
|
6
|
+
* its own SQLite transaction. Unlike a single JSON blob rewritten on each
|
|
7
|
+
* change, an update touches one row and a crash can never truncate the whole
|
|
8
|
+
* queue. Ids come from a persisted sequence, so a deleted entry's id is never
|
|
9
|
+
* reused across restarts.
|
|
10
|
+
*
|
|
11
|
+
* Pass the same database handle that backs the Local Replica or a dedicated
|
|
12
|
+
* one; the tables are prefixed with `_gonvex_outbox`.
|
|
13
|
+
*/
|
|
14
|
+
export class ExpoSQLiteOutboxStore {
|
|
15
|
+
database;
|
|
16
|
+
strictPersistence = false;
|
|
17
|
+
initialized;
|
|
18
|
+
queue = Promise.resolve();
|
|
19
|
+
constructor(database) {
|
|
20
|
+
this.database = database;
|
|
21
|
+
}
|
|
22
|
+
load(scope) {
|
|
23
|
+
return this.serial(async () => {
|
|
24
|
+
const rows = scope === undefined
|
|
25
|
+
? await this.database.getAllAsync(`SELECT value FROM _gonvex_outbox ORDER BY id`)
|
|
26
|
+
: await this.database.getAllAsync(`SELECT value FROM _gonvex_outbox WHERE scope = ? ORDER BY id`, scope);
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
const entry = parseEntry(row.value);
|
|
30
|
+
if (entry)
|
|
31
|
+
entries.push(entry);
|
|
32
|
+
}
|
|
33
|
+
return entries;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
put(entry) {
|
|
37
|
+
return this.serial(() => this.transaction(() => this.write(entry)));
|
|
38
|
+
}
|
|
39
|
+
delete(id) {
|
|
40
|
+
return this.serial(async () => {
|
|
41
|
+
await this.database.runAsync(`DELETE FROM _gonvex_outbox WHERE id = ?`, id);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
clear(scope) {
|
|
45
|
+
return this.serial(async () => {
|
|
46
|
+
if (scope === undefined)
|
|
47
|
+
await this.database.runAsync(`DELETE FROM _gonvex_outbox`);
|
|
48
|
+
else
|
|
49
|
+
await this.database.runAsync(`DELETE FROM _gonvex_outbox WHERE scope = ?`, scope);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
allocateId() {
|
|
53
|
+
return this.serial(() => this.transaction(() => this.reserveId()));
|
|
54
|
+
}
|
|
55
|
+
append(draft) {
|
|
56
|
+
return this.serial(() => this.transaction(async () => {
|
|
57
|
+
const entry = { ...draft, id: await this.reserveId() };
|
|
58
|
+
await this.write(entry);
|
|
59
|
+
return entry;
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
update(id, change) {
|
|
63
|
+
return this.serial(() => this.transaction(async () => {
|
|
64
|
+
const row = await this.database.getFirstAsync(`SELECT value FROM _gonvex_outbox WHERE id = ?`, id);
|
|
65
|
+
const prior = row ? parseEntry(row.value) : undefined;
|
|
66
|
+
if (!prior)
|
|
67
|
+
return undefined;
|
|
68
|
+
const next = change(prior);
|
|
69
|
+
if (next.id !== prior.id || next.scope !== prior.scope || next.idempotencyKey !== prior.idempotencyKey) {
|
|
70
|
+
throw new Error("Outbox identity cannot change");
|
|
71
|
+
}
|
|
72
|
+
if (JSON.stringify(next) !== JSON.stringify(prior))
|
|
73
|
+
await this.write(next);
|
|
74
|
+
return next;
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Import entries from a legacy whole-list JSON blob (for example the value
|
|
79
|
+
* an app kept under an AsyncStorage key). Runs in one transaction and is
|
|
80
|
+
* idempotent: an entry whose scope and idempotency key already exist is
|
|
81
|
+
* skipped, so re-running after a crash never duplicates an intent. Legacy
|
|
82
|
+
* ids are preserved to keep enqueue order; an id already taken by a
|
|
83
|
+
* different intent gets a fresh sequence id.
|
|
84
|
+
*/
|
|
85
|
+
importLegacy(legacy) {
|
|
86
|
+
const parsed = typeof legacy === "string" ? JSON.parse(legacy) : legacy;
|
|
87
|
+
if (parsed === null || parsed === undefined)
|
|
88
|
+
return Promise.resolve({ imported: 0, skipped: 0, invalid: 0 });
|
|
89
|
+
if (!Array.isArray(parsed))
|
|
90
|
+
return Promise.reject(new Error("Legacy Gonvex outbox must be a JSON array"));
|
|
91
|
+
return this.serial(() => this.transaction(async () => {
|
|
92
|
+
const sorted = parsed
|
|
93
|
+
.map((value) => validEntry(value))
|
|
94
|
+
.filter((entry) => entry !== undefined)
|
|
95
|
+
.sort((left, right) => left.id - right.id);
|
|
96
|
+
const result = { imported: 0, skipped: 0, invalid: parsed.length - sorted.length };
|
|
97
|
+
for (const entry of sorted) {
|
|
98
|
+
const existing = await this.database.getFirstAsync(`SELECT id FROM _gonvex_outbox WHERE scope = ? AND idempotency_key = ?`, entry.scope, entry.idempotencyKey);
|
|
99
|
+
if (existing) {
|
|
100
|
+
result.skipped += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const taken = await this.database.getFirstAsync(`SELECT id FROM _gonvex_outbox WHERE id = ?`, entry.id);
|
|
104
|
+
// A legacy row that was mid-send when the app died is resumed exactly
|
|
105
|
+
// like the SDK's own crash recovery: back to pending, same key.
|
|
106
|
+
const imported = {
|
|
107
|
+
...entry,
|
|
108
|
+
...(taken ? { id: await this.reserveId() } : {}),
|
|
109
|
+
...(entry.state === "inflight" ? { state: "pending" } : {}),
|
|
110
|
+
};
|
|
111
|
+
await this.write(imported);
|
|
112
|
+
result.imported += 1;
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
initialize() {
|
|
118
|
+
return (this.initialized ??= this.database.withTransactionAsync(async () => {
|
|
119
|
+
await this.database.execAsync(`
|
|
120
|
+
CREATE TABLE IF NOT EXISTS _gonvex_outbox (
|
|
121
|
+
id INTEGER PRIMARY KEY,
|
|
122
|
+
scope TEXT NOT NULL,
|
|
123
|
+
idempotency_key TEXT NOT NULL,
|
|
124
|
+
state TEXT NOT NULL,
|
|
125
|
+
value TEXT NOT NULL
|
|
126
|
+
);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS _gonvex_outbox_scope ON _gonvex_outbox(scope, id);
|
|
128
|
+
CREATE UNIQUE INDEX IF NOT EXISTS _gonvex_outbox_intent ON _gonvex_outbox(scope, idempotency_key);
|
|
129
|
+
CREATE TABLE IF NOT EXISTS _gonvex_outbox_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
|
|
130
|
+
`);
|
|
131
|
+
}).catch((error) => {
|
|
132
|
+
this.initialized = undefined;
|
|
133
|
+
throw error;
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Expo's withTransactionAsync is not exclusive against other statements on
|
|
138
|
+
* the same connection. Serialize this store's own work so two outbox
|
|
139
|
+
* transactions never interleave.
|
|
140
|
+
*/
|
|
141
|
+
serial(run) {
|
|
142
|
+
const job = this.queue.then(async () => {
|
|
143
|
+
await this.initialize();
|
|
144
|
+
return run();
|
|
145
|
+
});
|
|
146
|
+
this.queue = job.catch(() => undefined);
|
|
147
|
+
return job;
|
|
148
|
+
}
|
|
149
|
+
async transaction(run) {
|
|
150
|
+
let result;
|
|
151
|
+
await this.database.withTransactionAsync(async () => { result = await run(); });
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
async reserveId() {
|
|
155
|
+
const counter = await this.database.getFirstAsync(`SELECT value FROM _gonvex_outbox_meta WHERE key = 'sequence'`);
|
|
156
|
+
const highest = await this.database.getFirstAsync(`SELECT MAX(id) AS id FROM _gonvex_outbox`);
|
|
157
|
+
const id = Math.max(Number(counter?.value ?? 0), Number(highest?.id ?? 0)) + 1;
|
|
158
|
+
await this.database.runAsync(`INSERT INTO _gonvex_outbox_meta (key, value) VALUES ('sequence', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, id);
|
|
159
|
+
return id;
|
|
160
|
+
}
|
|
161
|
+
async write(entry) {
|
|
162
|
+
await this.database.runAsync(`INSERT OR REPLACE INTO _gonvex_outbox (id, scope, idempotency_key, state, value) VALUES (?, ?, ?, ?, ?)`, entry.id, entry.scope, entry.idempotencyKey, entry.state, JSON.stringify(entry));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export function expoSQLiteOutbox(database) {
|
|
166
|
+
return new ExpoSQLiteOutboxStore(database);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* One-time move of a legacy AsyncStorage outbox blob into SQLite. Run it
|
|
170
|
+
* before constructing the GonvexClient. The legacy key is removed only after
|
|
171
|
+
* the import committed; an unreadable blob throws and is left untouched so no
|
|
172
|
+
* intent is ever lost. Safe to call on every launch.
|
|
173
|
+
*/
|
|
174
|
+
export async function migrateLegacyOutbox(options) {
|
|
175
|
+
const raw = await options.storage.getItem(options.key);
|
|
176
|
+
if (raw === null || raw === undefined)
|
|
177
|
+
return { imported: 0, skipped: 0, invalid: 0 };
|
|
178
|
+
const result = await options.store.importLegacy(raw);
|
|
179
|
+
await options.storage.removeItem(options.key);
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
function parseEntry(value) {
|
|
183
|
+
try {
|
|
184
|
+
return validEntry(JSON.parse(value));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function validEntry(value) {
|
|
191
|
+
if (!value || typeof value !== "object")
|
|
192
|
+
return undefined;
|
|
193
|
+
const entry = value;
|
|
194
|
+
if (typeof entry.id !== "number" || !Number.isSafeInteger(entry.id) || entry.id <= 0
|
|
195
|
+
|| typeof entry.scope !== "string" || entry.scope.length === 0
|
|
196
|
+
|| typeof entry.path !== "string"
|
|
197
|
+
|| typeof entry.idempotencyKey !== "string" || entry.idempotencyKey.length === 0
|
|
198
|
+
|| typeof entry.state !== "string" || !outboxStates.has(entry.state))
|
|
199
|
+
return undefined;
|
|
200
|
+
return {
|
|
201
|
+
...entry,
|
|
202
|
+
entityKeys: Array.isArray(entry.entityKeys) ? entry.entityKeys : [],
|
|
203
|
+
createdAt: typeof entry.createdAt === "number" ? entry.createdAt : 0,
|
|
204
|
+
attempts: typeof entry.attempts === "number" ? entry.attempts : 0,
|
|
205
|
+
nextAttemptAt: typeof entry.nextAttemptAt === "number" ? entry.nextAttemptAt : 0,
|
|
206
|
+
};
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonvex/expo-sqlite",
|
|
3
|
-
"version": "0.5.2-staging.
|
|
3
|
+
"version": "0.5.2-staging.20",
|
|
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.
|
|
21
|
+
"@gonvex/client": "0.5.2-staging.20"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"typescript": "^6.0.3",
|
package/dist/src/index.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { LocalReplicaStorage, ReplicaScope, ReplicaSnapshot, ReplicaTransaction } from "@gonvex/client";
|
|
2
|
-
export interface ExpoSQLiteDatabase {
|
|
3
|
-
execAsync(sql: string): Promise<void>;
|
|
4
|
-
runAsync(sql: string, ...params: unknown[]): Promise<unknown>;
|
|
5
|
-
getFirstAsync<T>(sql: string, ...params: unknown[]): Promise<T | null>;
|
|
6
|
-
getAllAsync<T>(sql: string, ...params: unknown[]): Promise<T[]>;
|
|
7
|
-
withTransactionAsync(task: () => Promise<void>): Promise<void>;
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* Transactional, normalized SQLite persistence for Expo. Every server
|
|
11
|
-
* transaction updates entities, Live Query memberships, and the replica
|
|
12
|
-
* cursor inside one SQLite transaction.
|
|
13
|
-
*/
|
|
14
|
-
export declare class ExpoSQLiteLocalReplicaStorage implements LocalReplicaStorage {
|
|
15
|
-
private readonly database;
|
|
16
|
-
private initialized?;
|
|
17
|
-
constructor(database: ExpoSQLiteDatabase);
|
|
18
|
-
private initialize;
|
|
19
|
-
private migrateSchema;
|
|
20
|
-
load(scope?: ReplicaScope): Promise<ReplicaSnapshot | undefined>;
|
|
21
|
-
applyTransaction(transaction: ReplicaTransaction, _snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
|
|
22
|
-
replaceSnapshot(snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
|
|
23
|
-
}
|
|
24
|
-
export declare function expoSQLite(database: ExpoSQLiteDatabase): LocalReplicaStorage;
|
package/dist/src/index.js
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
const defaultReplicaScope = "default";
|
|
2
|
-
const replicaSchemaVersion = 2;
|
|
3
|
-
/**
|
|
4
|
-
* Transactional, normalized SQLite persistence for Expo. Every server
|
|
5
|
-
* transaction updates entities, Live Query memberships, and the replica
|
|
6
|
-
* cursor inside one SQLite transaction.
|
|
7
|
-
*/
|
|
8
|
-
export class ExpoSQLiteLocalReplicaStorage {
|
|
9
|
-
database;
|
|
10
|
-
initialized;
|
|
11
|
-
constructor(database) {
|
|
12
|
-
this.database = database;
|
|
13
|
-
}
|
|
14
|
-
initialize() {
|
|
15
|
-
return this.initialized ??= this.migrateSchema();
|
|
16
|
-
}
|
|
17
|
-
async migrateSchema() {
|
|
18
|
-
const [entities, queries, meta] = await Promise.all([
|
|
19
|
-
this.database.getAllAsync(`PRAGMA table_info(_gonvex_replica_entities)`),
|
|
20
|
-
this.database.getAllAsync(`PRAGMA table_info(_gonvex_replica_queries)`),
|
|
21
|
-
this.database.getAllAsync(`PRAGMA table_info(_gonvex_replica_meta)`),
|
|
22
|
-
]);
|
|
23
|
-
const legacyEntities = entities.length > 0 && !entities.some((column) => column.name === "scope");
|
|
24
|
-
const legacyQueries = queries.length > 0 && !queries.some((column) => column.name === "scope");
|
|
25
|
-
const legacyMeta = meta.length > 0 && !meta.some((column) => column.name === "scope");
|
|
26
|
-
const createTables = `
|
|
27
|
-
CREATE TABLE IF NOT EXISTS _gonvex_replica_entities (
|
|
28
|
-
scope TEXT NOT NULL, entity TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL,
|
|
29
|
-
PRIMARY KEY (scope, entity, id)
|
|
30
|
-
);
|
|
31
|
-
CREATE TABLE IF NOT EXISTS _gonvex_replica_queries (
|
|
32
|
-
scope TEXT NOT NULL, signature TEXT NOT NULL, value TEXT NOT NULL,
|
|
33
|
-
PRIMARY KEY (scope, signature)
|
|
34
|
-
);
|
|
35
|
-
CREATE TABLE IF NOT EXISTS _gonvex_replica_meta (
|
|
36
|
-
scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
37
|
-
PRIMARY KEY (scope, key)
|
|
38
|
-
);
|
|
39
|
-
`;
|
|
40
|
-
if (!legacyEntities && !legacyQueries && !legacyMeta) {
|
|
41
|
-
await this.database.execAsync(`PRAGMA user_version = ${replicaSchemaVersion};${createTables}`);
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
// The v1 tables had no namespace at all. Keep their contents available to
|
|
45
|
-
// direct/default LocalReplica users, but never leave those rows in the
|
|
46
|
-
// canonical tables where an authenticated scope could read them.
|
|
47
|
-
await this.database.withTransactionAsync(async () => {
|
|
48
|
-
if (legacyEntities)
|
|
49
|
-
await this.database.execAsync(`ALTER TABLE _gonvex_replica_entities RENAME TO _gonvex_replica_entities_legacy_v1`);
|
|
50
|
-
if (legacyQueries)
|
|
51
|
-
await this.database.execAsync(`ALTER TABLE _gonvex_replica_queries RENAME TO _gonvex_replica_queries_legacy_v1`);
|
|
52
|
-
if (legacyMeta)
|
|
53
|
-
await this.database.execAsync(`ALTER TABLE _gonvex_replica_meta RENAME TO _gonvex_replica_meta_legacy_v1`);
|
|
54
|
-
await this.database.execAsync(createTables);
|
|
55
|
-
if (legacyEntities) {
|
|
56
|
-
await this.database.execAsync(`INSERT INTO _gonvex_replica_entities (scope, entity, id, value)
|
|
57
|
-
SELECT '${defaultReplicaScope}', entity, id, value FROM _gonvex_replica_entities_legacy_v1`);
|
|
58
|
-
}
|
|
59
|
-
if (legacyQueries) {
|
|
60
|
-
await this.database.execAsync(`INSERT INTO _gonvex_replica_queries (scope, signature, value)
|
|
61
|
-
SELECT '${defaultReplicaScope}', signature, value FROM _gonvex_replica_queries_legacy_v1`);
|
|
62
|
-
}
|
|
63
|
-
if (legacyMeta) {
|
|
64
|
-
await this.database.execAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value)
|
|
65
|
-
SELECT '${defaultReplicaScope}', key, value FROM _gonvex_replica_meta_legacy_v1`);
|
|
66
|
-
}
|
|
67
|
-
await this.database.execAsync(`PRAGMA user_version = ${replicaSchemaVersion}`);
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
async load(scope = defaultReplicaScope) {
|
|
71
|
-
await this.initialize();
|
|
72
|
-
const cursor = await this.database.getFirstAsync(`SELECT scope, key, value FROM _gonvex_replica_meta WHERE scope = ? AND key = 'cursor'`, scope);
|
|
73
|
-
const entities = {};
|
|
74
|
-
const entityRecords = await this.database.getAllAsync(`SELECT scope, entity, id, value FROM _gonvex_replica_entities WHERE scope = ? ORDER BY entity, id`, scope);
|
|
75
|
-
for (const row of entityRecords) {
|
|
76
|
-
(entities[row.entity] ??= {})[row.id] = JSON.parse(row.value);
|
|
77
|
-
}
|
|
78
|
-
const liveQueries = {};
|
|
79
|
-
const queryRecords = await this.database.getAllAsync(`SELECT scope, signature, value FROM _gonvex_replica_queries WHERE scope = ? ORDER BY signature`, scope);
|
|
80
|
-
for (const row of queryRecords) {
|
|
81
|
-
liveQueries[row.signature] = JSON.parse(row.value);
|
|
82
|
-
}
|
|
83
|
-
if (!cursor && entityRecords.length === 0 && queryRecords.length === 0)
|
|
84
|
-
return undefined;
|
|
85
|
-
return { cursor: cursor ? JSON.parse(cursor.value) : undefined, entities, liveQueries };
|
|
86
|
-
}
|
|
87
|
-
async applyTransaction(transaction, _snapshot, scope = defaultReplicaScope) {
|
|
88
|
-
await this.initialize();
|
|
89
|
-
await this.database.withTransactionAsync(async () => {
|
|
90
|
-
const previous = await this.database.getFirstAsync(`SELECT scope, key, value FROM _gonvex_replica_meta WHERE scope = ? AND key = 'cursor'`, scope);
|
|
91
|
-
if (previous && JSON.parse(previous.value).epoch !== transaction.cursor.epoch) {
|
|
92
|
-
await this.database.runAsync(`DELETE FROM _gonvex_replica_entities WHERE scope = ?`, scope);
|
|
93
|
-
await this.database.runAsync(`DELETE FROM _gonvex_replica_queries WHERE scope = ?`, scope);
|
|
94
|
-
}
|
|
95
|
-
for (const change of transaction.changes) {
|
|
96
|
-
if (change.operation === "delete") {
|
|
97
|
-
await this.database.runAsync(`DELETE FROM _gonvex_replica_entities WHERE scope = ? AND entity = ? AND id = ?`, scope, change.entity, change.id);
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
await this.database.runAsync(`INSERT INTO _gonvex_replica_entities (scope, entity, id, value) VALUES (?, ?, ?, ?)
|
|
101
|
-
ON CONFLICT(scope, entity, id) DO UPDATE SET value = excluded.value`, scope, change.entity, change.id, JSON.stringify(change.newValue));
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
for (const membership of transaction.memberships ?? []) {
|
|
105
|
-
await this.database.runAsync(`INSERT INTO _gonvex_replica_queries (scope, signature, value) VALUES (?, ?, ?)
|
|
106
|
-
ON CONFLICT(scope, signature) DO UPDATE SET value = excluded.value`, scope, membership.signature, JSON.stringify(membership));
|
|
107
|
-
}
|
|
108
|
-
await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
|
|
109
|
-
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`, scope, JSON.stringify(transaction.cursor));
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
async replaceSnapshot(snapshot, scope = defaultReplicaScope) {
|
|
113
|
-
await this.initialize();
|
|
114
|
-
await this.database.withTransactionAsync(async () => {
|
|
115
|
-
await this.database.runAsync(`DELETE FROM _gonvex_replica_entities WHERE scope = ?`, scope);
|
|
116
|
-
await this.database.runAsync(`DELETE FROM _gonvex_replica_queries WHERE scope = ?`, scope);
|
|
117
|
-
await this.database.runAsync(`DELETE FROM _gonvex_replica_meta WHERE scope = ? AND key = 'cursor'`, scope);
|
|
118
|
-
for (const [entity, rows] of Object.entries(snapshot.entities)) {
|
|
119
|
-
for (const [id, value] of Object.entries(rows)) {
|
|
120
|
-
await this.database.runAsync(`INSERT INTO _gonvex_replica_entities (scope, entity, id, value) VALUES (?, ?, ?, ?)`, scope, entity, id, JSON.stringify(value));
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
for (const [signature, membership] of Object.entries(snapshot.liveQueries)) {
|
|
124
|
-
await this.database.runAsync(`INSERT INTO _gonvex_replica_queries (scope, signature, value) VALUES (?, ?, ?)`, scope, signature, JSON.stringify(membership));
|
|
125
|
-
}
|
|
126
|
-
if (snapshot.cursor) {
|
|
127
|
-
await this.database.runAsync(`INSERT INTO _gonvex_replica_meta (scope, key, value) VALUES (?, 'cursor', ?)
|
|
128
|
-
ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value`, scope, JSON.stringify(snapshot.cursor));
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
export function expoSQLite(database) {
|
|
134
|
-
return new ExpoSQLiteLocalReplicaStorage(database);
|
|
135
|
-
}
|