@convex-localfirst/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/collection.d.ts +101 -0
  4. package/dist/collection.js +100 -0
  5. package/dist/declarative.d.ts +56 -0
  6. package/dist/declarative.js +86 -0
  7. package/dist/engine.d.ts +237 -0
  8. package/dist/engine.js +934 -0
  9. package/dist/functionName.d.ts +3 -0
  10. package/dist/functionName.js +15 -0
  11. package/dist/id.d.ts +5 -0
  12. package/dist/id.js +22 -0
  13. package/dist/index.d.ts +14 -0
  14. package/dist/index.js +27 -0
  15. package/dist/indexedDbStore.d.ts +53 -0
  16. package/dist/indexedDbStore.js +328 -0
  17. package/dist/internal.d.ts +12 -0
  18. package/dist/internal.js +22 -0
  19. package/dist/leadership.d.ts +48 -0
  20. package/dist/leadership.js +69 -0
  21. package/dist/manifest.d.ts +84 -0
  22. package/dist/manifest.js +28 -0
  23. package/dist/memoryStore.d.ts +33 -0
  24. package/dist/memoryStore.js +130 -0
  25. package/dist/multiTab.d.ts +69 -0
  26. package/dist/multiTab.js +96 -0
  27. package/dist/mutationCall.d.ts +20 -0
  28. package/dist/mutationCall.js +40 -0
  29. package/dist/ordering.d.ts +14 -0
  30. package/dist/ordering.js +35 -0
  31. package/dist/rebase.d.ts +14 -0
  32. package/dist/rebase.js +54 -0
  33. package/dist/relations.d.ts +42 -0
  34. package/dist/relations.js +89 -0
  35. package/dist/setMerge.d.ts +63 -0
  36. package/dist/setMerge.js +93 -0
  37. package/dist/status.d.ts +2 -0
  38. package/dist/status.js +10 -0
  39. package/dist/storage.d.ts +53 -0
  40. package/dist/storage.js +1 -0
  41. package/dist/transport.d.ts +43 -0
  42. package/dist/transport.js +93 -0
  43. package/dist/types.d.ts +173 -0
  44. package/dist/types.js +1 -0
  45. package/dist/view.d.ts +12 -0
  46. package/dist/view.js +74 -0
  47. package/package.json +42 -0
