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

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
@@ -17,3 +17,29 @@ reads use 256-row pages and retain only the requested result window. The adapter
17
17
  keeps normalized rows and query memberships on disk; it does not hydrate the
18
18
  whole database to execute a Reducer. Incoming projections update only their fields.
19
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
@@ -47,4 +47,5 @@ export declare class ExpoSQLiteLocalReplicaStorage implements LocalReplicaStorag
47
47
  removeWindow(signature: string, snapshot: ReplicaSnapshot, scope?: ReplicaScope): Promise<void>;
48
48
  clear(scope?: ReplicaScope): Promise<void>;
49
49
  }
50
+ export * from "./outbox.js";
50
51
  export declare function expoSQLite(database: ExpoSQLiteDatabase): LocalReplicaStorage;
package/dist/index.js CHANGED
@@ -457,6 +457,7 @@ export class ExpoSQLiteLocalReplicaStorage {
457
457
  });
458
458
  }
459
459
  }
460
+ export * from "./outbox.js";
460
461
  export function expoSQLite(database) {
461
462
  return new ExpoSQLiteLocalReplicaStorage(database);
462
463
  }
@@ -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.14",
3
+ "version": "0.5.2-staging.15",
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.14"
21
+ "@gonvex/client": "0.5.2-staging.15"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "^6.0.3",