@@ -0,0 +1,3 @@
1
+ import type { FunctionName } from "./types.js";
2
+ export type FunctionNameResolver = (reference: unknown) => FunctionName;
3
+ export declare function defaultFunctionName(reference: unknown): FunctionName;
@@ -0,0 +1,15 @@
1
+ export function defaultFunctionName(reference) {
2
+ if (typeof reference === "string") {
3
+ return reference;
4
+ }
5
+ if (reference && typeof reference === "object") {
6
+ const record = reference;
7
+ const candidates = [record._name, record.name, record.functionName, record.path];
8
+ for (const candidate of candidates) {
9
+ if (typeof candidate === "string" && candidate.length > 0) {
10
+ return candidate;
11
+ }
12
+ }
13
+ }
14
+ throw new Error("Unable to resolve Convex function name. Inject the official getFunctionName resolver in the React adapter.");
15
+ }
package/dist/id.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { TableName } from "./types.js";
2
+ export type IdFactory = (table: TableName) => string;
3
+ export declare function createDefaultIdFactory(prefix?: string): IdFactory;
4
+ export declare function createClientId(): string;
5
+ export declare function createOpId(clientId: string): string;
package/dist/id.js ADDED
@@ -0,0 +1,22 @@
1
+ export function createDefaultIdFactory(prefix = "lf") {
2
+ let counter = 0;
3
+ return (table) => {
4
+ counter += 1;
5
+ const random = Math.random().toString(36).slice(2, 10);
6
+ return `${prefix}_${table}_${Date.now().toString(36)}_${counter.toString(36)}_${random}`;
7
+ };
8
+ }
9
+ export function createClientId() {
10
+ const random = Math.random().toString(36).slice(2, 14);
11
+ return `client_${Date.now().toString(36)}_${random}`;
12
+ }
13
+ let opCounter = 0;
14
+ export function createOpId(clientId) {
15
+ // Zero-padded monotonic counter so lexicographic opId order matches creation
16
+ // order — this is the deterministic tiebreak when two ops share a createdAt
17
+ // (Invariant I4). Time component keeps ids sortable across process restarts.
18
+ opCounter += 1;
19
+ const seq = opCounter.toString(36).padStart(8, "0");
20
+ const random = Math.random().toString(36).slice(2, 10);
21
+ return `op_${clientId}_${Date.now().toString(36)}_${seq}_${random}`;
22
+ }
@@ -0,0 +1,14 @@
1
+ export { createLocalFirstEngine } from "./engine.js";
2
+ export type { LocalFirstEngine, LocalFirstEngineOptions } from "./engine.js";
3
+ export * from "./collection.js";
4
+ export * from "./relations.js";
5
+ export { createClientId, type IdFactory } from "./id.js";
6
+ export { IndexedDbStore, type IndexedDbStoreOptions } from "./indexedDbStore.js";
7
+ export * from "./manifest.js";
8
+ export * from "./memoryStore.js";
9
+ export type { LocalFirstMutationCall } from "./mutationCall.js";
10
+ export type { FunctionNameResolver } from "./functionName.js";
11
+ export * from "./status.js";
12
+ export * from "./storage.js";
13
+ export * from "./transport.js";
14
+ export * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ // Public API surface (GOAL §6). Rebase/replay, the derived view, and other
2
+ // implementation internals are intentionally NOT re-exported here — they live in
3
+ // "./internal.js" ("@convex-localfirst/core/internal") for the React adapter only.
4
+ // Keeping them out keeps the public type surface free of internal vocabulary (I13)
5
+ // so internals can be rewritten without a semver break. publicApi.test.ts guards this.
6
+ //
7
+ // The engine is exposed as a HEADLESS factory only (createLocalFirstEngine) + its
8
+ // instance TYPE — the class constructor stays internal, so the construction path is
9
+ // one blessed function, but imperative/vanilla consumers (a service layer, a MobX
10
+ // store, a worker) can build and drive an engine without React. This is what lets a
11
+ // real app adopt the library without rewriting its components into hooks.
12
+ export { createLocalFirstEngine } from "./engine.js";
13
+ export * from "./collection.js";
14
+ export * from "./relations.js";
15
+ // id: only createClientId is a wiring helper (GOAL §6). createOpId/createDefaultIdFactory
16
+ // are engine internals → "./internal.js".
17
+ export { createClientId } from "./id.js";
18
+ // indexedDbStore: IndexedDbStore is the wiring helper (GOAL §6). openLocalFirstDb +
19
+ // the schema-version constant are internals → "./internal.js".
20
+ export { IndexedDbStore } from "./indexedDbStore.js";
21
+ export * from "./manifest.js";
22
+ export * from "./memoryStore.js";
23
+ // ordering (compareOperations) is an engine internal → "./internal.js".
24
+ export * from "./status.js";
25
+ export * from "./storage.js";
26
+ export * from "./transport.js";
27
+ export * from "./types.js";
@@ -0,0 +1,53 @@
1
+ import type { LocalStore, StoreListener, StoreUnsubscribe } from "./storage.js";
2
+ import type { Cursor, LocalId, LocalOperation, OperationStatus, RowValue, ScopeKey, ServerChange, TableName } from "./types.js";
3
+ export type IndexedDbStoreOptions = {
4
+ readonly databaseName: string;
5
+ /** Per-user/per-tenant suffix; switching it isolates data (Invariant I9). */
6
+ readonly namespace: string;
7
+ /** Called when an upgrade is blocked by another open connection. */
8
+ readonly onBlocked?: () => void;
9
+ };
10
+ export declare const INDEXED_DB_SCHEMA_VERSION = 3;
11
+ export declare function openLocalFirstDb(name: string, version: number, options?: {
12
+ onBlocked?: () => void;
13
+ }): Promise<IDBDatabase>;
14
+ /**
15
+ * IndexedDB-backed canonical store. Same model as MemoryLocalStore (the live
16
+ * view is derived via deriveView), persisted durably so pending operations and
17
+ * canonical rows survive reloads (Invariant I3).
18
+ */
19
+ export declare class IndexedDbStore implements LocalStore {
20
+ readonly options: IndexedDbStoreOptions;
21
+ private readonly listeners;
22
+ private readonly dbName;
23
+ private dbPromise;
24
+ /** Serializes canonical-mutating writes within this tab so a logout clear can't
25
+ * interleave with an in-flight apply and resurrect rows. Cross-tab safety comes from
26
+ * each apply being one atomic readwrite tx, not this lock. ponytail: promise-chain;
27
+ * errors swallowed so one failed write can't wedge the queue. */
28
+ private writeChain;
29
+ private epoch;
30
+ private sessionEnded;
31
+ constructor(options: IndexedDbStoreOptions);
32
+ private db;
33
+ /** Internal accessor for tests that need raw transactional access. */
34
+ _database(): Promise<IDBDatabase>;
35
+ getCanonicalRows(table: TableName): Promise<readonly RowValue[]>;
36
+ getRows(table: TableName): Promise<readonly RowValue[]>;
37
+ getRow(table: TableName, id: LocalId): Promise<RowValue | null>;
38
+ applyServerChange(change: ServerChange): Promise<void>;
39
+ applyServerChanges(changes: readonly ServerChange[]): Promise<void>;
40
+ private applyServerChangesAtomic;
41
+ enqueueOperation(operation: LocalOperation): Promise<void>;
42
+ getPendingOperations(): Promise<readonly LocalOperation[]>;
43
+ getAllOperations(): Promise<readonly LocalOperation[]>;
44
+ getOperation(opId: string): Promise<LocalOperation | null>;
45
+ updateOperationStatus(opId: string, status: OperationStatus, error?: string): Promise<void>;
46
+ dropOperation(opId: string): Promise<void>;
47
+ getCursor(scopeKey: ScopeKey): Promise<Cursor>;
48
+ setCursor(scopeKey: ScopeKey, cursor: string): Promise<void>;
49
+ clear(): Promise<void>;
50
+ private clearAtomic;
51
+ subscribe(listener: StoreListener): StoreUnsubscribe;
52
+ notify(): void;
53
+ }
@@ -0,0 +1,328 @@
1
+ import { compareOperations } from "./ordering.js";
2
+ import { deriveView, nextCanonicalRow } from "./view.js";
3
+ export const INDEXED_DB_SCHEMA_VERSION = 3;
4
+ const CANONICAL = "canonical";
5
+ const OPERATIONS = "operations";
6
+ const CURSORS = "cursors";
7
+ const META = "meta";
8
+ const BY_TABLE = "by_table";
9
+ const EPOCH_KEY = "epoch";
10
+ const OWED_STATUSES = new Set(["pending", "pushing"]);
11
+ function request(req) {
12
+ return new Promise((resolve, reject) => {
13
+ req.onsuccess = () => resolve(req.result);
14
+ req.onerror = () => reject(req.error);
15
+ });
16
+ }
17
+ function transactionDone(tx) {
18
+ return new Promise((resolve, reject) => {
19
+ tx.oncomplete = () => resolve();
20
+ tx.onerror = () => reject(tx.error);
21
+ tx.onabort = () => reject(tx.error ?? new Error("Transaction aborted"));
22
+ });
23
+ }
24
+ /** Schema migrations. Each block runs when upgrading past that version. */
25
+ function upgrade(db, oldVersion, tx) {
26
+ if (oldVersion < 1) {
27
+ db.createObjectStore(CANONICAL, { keyPath: ["_table", "_id"] });
28
+ db.createObjectStore(OPERATIONS, { keyPath: "opId" });
29
+ db.createObjectStore(CURSORS, { keyPath: "scopeKey" });
30
+ }
31
+ if (oldVersion < 2) {
32
+ const canonical = tx.objectStore(CANONICAL);
33
+ if (!canonical.indexNames.contains(BY_TABLE)) {
34
+ canonical.createIndex(BY_TABLE, "_table", { unique: false });
35
+ }
36
+ }
37
+ if (oldVersion < 3) {
38
+ // Durable logout epoch (I9). NOT wiped by clear(); clear() bumps it so a
39
+ // concurrent apply in another tab sees the advance and aborts (no resurrection).
40
+ // Guarded like the by_table index: opening AT an intermediate version runs every
41
+ // `oldVersion < N` block, so a later open must not recreate an existing store.
42
+ if (!db.objectStoreNames.contains(META)) {
43
+ db.createObjectStore(META, { keyPath: "key" });
44
+ }
45
+ }
46
+ }
47
+ export function openLocalFirstDb(name, version, options = {}) {
48
+ return new Promise((resolve, reject) => {
49
+ const open = indexedDB.open(name, version);
50
+ open.onupgradeneeded = (event) => {
51
+ upgrade(open.result, event.oldVersion, open.transaction);
52
+ };
53
+ open.onblocked = () => options.onBlocked?.();
54
+ open.onsuccess = () => resolve(open.result);
55
+ open.onerror = () => reject(open.error);
56
+ });
57
+ }
58
+ /**
59
+ * IndexedDB-backed canonical store. Same model as MemoryLocalStore (the live
60
+ * view is derived via deriveView), persisted durably so pending operations and
61
+ * canonical rows survive reloads (Invariant I3).
62
+ */
63
+ export class IndexedDbStore {
64
+ options;
65
+ listeners = new Set();
66
+ dbName;
67
+ dbPromise = null;
68
+ /** Serializes canonical-mutating writes within this tab so a logout clear can't
69
+ * interleave with an in-flight apply and resurrect rows. Cross-tab safety comes from
70
+ * each apply being one atomic readwrite tx, not this lock. ponytail: promise-chain;
71
+ * errors swallowed so one failed write can't wedge the queue. */
72
+ writeChain = Promise.resolve();
73
+ // Logout epoch this instance opened under. clear() bumps the durable epoch; an apply
74
+ // whose captured epoch no longer matches means a clear happened → the session is over.
75
+ epoch = 0;
76
+ // Set once a clear/foreign-clear is seen: further applies become no-ops (else they'd
77
+ // resurrect cleared data). Per-instance, not a permanent lock — a new login opens a
78
+ // fresh store (the provider keys it on userId).
79
+ sessionEnded = false;
80
+ constructor(options) {
81
+ this.options = options;
82
+ this.dbName = `${options.databaseName}:${options.namespace}`;
83
+ }
84
+ db() {
85
+ if (!this.dbPromise) {
86
+ this.dbPromise = openLocalFirstDb(this.dbName, INDEXED_DB_SCHEMA_VERSION, {
87
+ onBlocked: this.options.onBlocked
88
+ }).then(async (db) => {
89
+ // If another tab needs to upgrade, get out of the way and reopen lazily.
90
+ db.onversionchange = () => {
91
+ db.close();
92
+ this.dbPromise = null;
93
+ };
94
+ // Seed the epoch this instance opened under, so a later clear() (here or in
95
+ // another tab) is detectable as an advance against this captured value.
96
+ try {
97
+ const row = (await request(db.transaction(META, "readonly").objectStore(META).get(EPOCH_KEY)));
98
+ this.epoch = row?.value ?? 0;
99
+ }
100
+ catch {
101
+ this.epoch = 0;
102
+ }
103
+ return db;
104
+ });
105
+ }
106
+ return this.dbPromise;
107
+ }
108
+ /** Internal accessor for tests that need raw transactional access. */
109
+ async _database() {
110
+ return this.db();
111
+ }
112
+ async getCanonicalRows(table) {
113
+ const db = await this.db();
114
+ const tx = db.transaction(CANONICAL, "readonly");
115
+ const index = tx.objectStore(CANONICAL).index(BY_TABLE);
116
+ return (await request(index.getAll(table)));
117
+ }
118
+ async getRows(table) {
119
+ const db = await this.db();
120
+ const tx = db.transaction([CANONICAL, OPERATIONS], "readonly");
121
+ const canonical = (await request(tx.objectStore(CANONICAL).index(BY_TABLE).getAll(table)));
122
+ const operations = (await request(tx.objectStore(OPERATIONS).getAll()));
123
+ return deriveView(table, canonical, operations);
124
+ }
125
+ async getRow(table, id) {
126
+ const rows = await this.getRows(table);
127
+ return rows.find((row) => row._id === id) ?? null;
128
+ }
129
+ async applyServerChange(change) {
130
+ await this.applyServerChanges([change]);
131
+ }
132
+ async applyServerChanges(changes) {
133
+ if (changes.length === 0 || this.sessionEnded) {
134
+ return;
135
+ }
136
+ // Queue behind any in-flight canonical write IN THIS TAB (so clear() cannot
137
+ // interleave). Cross-tab safety is the atomic tx inside applyServerChangesAtomic.
138
+ const run = this.writeChain.then(() => this.applyServerChangesAtomic(changes));
139
+ this.writeChain = run.then(() => undefined, () => undefined);
140
+ return run;
141
+ }
142
+ async applyServerChangesAtomic(changes) {
143
+ const db = await this.db();
144
+ // NUL separator: table names and localIds cannot contain it, so distinct
145
+ // (table, id) pairs never collide in the grouping map below.
146
+ const keyOf = (table, id) => `${table}\u0000${id}`;
147
+ // Group changes per row, preserving arrival order. A batch may carry several
148
+ // changes to the same row (e.g. insert then patch) that must fold in order.
149
+ const perRow = new Map();
150
+ const opIds = new Set();
151
+ for (const c of changes) {
152
+ const k = keyOf(c.table, c.id);
153
+ let entry = perRow.get(k);
154
+ if (!entry) {
155
+ entry = { table: c.table, id: c.id, changes: [] };
156
+ perRow.set(k, entry);
157
+ }
158
+ entry.changes.push(c);
159
+ if (c.opId)
160
+ opIds.add(c.opId);
161
+ }
162
+ // ONE readwrite tx: the per-row get -> version-compare -> put happens atomically
163
+ // via request callbacks. NEVER await between requests, which would let the tx
164
+ // auto-commit early. Because IndexedDB serializes overlapping readwrite
165
+ // transactions to the same store (INCLUDING across tabs/connections), a
166
+ // concurrent apply's get observes our committed put, so an older version cannot
167
+ // overwrite a newer one and the canonical row never regresses (I5). A delete
168
+ // folds to a `_deleted: true` tombstone row (deriveView hides it), so we only put.
169
+ // META is in the SAME tx as the canonical writes: read the durable epoch FIRST and
170
+ // only apply if it still matches the epoch we opened under. IndexedDB serializes
171
+ // overlapping readwrite txns to the same stores across tabs/connections, so a
172
+ // clear() that committed first (bumping the epoch) is observed here and we abort —
173
+ // an apply can never resurrect rows a concurrent logout just cleared (I9).
174
+ const tx = db.transaction([CANONICAL, OPERATIONS, META], "readwrite");
175
+ let applied = false;
176
+ const epochReq = tx.objectStore(META).get(EPOCH_KEY);
177
+ epochReq.onsuccess = () => {
178
+ const epoch = epochReq.result?.value ?? 0;
179
+ if (epoch !== this.epoch) {
180
+ // A clear() advanced the epoch since we opened: applying now would resurrect
181
+ // just-cleared (sensitive) rows. Abort — issue NO writes — and end this session.
182
+ this.epoch = epoch;
183
+ this.sessionEnded = true;
184
+ return;
185
+ }
186
+ applied = true;
187
+ const canonical = tx.objectStore(CANONICAL);
188
+ const ops = tx.objectStore(OPERATIONS);
189
+ for (const entry of perRow.values()) {
190
+ const getReq = canonical.get([entry.table, entry.id]);
191
+ getReq.onsuccess = () => {
192
+ let row = getReq.result ?? null;
193
+ for (const change of entry.changes) {
194
+ const next = nextCanonicalRow(row, change);
195
+ if (next !== "stale") {
196
+ row = next;
197
+ }
198
+ }
199
+ if (row) {
200
+ canonical.put(row);
201
+ }
202
+ };
203
+ }
204
+ for (const opId of opIds) {
205
+ ops.delete(opId);
206
+ }
207
+ };
208
+ await transactionDone(tx);
209
+ if (applied) {
210
+ this.notify();
211
+ }
212
+ }
213
+ async enqueueOperation(operation) {
214
+ const db = await this.db();
215
+ const tx = db.transaction(OPERATIONS, "readwrite");
216
+ const store = tx.objectStore(OPERATIONS);
217
+ const existing = await request(store.get(operation.opId));
218
+ if (!existing) {
219
+ store.put(operation);
220
+ }
221
+ await transactionDone(tx);
222
+ if (!existing) {
223
+ this.notify();
224
+ }
225
+ }
226
+ async getPendingOperations() {
227
+ const all = await this.getAllOperations();
228
+ return all.filter((operation) => OWED_STATUSES.has(operation.status));
229
+ }
230
+ async getAllOperations() {
231
+ const db = await this.db();
232
+ const tx = db.transaction(OPERATIONS, "readonly");
233
+ const all = (await request(tx.objectStore(OPERATIONS).getAll()));
234
+ return all.sort(compareOperations);
235
+ }
236
+ async getOperation(opId) {
237
+ const db = await this.db();
238
+ const tx = db.transaction(OPERATIONS, "readonly");
239
+ return (await request(tx.objectStore(OPERATIONS).get(opId))) ?? null;
240
+ }
241
+ async updateOperationStatus(opId, status, error) {
242
+ const db = await this.db();
243
+ const tx = db.transaction(OPERATIONS, "readwrite");
244
+ const store = tx.objectStore(OPERATIONS);
245
+ const current = (await request(store.get(opId)));
246
+ if (current) {
247
+ store.put({ ...current, status, error });
248
+ }
249
+ await transactionDone(tx);
250
+ if (current) {
251
+ this.notify();
252
+ }
253
+ }
254
+ async dropOperation(opId) {
255
+ const db = await this.db();
256
+ const tx = db.transaction(OPERATIONS, "readwrite");
257
+ const store = tx.objectStore(OPERATIONS);
258
+ const current = (await request(store.get(opId)));
259
+ if (current) {
260
+ store.delete(opId);
261
+ }
262
+ await transactionDone(tx);
263
+ if (current) {
264
+ this.notify();
265
+ }
266
+ }
267
+ async getCursor(scopeKey) {
268
+ const db = await this.db();
269
+ const tx = db.transaction(CURSORS, "readonly");
270
+ const row = (await request(tx.objectStore(CURSORS).get(scopeKey)));
271
+ return row?.cursor ?? null;
272
+ }
273
+ async setCursor(scopeKey, cursor) {
274
+ const db = await this.db();
275
+ const tx = db.transaction(CURSORS, "readwrite");
276
+ const store = tx.objectStore(CURSORS);
277
+ // Monotonic (I5): read-compare-write in ONE readwrite tx. IndexedDB serializes
278
+ // readwrite txns on the same store, so this is atomic across concurrent pulls;
279
+ // the put is issued inside the get's onsuccess so the tx can't auto-commit
280
+ // between them. A write that would move the cursor backward is dropped.
281
+ const getReq = store.get(scopeKey);
282
+ getReq.onsuccess = () => {
283
+ const existing = getReq.result;
284
+ if (!existing || cursor > existing.cursor) {
285
+ store.put({ scopeKey, cursor });
286
+ }
287
+ };
288
+ await transactionDone(tx);
289
+ }
290
+ async clear() {
291
+ // Serialize through writeChain so a logout clear cannot land BETWEEN an
292
+ // in-flight apply's read and write and resurrect the just-cleared rows.
293
+ const run = this.writeChain.then(() => this.clearAtomic());
294
+ this.writeChain = run.then(() => undefined, () => undefined);
295
+ return run;
296
+ }
297
+ async clearAtomic() {
298
+ const db = await this.db();
299
+ const tx = db.transaction([CANONICAL, OPERATIONS, CURSORS, META], "readwrite");
300
+ tx.objectStore(CANONICAL).clear();
301
+ tx.objectStore(OPERATIONS).clear();
302
+ tx.objectStore(CURSORS).clear();
303
+ // Bump (NOT clear) the durable epoch in the SAME tx so a concurrent apply — in this
304
+ // tab or another, which reads META in its own serialized tx — sees the advance and
305
+ // aborts instead of resurrecting the just-cleared rows.
306
+ const meta = tx.objectStore(META);
307
+ const getReq = meta.get(EPOCH_KEY);
308
+ getReq.onsuccess = () => {
309
+ const next = (getReq.result?.value ?? 0) + 1;
310
+ meta.put({ key: EPOCH_KEY, value: next });
311
+ this.epoch = next;
312
+ };
313
+ await transactionDone(tx);
314
+ this.sessionEnded = true;
315
+ this.notify();
316
+ }
317
+ subscribe(listener) {
318
+ this.listeners.add(listener);
319
+ return () => {
320
+ this.listeners.delete(listener);
321
+ };
322
+ }
323
+ notify() {
324
+ for (const listener of Array.from(this.listeners)) {
325
+ listener();
326
+ }
327
+ }
328
+ }
@@ -0,0 +1,12 @@
1
+ export * from "./engine.js";
2
+ export * from "./rebase.js";
3
+ export * from "./view.js";
4
+ export * from "./declarative.js";
5
+ export * from "./leadership.js";
6
+ export * from "./multiTab.js";
7
+ export * from "./ordering.js";
8
+ export * from "./setMerge.js";
9
+ export { createOpId, createDefaultIdFactory } from "./id.js";
10
+ export { openLocalFirstDb, INDEXED_DB_SCHEMA_VERSION } from "./indexedDbStore.js";
11
+ export { createLocalFirstMutationCall, createFallbackMutationCall } from "./mutationCall.js";
12
+ export { defaultFunctionName } from "./functionName.js";
@@ -0,0 +1,22 @@
1
+ // Internal API surface — NOT part of the public package (GOAL §6 / I13).
2
+ //
3
+ // The engine, rebase/replay, the derived view, the manifest interpreters
4
+ // (declarative*, consumed only by codegen output), multi-tab leadership, and
5
+ // low-level call/name helpers are implementation details: app authors must never
6
+ // import them, so they are kept OUT of the package's public entry
7
+ // ("@convex-localfirst/core"). The React adapter and generated code — the only
8
+ // legitimate internal consumers — import them from "@convex-localfirst/core/internal".
9
+ // A type-surface guard test (publicApi.test.ts) asserts none of these leak into the
10
+ // public index.
11
+ export * from "./engine.js";
12
+ export * from "./rebase.js";
13
+ export * from "./view.js";
14
+ export * from "./declarative.js";
15
+ export * from "./leadership.js";
16
+ export * from "./multiTab.js";
17
+ export * from "./ordering.js";
18
+ export * from "./setMerge.js";
19
+ export { createOpId, createDefaultIdFactory } from "./id.js";
20
+ export { openLocalFirstDb, INDEXED_DB_SCHEMA_VERSION } from "./indexedDbStore.js";
21
+ export { createLocalFirstMutationCall, createFallbackMutationCall } from "./mutationCall.js";
22
+ export { defaultFunctionName } from "./functionName.js";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Multi-tab sync leadership. Only the leader tab runs background push/pull so a
3
+ * mutation is not pushed N times from N open tabs. Uses the Web Locks API — the robust
4
+ * browser primitive whose lock auto-releases when a tab crashes or closes, so failover
5
+ * needs no heartbeat or hand-rolled election.
6
+ *
7
+ * The React provider only engages multi-tab when Web Locks is present, and every browser
8
+ * that ships IndexedDB (which this library requires for persistence) also ships Web Locks.
9
+ * Without Web Locks a tab simply leads itself and syncs; concurrent tabs would then each
10
+ * push, but the server ledger dedupes by opId, so the result is correct (just less
11
+ * efficient). That keeps this the single, crash-safe coordination path — no second
12
+ * election protocol to keep correct.
13
+ */
14
+ export type LeadershipOptions = {
15
+ /** Lock name; tabs sharing it elect a single leader (whoever holds the exclusive lock). */
16
+ readonly name: string;
17
+ /** Stable id for this tab (the engine's clientId). */
18
+ readonly id: string;
19
+ readonly onChange?: (isLeader: boolean) => void;
20
+ /**
21
+ * Injectable Web Locks manager. `undefined` → use navigator.locks if present;
22
+ * `null` → no Web Locks, so this tab leads itself (used by tests / non-browser).
23
+ */
24
+ readonly locks?: LockManagerLike | null;
25
+ };
26
+ /** The slice of the Web Locks API we use (navigator.locks). */
27
+ export type LockManagerLike = {
28
+ request(name: string, options: {
29
+ mode?: "exclusive" | "shared";
30
+ signal?: AbortSignal;
31
+ }, callback: () => Promise<unknown>): Promise<unknown>;
32
+ };
33
+ export declare class TabLeadership {
34
+ private readonly options;
35
+ private leader;
36
+ private stopped;
37
+ private abort;
38
+ private releaseLock;
39
+ constructor(options: LeadershipOptions);
40
+ isLeader(): boolean;
41
+ /** Begin acquisition. Resolves once the request is issued (Web Locks) or immediately
42
+ * (no Web Locks → this tab self-leads). Leadership changes thereafter via onChange. */
43
+ start(): Promise<void>;
44
+ stop(): void;
45
+ private resolveLocks;
46
+ private startWithLocks;
47
+ private setLeader;
48
+ }
@@ -0,0 +1,69 @@
1
+ export class TabLeadership {
2
+ options;
3
+ leader = false;
4
+ stopped = false;
5
+ abort = null;
6
+ releaseLock = null;
7
+ constructor(options) {
8
+ this.options = options;
9
+ }
10
+ isLeader() {
11
+ return this.leader;
12
+ }
13
+ /** Begin acquisition. Resolves once the request is issued (Web Locks) or immediately
14
+ * (no Web Locks → this tab self-leads). Leadership changes thereafter via onChange. */
15
+ start() {
16
+ const locks = this.resolveLocks();
17
+ if (locks) {
18
+ return this.startWithLocks(locks);
19
+ }
20
+ // No Web Locks: this class can't coordinate across tabs (the React provider only engages
21
+ // multi-tab when Web Locks is present). A direct caller without locks gets sole-tab
22
+ // behavior — lead immediately so its engine runs sync rather than staying gated.
23
+ this.setLeader(true);
24
+ return Promise.resolve();
25
+ }
26
+ stop() {
27
+ this.stopped = true;
28
+ // Releasing the held promise frees the lock so a waiting tab leads; aborting cancels a
29
+ // pending (follower) request. The browser also auto-releases if this tab crashes.
30
+ this.releaseLock?.();
31
+ this.releaseLock = null;
32
+ this.abort?.abort();
33
+ this.setLeader(false);
34
+ }
35
+ resolveLocks() {
36
+ if (this.options.locks !== undefined) {
37
+ return this.options.locks;
38
+ }
39
+ const nav = globalThis.navigator;
40
+ return nav?.locks ?? null;
41
+ }
42
+ startWithLocks(locks) {
43
+ this.abort = new AbortController();
44
+ // Holding an exclusive lock named `name` == being the leader. The callback runs only
45
+ // once the lock is granted; until then this tab is a follower. The browser frees the
46
+ // lock if this tab dies, so a dead leader fails over automatically.
47
+ void locks
48
+ .request(this.options.name, { mode: "exclusive", signal: this.abort.signal }, () => {
49
+ if (this.stopped) {
50
+ return Promise.resolve();
51
+ }
52
+ this.setLeader(true);
53
+ return new Promise((release) => {
54
+ this.releaseLock = release;
55
+ });
56
+ })
57
+ .catch(() => {
58
+ // AbortError (stopped before acquiring) or a lock failure: stay a follower.
59
+ });
60
+ // "Settled" = request issued; leadership arrives via onChange when the lock is held.
61
+ return Promise.resolve();
62
+ }
63
+ setLeader(value) {
64
+ if (this.leader !== value) {
65
+ this.leader = value;
66
+ this.options.onChange?.(value);
67
+ }
68
+ }
69
+ }