@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/collection.d.ts +101 -0
- package/dist/collection.js +100 -0
- package/dist/declarative.d.ts +56 -0
- package/dist/declarative.js +86 -0
- package/dist/engine.d.ts +237 -0
- package/dist/engine.js +934 -0
- package/dist/functionName.d.ts +3 -0
- package/dist/functionName.js +15 -0
- package/dist/id.d.ts +5 -0
- package/dist/id.js +22 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +27 -0
- package/dist/indexedDbStore.d.ts +53 -0
- package/dist/indexedDbStore.js +328 -0
- package/dist/internal.d.ts +12 -0
- package/dist/internal.js +22 -0
- package/dist/leadership.d.ts +48 -0
- package/dist/leadership.js +69 -0
- package/dist/manifest.d.ts +84 -0
- package/dist/manifest.js +28 -0
- package/dist/memoryStore.d.ts +33 -0
- package/dist/memoryStore.js +130 -0
- package/dist/multiTab.d.ts +69 -0
- package/dist/multiTab.js +96 -0
- package/dist/mutationCall.d.ts +20 -0
- package/dist/mutationCall.js +40 -0
- package/dist/ordering.d.ts +14 -0
- package/dist/ordering.js +35 -0
- package/dist/rebase.d.ts +14 -0
- package/dist/rebase.js +54 -0
- package/dist/relations.d.ts +42 -0
- package/dist/relations.js +89 -0
- package/dist/setMerge.d.ts +63 -0
- package/dist/setMerge.js +93 -0
- package/dist/status.d.ts +2 -0
- package/dist/status.js +10 -0
- package/dist/storage.d.ts +53 -0
- package/dist/storage.js +1 -0
- package/dist/transport.d.ts +43 -0
- package/dist/transport.js +93 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.js +1 -0
- package/dist/view.d.ts +12 -0
- package/dist/view.js +74 -0
- package/package.json +42 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { FunctionName, OperationPlan, RowValue, SyncScope, TableName } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Per-table conflict policy. Only REAL, server-enforced policies live here — declaring one
|
|
4
|
+
* that silently no-ops would be a footgun, so unimplemented options are not offered (they'd
|
|
5
|
+
* be a compile error, not a silent surprise).
|
|
6
|
+
*
|
|
7
|
+
* - `fieldLww` (default): field-level last-writer-wins. Patches are field-scoped deltas, so
|
|
8
|
+
* concurrent edits to *different* fields both survive; same-field collisions resolve by
|
|
9
|
+
* arrival order at the server. Free — falls out of `ctx.db.patch` + client replay.
|
|
10
|
+
* - `timestampLww`: same field-level merge, but same-field collisions resolve by the op's
|
|
11
|
+
* logical timestamp (+ clientId tiebreaker) instead of arrival order — a NEWER edit wins
|
|
12
|
+
* regardless of arrival, backed by per-field write clocks on the server. The offline-first fix.
|
|
13
|
+
*
|
|
14
|
+
* Orthogonal convergent merges are declared per FIELD, not as a whole-row policy:
|
|
15
|
+
* `setFields` (array add/remove) and `counterFields` (numeric increments). They compose with
|
|
16
|
+
* either policy above (delta fields are exempt from the LWW rule — they never clobber).
|
|
17
|
+
*/
|
|
18
|
+
export type ConflictPolicyName = "fieldLww" | "timestampLww";
|
|
19
|
+
export type ScopeDefinition = {
|
|
20
|
+
readonly kind: "byUser";
|
|
21
|
+
readonly field: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: "byWorkspace";
|
|
24
|
+
readonly workspaceIdField: string;
|
|
25
|
+
readonly membershipTable: string;
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "byProject";
|
|
28
|
+
readonly projectIdField: string;
|
|
29
|
+
readonly membershipTable: string;
|
|
30
|
+
};
|
|
31
|
+
export type LocalTableDefinition = {
|
|
32
|
+
readonly table: TableName;
|
|
33
|
+
readonly idField: string;
|
|
34
|
+
readonly scope: ScopeDefinition;
|
|
35
|
+
readonly conflict: ConflictPolicyName;
|
|
36
|
+
readonly indexes: Record<string, readonly string[]>;
|
|
37
|
+
readonly setFields?: readonly string[];
|
|
38
|
+
readonly counterFields?: readonly string[];
|
|
39
|
+
};
|
|
40
|
+
export type LocalQueryContext = {
|
|
41
|
+
readonly now: number;
|
|
42
|
+
};
|
|
43
|
+
export type LocalQueryDefinition<TArgs = unknown, TResult = unknown> = {
|
|
44
|
+
readonly kind: "query";
|
|
45
|
+
readonly name: FunctionName;
|
|
46
|
+
readonly table: TableName;
|
|
47
|
+
readonly initial?: TResult;
|
|
48
|
+
readonly scope?: (args: TArgs) => SyncScope;
|
|
49
|
+
readonly run: (rows: readonly RowValue[], args: TArgs, context: LocalQueryContext) => TResult;
|
|
50
|
+
};
|
|
51
|
+
export type LocalMutationContext = {
|
|
52
|
+
readonly now: number;
|
|
53
|
+
readonly clientId: string;
|
|
54
|
+
readonly userId: string | null;
|
|
55
|
+
readonly localId: (table: TableName) => string;
|
|
56
|
+
};
|
|
57
|
+
export type LocalMutationDefinition<TArgs = unknown, TResult = unknown> = {
|
|
58
|
+
readonly kind: "mutation";
|
|
59
|
+
readonly name: FunctionName;
|
|
60
|
+
readonly table: TableName;
|
|
61
|
+
readonly serverResult?: TResult;
|
|
62
|
+
readonly plan: (args: TArgs, context: LocalMutationContext) => OperationPlan;
|
|
63
|
+
};
|
|
64
|
+
export type LocalFirstManifest = {
|
|
65
|
+
readonly schemaVersion: number;
|
|
66
|
+
readonly tables: Record<TableName, LocalTableDefinition>;
|
|
67
|
+
readonly queries: Record<FunctionName, LocalQueryDefinition<any, any>>;
|
|
68
|
+
readonly mutations: Record<FunctionName, LocalMutationDefinition<any, any>>;
|
|
69
|
+
};
|
|
70
|
+
export declare function defineLocalFirstManifest<T extends LocalFirstManifest>(manifest: T): T;
|
|
71
|
+
export declare function localTable(definition: LocalTableDefinition): LocalTableDefinition;
|
|
72
|
+
export declare function localQuery<TArgs, TResult>(definition: LocalQueryDefinition<TArgs, TResult>): LocalQueryDefinition<TArgs, TResult>;
|
|
73
|
+
export declare function localMutation<TArgs, TResult = unknown>(definition: LocalMutationDefinition<TArgs, TResult>): LocalMutationDefinition<TArgs, TResult>;
|
|
74
|
+
export declare function byUser(field: string): ScopeDefinition;
|
|
75
|
+
export declare function byWorkspace(input: {
|
|
76
|
+
workspaceIdField: string;
|
|
77
|
+
membershipTable: string;
|
|
78
|
+
}): ScopeDefinition;
|
|
79
|
+
export declare function byProject(input: {
|
|
80
|
+
projectIdField: string;
|
|
81
|
+
membershipTable: string;
|
|
82
|
+
}): ScopeDefinition;
|
|
83
|
+
export declare function fieldLww(): ConflictPolicyName;
|
|
84
|
+
export declare function timestampLww(): ConflictPolicyName;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function defineLocalFirstManifest(manifest) {
|
|
2
|
+
return manifest;
|
|
3
|
+
}
|
|
4
|
+
export function localTable(definition) {
|
|
5
|
+
return definition;
|
|
6
|
+
}
|
|
7
|
+
export function localQuery(definition) {
|
|
8
|
+
return definition;
|
|
9
|
+
}
|
|
10
|
+
export function localMutation(definition) {
|
|
11
|
+
return definition;
|
|
12
|
+
}
|
|
13
|
+
export function byUser(field) {
|
|
14
|
+
return { kind: "byUser", field };
|
|
15
|
+
}
|
|
16
|
+
export function byWorkspace(input) {
|
|
17
|
+
return { kind: "byWorkspace", ...input };
|
|
18
|
+
}
|
|
19
|
+
export function byProject(input) {
|
|
20
|
+
return { kind: "byProject", ...input };
|
|
21
|
+
}
|
|
22
|
+
// These helpers tag a table with a conflict policy — see ConflictPolicyName for what each does.
|
|
23
|
+
export function fieldLww() {
|
|
24
|
+
return "fieldLww";
|
|
25
|
+
}
|
|
26
|
+
export function timestampLww() {
|
|
27
|
+
return "timestampLww";
|
|
28
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { LocalStore, StoreListener, StoreUnsubscribe } from "./storage.js";
|
|
2
|
+
import type { Cursor, LocalId, LocalOperation, OperationStatus, RowValue, ScopeKey, ServerChange, TableName } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* In-memory implementation of the canonical-centric store. The live view is
|
|
5
|
+
* never stored; it is derived on every read from the canonical snapshot plus a
|
|
6
|
+
* deterministic replay of the pending operation log (see Invariant I1).
|
|
7
|
+
*/
|
|
8
|
+
export declare class MemoryLocalStore implements LocalStore {
|
|
9
|
+
private readonly canonical;
|
|
10
|
+
private readonly operations;
|
|
11
|
+
private readonly cursors;
|
|
12
|
+
private readonly listeners;
|
|
13
|
+
getRows(table: TableName): Promise<readonly RowValue[]>;
|
|
14
|
+
getRow(table: TableName, id: LocalId): Promise<RowValue | null>;
|
|
15
|
+
getCanonicalRows(table: TableName): Promise<readonly RowValue[]>;
|
|
16
|
+
applyServerChange(change: ServerChange): Promise<void>;
|
|
17
|
+
applyServerChanges(changes: readonly ServerChange[]): Promise<void>;
|
|
18
|
+
private applyOne;
|
|
19
|
+
enqueueOperation(operation: LocalOperation): Promise<void>;
|
|
20
|
+
getPendingOperations(): Promise<readonly LocalOperation[]>;
|
|
21
|
+
getAllOperations(): Promise<readonly LocalOperation[]>;
|
|
22
|
+
getOperation(opId: string): Promise<LocalOperation | null>;
|
|
23
|
+
updateOperationStatus(opId: string, status: OperationStatus, error?: string): Promise<void>;
|
|
24
|
+
dropOperation(opId: string): Promise<void>;
|
|
25
|
+
getCursor(scopeKey: ScopeKey): Promise<Cursor>;
|
|
26
|
+
setCursor(scopeKey: ScopeKey, cursor: string): Promise<void>;
|
|
27
|
+
clear(): Promise<void>;
|
|
28
|
+
subscribe(listener: StoreListener): StoreUnsubscribe;
|
|
29
|
+
notify(): void;
|
|
30
|
+
/** Derive the live view for one table: canonical + deterministic replay of active ops. */
|
|
31
|
+
private deriveTable;
|
|
32
|
+
private tableMap;
|
|
33
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { compareOperations } from "./ordering.js";
|
|
2
|
+
import { deriveView, nextCanonicalRow } from "./view.js";
|
|
3
|
+
function clone(value) {
|
|
4
|
+
// structuredClone (Node 18+/browsers) preserves the full Convex value range —
|
|
5
|
+
// int64 (bigint), bytes (ArrayBuffer), and `undefined` properties — which a
|
|
6
|
+
// JSON round-trip throws on or silently drops.
|
|
7
|
+
return structuredClone(value);
|
|
8
|
+
}
|
|
9
|
+
const OWED_STATUSES = new Set(["pending", "pushing"]);
|
|
10
|
+
/**
|
|
11
|
+
* In-memory implementation of the canonical-centric store. The live view is
|
|
12
|
+
* never stored; it is derived on every read from the canonical snapshot plus a
|
|
13
|
+
* deterministic replay of the pending operation log (see Invariant I1).
|
|
14
|
+
*/
|
|
15
|
+
export class MemoryLocalStore {
|
|
16
|
+
canonical = new Map();
|
|
17
|
+
operations = new Map();
|
|
18
|
+
cursors = new Map();
|
|
19
|
+
listeners = new Set();
|
|
20
|
+
async getRows(table) {
|
|
21
|
+
return this.deriveTable(table).map(clone);
|
|
22
|
+
}
|
|
23
|
+
async getRow(table, id) {
|
|
24
|
+
const row = this.deriveTable(table).find((candidate) => candidate._id === id);
|
|
25
|
+
return row ? clone(row) : null;
|
|
26
|
+
}
|
|
27
|
+
async getCanonicalRows(table) {
|
|
28
|
+
return Array.from(this.tableMap(table).values()).map(clone);
|
|
29
|
+
}
|
|
30
|
+
async applyServerChange(change) {
|
|
31
|
+
this.applyOne(change);
|
|
32
|
+
this.notify();
|
|
33
|
+
}
|
|
34
|
+
async applyServerChanges(changes) {
|
|
35
|
+
if (changes.length === 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for (const change of changes) {
|
|
39
|
+
this.applyOne(change);
|
|
40
|
+
}
|
|
41
|
+
this.notify(); // one notify for the whole batch — see LocalStore.applyServerChanges
|
|
42
|
+
}
|
|
43
|
+
applyOne(change) {
|
|
44
|
+
const table = this.tableMap(change.table);
|
|
45
|
+
const next = nextCanonicalRow(table.get(change.id) ?? null, change);
|
|
46
|
+
if (next !== "stale") {
|
|
47
|
+
table.set(change.id, next);
|
|
48
|
+
}
|
|
49
|
+
// The op that produced this change is now part of canonical: stop replaying it.
|
|
50
|
+
if (change.opId) {
|
|
51
|
+
this.operations.delete(change.opId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async enqueueOperation(operation) {
|
|
55
|
+
if (!this.operations.has(operation.opId)) {
|
|
56
|
+
this.operations.set(operation.opId, clone(operation));
|
|
57
|
+
this.notify();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async getPendingOperations() {
|
|
61
|
+
return Array.from(this.operations.values())
|
|
62
|
+
.filter((operation) => OWED_STATUSES.has(operation.status))
|
|
63
|
+
.sort(compareOperations)
|
|
64
|
+
.map(clone);
|
|
65
|
+
}
|
|
66
|
+
async getAllOperations() {
|
|
67
|
+
return Array.from(this.operations.values()).sort(compareOperations).map(clone);
|
|
68
|
+
}
|
|
69
|
+
async getOperation(opId) {
|
|
70
|
+
const operation = this.operations.get(opId);
|
|
71
|
+
return operation ? clone(operation) : null;
|
|
72
|
+
}
|
|
73
|
+
async updateOperationStatus(opId, status, error) {
|
|
74
|
+
const current = this.operations.get(opId);
|
|
75
|
+
if (!current) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.operations.set(opId, { ...current, status, error });
|
|
79
|
+
this.notify();
|
|
80
|
+
}
|
|
81
|
+
async dropOperation(opId) {
|
|
82
|
+
if (this.operations.delete(opId)) {
|
|
83
|
+
this.notify();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async getCursor(scopeKey) {
|
|
87
|
+
return this.cursors.get(scopeKey) ?? null;
|
|
88
|
+
}
|
|
89
|
+
async setCursor(scopeKey, cursor) {
|
|
90
|
+
// Monotonic (I5): cursors only advance. Concurrent same-scope pulls (multiple
|
|
91
|
+
// mounted hooks + the reactive watch) can resolve out of order; a write that
|
|
92
|
+
// would move the cursor backward is ignored, since it would cause redundant
|
|
93
|
+
// re-delivery and destabilize the reactive resubscribe window.
|
|
94
|
+
const current = this.cursors.get(scopeKey);
|
|
95
|
+
if (current !== undefined && cursor <= current) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.cursors.set(scopeKey, cursor);
|
|
99
|
+
}
|
|
100
|
+
async clear() {
|
|
101
|
+
this.canonical.clear();
|
|
102
|
+
this.operations.clear();
|
|
103
|
+
this.cursors.clear();
|
|
104
|
+
this.notify();
|
|
105
|
+
}
|
|
106
|
+
subscribe(listener) {
|
|
107
|
+
this.listeners.add(listener);
|
|
108
|
+
return () => {
|
|
109
|
+
this.listeners.delete(listener);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
notify() {
|
|
113
|
+
for (const listener of Array.from(this.listeners)) {
|
|
114
|
+
listener();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Derive the live view for one table: canonical + deterministic replay of active ops. */
|
|
118
|
+
deriveTable(table) {
|
|
119
|
+
return deriveView(table, Array.from(this.tableMap(table).values()), Array.from(this.operations.values()));
|
|
120
|
+
}
|
|
121
|
+
tableMap(table) {
|
|
122
|
+
const current = this.canonical.get(table);
|
|
123
|
+
if (current) {
|
|
124
|
+
return current;
|
|
125
|
+
}
|
|
126
|
+
const next = new Map();
|
|
127
|
+
this.canonical.set(table, next);
|
|
128
|
+
return next;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type LockManagerLike } from "./leadership.js";
|
|
2
|
+
import type { LocalStore } from "./storage.js";
|
|
3
|
+
/** The slice of BroadcastChannel the cross-tab poke channel uses. */
|
|
4
|
+
export type BroadcastChannelLike = {
|
|
5
|
+
postMessage(message: unknown): void;
|
|
6
|
+
close(): void;
|
|
7
|
+
onmessage: ((event: {
|
|
8
|
+
data: unknown;
|
|
9
|
+
}) => void) | null;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Multi-tab sync coordination. Two independent jobs over one BroadcastChannel:
|
|
13
|
+
*
|
|
14
|
+
* 1. Leadership gate — elect a single leader among the tabs sharing this user's
|
|
15
|
+
* local data; only the leader runs the engine's BACKGROUND batch push, so the
|
|
16
|
+
* shared outbox is pushed once, not N times. (Pull/watch stay per-tab so every
|
|
17
|
+
* tab still sees fresh data for its own scopes — no divergent-scope staleness.)
|
|
18
|
+
*
|
|
19
|
+
* 2. Cross-tab data poke — IndexedDB has no cross-tab change event, so whenever a
|
|
20
|
+
* tab applies changes to the shared DB it broadcasts "changed"; the others
|
|
21
|
+
* re-derive their mounted queries (pokeLocalChange).
|
|
22
|
+
*
|
|
23
|
+
* The leader does NOT re-push a follower's queued op on "changed": a follower pushes
|
|
24
|
+
* its OWN explicit mutations (pushSingleOperation is never gated) and flushes its own
|
|
25
|
+
* backlog on reconnect, so leader re-push would only DOUBLE-push an op the follower is
|
|
26
|
+
* already pushing — a concurrent same-opId race (idempotency TOCTOU + a call.server that
|
|
27
|
+
* the leader could prune out from under the follower). The leader's normal background
|
|
28
|
+
* push still drains anything genuinely stuck in the shared outbox.
|
|
29
|
+
*/
|
|
30
|
+
/** The slice of the engine the coordinator drives (keeps it unit-testable). */
|
|
31
|
+
export type MultiTabEngine = {
|
|
32
|
+
setSyncEnabled(enabled: boolean): void;
|
|
33
|
+
pokeLocalChange(): void;
|
|
34
|
+
subscribe(listener: () => void): () => void;
|
|
35
|
+
};
|
|
36
|
+
/** The slice of TabLeadership the coordinator drives (so tests can inject a fake). */
|
|
37
|
+
export type TabLeadershipLike = {
|
|
38
|
+
start(): Promise<void>;
|
|
39
|
+
stop(): void;
|
|
40
|
+
isLeader(): boolean;
|
|
41
|
+
};
|
|
42
|
+
export type MultiTabSyncOptions = {
|
|
43
|
+
/** Coordination boundary — tabs sharing the SAME local data (deployment + user) must
|
|
44
|
+
* share this name and no others. The provider derives it from the authed user. */
|
|
45
|
+
readonly name: string;
|
|
46
|
+
/** Stable id for this tab (the engine's clientId). */
|
|
47
|
+
readonly id: string;
|
|
48
|
+
/** Injected for tests; defaults to a real BroadcastChannel. */
|
|
49
|
+
readonly createChannel?: (name: string) => BroadcastChannelLike;
|
|
50
|
+
/** Injected for tests; defaults to a real TabLeadership over the same lock name. */
|
|
51
|
+
readonly createLeadership?: (onChange: (isLeader: boolean) => void) => TabLeadershipLike;
|
|
52
|
+
/** Passed through to the default TabLeadership (Web Locks manager; null → this tab self-leads). */
|
|
53
|
+
readonly locks?: LockManagerLike | null;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The multi-tab coordination key for a store. It MUST equal the shared-data boundary so
|
|
57
|
+
* tabs over the SAME data elect one leader, and tabs over DIFFERENT data (a second
|
|
58
|
+
* deployment on one origin, or a different user) never elect a shared leader that can't
|
|
59
|
+
* drain the other's outbox. An IndexedDbStore's (databaseName, namespace) IS that
|
|
60
|
+
* partition (Rule 7: namespaced by deployment + authenticated user). Non-IndexedDb
|
|
61
|
+
* stores are per-instance (not shared across tabs), so the user id is a safe fallback.
|
|
62
|
+
* JSON-encoded so a ':' inside any segment can't merge two distinct boundaries into one.
|
|
63
|
+
*/
|
|
64
|
+
export declare function coordinationName(store: LocalStore | undefined, userId: string | null): string;
|
|
65
|
+
/**
|
|
66
|
+
* Wire one engine into multi-tab coordination. Returns a dispose that tears down the
|
|
67
|
+
* channel + leadership and restores the engine to the un-gated (every-tab-sync) default.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createMultiTabSync(engine: MultiTabEngine, options: MultiTabSyncOptions): () => void;
|
package/dist/multiTab.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { IndexedDbStore } from "./indexedDbStore.js";
|
|
2
|
+
import { TabLeadership } from "./leadership.js";
|
|
3
|
+
/**
|
|
4
|
+
* The multi-tab coordination key for a store. It MUST equal the shared-data boundary so
|
|
5
|
+
* tabs over the SAME data elect one leader, and tabs over DIFFERENT data (a second
|
|
6
|
+
* deployment on one origin, or a different user) never elect a shared leader that can't
|
|
7
|
+
* drain the other's outbox. An IndexedDbStore's (databaseName, namespace) IS that
|
|
8
|
+
* partition (Rule 7: namespaced by deployment + authenticated user). Non-IndexedDb
|
|
9
|
+
* stores are per-instance (not shared across tabs), so the user id is a safe fallback.
|
|
10
|
+
* JSON-encoded so a ':' inside any segment can't merge two distinct boundaries into one.
|
|
11
|
+
*/
|
|
12
|
+
export function coordinationName(store, userId) {
|
|
13
|
+
if (store instanceof IndexedDbStore) {
|
|
14
|
+
return `idb:${JSON.stringify([store.options.databaseName, store.options.namespace])}`;
|
|
15
|
+
}
|
|
16
|
+
return `user:${JSON.stringify(userId ?? "anon")}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Wire one engine into multi-tab coordination. Returns a dispose that tears down the
|
|
20
|
+
* channel + leadership and restores the engine to the un-gated (every-tab-sync) default.
|
|
21
|
+
*/
|
|
22
|
+
export function createMultiTabSync(engine, options) {
|
|
23
|
+
const channelName = `convex-localfirst:multitab:${options.name}`;
|
|
24
|
+
const channelFactory = options.createChannel ?? ((name) => new BroadcastChannel(name));
|
|
25
|
+
const channel = channelFactory(channelName);
|
|
26
|
+
let stopped = false;
|
|
27
|
+
// Echo suppression: a received poke fires the store's listeners; without this flag
|
|
28
|
+
// that local notify would re-broadcast, ping-ponging "changed" between tabs forever.
|
|
29
|
+
let applyingPoke = false;
|
|
30
|
+
let broadcastQueued = false;
|
|
31
|
+
// 1) Leadership gates the background push.
|
|
32
|
+
const leadershipFactory = options.createLeadership ??
|
|
33
|
+
((onChange) => new TabLeadership({
|
|
34
|
+
name: channelName,
|
|
35
|
+
id: options.id,
|
|
36
|
+
locks: options.locks,
|
|
37
|
+
onChange
|
|
38
|
+
}));
|
|
39
|
+
const leadership = leadershipFactory((isLeader) => {
|
|
40
|
+
if (!stopped) {
|
|
41
|
+
engine.setSyncEnabled(isLeader);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
// Gate EVERY tab up front, then let leadership re-enable only the actual leader.
|
|
45
|
+
// TabLeadership fires onChange(true) when a tab BECOMES leader but never fires
|
|
46
|
+
// onChange(false) for a tab that merely starts as a follower (a queued Web Locks
|
|
47
|
+
// waiter that begins non-leader and only transitions if the holder releases).
|
|
48
|
+
// Without this, a follower keeps the engine's default syncEnabled=true and still runs
|
|
49
|
+
// the background batch push — defeating the single-leader invariant. setSyncEnabled is
|
|
50
|
+
// idempotent, and the leader's onChange(true) flips it back on.
|
|
51
|
+
engine.setSyncEnabled(false);
|
|
52
|
+
// 2) Cross-tab data poke. Broadcast on a genuine local change; on receipt every tab
|
|
53
|
+
// re-derives from the shared store (pokeLocalChange). The leader does NOT re-push
|
|
54
|
+
// here — see the header note (it would double-push an op the follower is pushing).
|
|
55
|
+
channel.onmessage = (event) => {
|
|
56
|
+
if (stopped) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const data = event.data;
|
|
60
|
+
if (data?.type !== "changed") {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
applyingPoke = true;
|
|
64
|
+
try {
|
|
65
|
+
engine.pokeLocalChange();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
applyingPoke = false;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const unsubscribe = engine.subscribe(() => {
|
|
72
|
+
if (stopped || applyingPoke || broadcastQueued) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Coalesce a burst of writes into ONE cross-tab poke per microtask.
|
|
76
|
+
broadcastQueued = true;
|
|
77
|
+
queueMicrotask(() => {
|
|
78
|
+
broadcastQueued = false;
|
|
79
|
+
if (!stopped) {
|
|
80
|
+
channel.postMessage({ type: "changed" });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
void leadership.start();
|
|
85
|
+
return () => {
|
|
86
|
+
if (stopped) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
stopped = true;
|
|
90
|
+
unsubscribe();
|
|
91
|
+
leadership.stop();
|
|
92
|
+
channel.close();
|
|
93
|
+
// A follower's engine was gated; restore the default so a remount/HMR isn't stuck off.
|
|
94
|
+
engine.setSyncEnabled(true);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LocalCommit, MutationStatus } from "./types.js";
|
|
2
|
+
export type LocalFirstMutationCall<T> = Promise<T> & {
|
|
3
|
+
readonly opId: string;
|
|
4
|
+
readonly local: Promise<LocalCommit>;
|
|
5
|
+
readonly server: Promise<T>;
|
|
6
|
+
readonly status: () => MutationStatus;
|
|
7
|
+
};
|
|
8
|
+
export declare function createLocalFirstMutationCall<T>(input: {
|
|
9
|
+
opId: string;
|
|
10
|
+
local: Promise<LocalCommit>;
|
|
11
|
+
server: Promise<T>;
|
|
12
|
+
status: () => MutationStatus;
|
|
13
|
+
}): LocalFirstMutationCall<T>;
|
|
14
|
+
/**
|
|
15
|
+
* Wrap a plain Convex mutation promise in the hybrid-call shape so useMutation
|
|
16
|
+
* has ONE return type. await behaves exactly like Convex (resolves to the server
|
|
17
|
+
* result); .local resolves immediately (nothing is stored locally for fallback);
|
|
18
|
+
* .server is the Convex promise. Fallback never breaks existing Convex code.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createFallbackMutationCall<T>(promise: Promise<T>): LocalFirstMutationCall<T>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function createLocalFirstMutationCall(input) {
|
|
2
|
+
const promise = input.server;
|
|
3
|
+
Object.defineProperties(promise, {
|
|
4
|
+
opId: {
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: false,
|
|
7
|
+
value: input.opId
|
|
8
|
+
},
|
|
9
|
+
local: {
|
|
10
|
+
enumerable: true,
|
|
11
|
+
configurable: false,
|
|
12
|
+
value: input.local
|
|
13
|
+
},
|
|
14
|
+
server: {
|
|
15
|
+
enumerable: true,
|
|
16
|
+
configurable: false,
|
|
17
|
+
value: input.server
|
|
18
|
+
},
|
|
19
|
+
status: {
|
|
20
|
+
enumerable: false,
|
|
21
|
+
configurable: false,
|
|
22
|
+
value: input.status
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return promise;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Wrap a plain Convex mutation promise in the hybrid-call shape so useMutation
|
|
29
|
+
* has ONE return type. await behaves exactly like Convex (resolves to the server
|
|
30
|
+
* result); .local resolves immediately (nothing is stored locally for fallback);
|
|
31
|
+
* .server is the Convex promise. Fallback never breaks existing Convex code.
|
|
32
|
+
*/
|
|
33
|
+
export function createFallbackMutationCall(promise) {
|
|
34
|
+
return createLocalFirstMutationCall({
|
|
35
|
+
opId: "convex-fallback",
|
|
36
|
+
local: promise.then(() => ({ opId: "convex-fallback", table: "", id: "", committedAt: 0 })),
|
|
37
|
+
server: promise,
|
|
38
|
+
status: () => ({ opId: "convex-fallback", status: "pushing" })
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LocalOperation } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Total, stable order for replaying pending operations (Invariant I4).
|
|
4
|
+
* Primary: creation time. Tiebreak: opId (lexicographic) so the order is
|
|
5
|
+
* identical across reloads, tabs, and repeated derivations.
|
|
6
|
+
*/
|
|
7
|
+
export declare function compareOperations(left: LocalOperation, right: LocalOperation): number;
|
|
8
|
+
/**
|
|
9
|
+
* Stable comparison for client-side query order-by (the chainable `collection`
|
|
10
|
+
* builder and the declarative query interpreter). null/undefined sort LAST so a
|
|
11
|
+
* missing field never wins an ascending sort; numbers compare numerically; everything
|
|
12
|
+
* else compares by locale. One shared definition so the two query paths can't drift.
|
|
13
|
+
*/
|
|
14
|
+
export declare function compareValues(left: unknown, right: unknown): number;
|
package/dist/ordering.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Total, stable order for replaying pending operations (Invariant I4).
|
|
3
|
+
* Primary: creation time. Tiebreak: opId (lexicographic) so the order is
|
|
4
|
+
* identical across reloads, tabs, and repeated derivations.
|
|
5
|
+
*/
|
|
6
|
+
export function compareOperations(left, right) {
|
|
7
|
+
if (left.createdAt !== right.createdAt) {
|
|
8
|
+
return left.createdAt - right.createdAt;
|
|
9
|
+
}
|
|
10
|
+
if (left.opId < right.opId) {
|
|
11
|
+
return -1;
|
|
12
|
+
}
|
|
13
|
+
if (left.opId > right.opId) {
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Stable comparison for client-side query order-by (the chainable `collection`
|
|
20
|
+
* builder and the declarative query interpreter). null/undefined sort LAST so a
|
|
21
|
+
* missing field never wins an ascending sort; numbers compare numerically; everything
|
|
22
|
+
* else compares by locale. One shared definition so the two query paths can't drift.
|
|
23
|
+
*/
|
|
24
|
+
export function compareValues(left, right) {
|
|
25
|
+
if (left == null && right == null)
|
|
26
|
+
return 0;
|
|
27
|
+
if (left == null)
|
|
28
|
+
return 1;
|
|
29
|
+
if (right == null)
|
|
30
|
+
return -1;
|
|
31
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
32
|
+
return left - right;
|
|
33
|
+
}
|
|
34
|
+
return String(left).localeCompare(String(right));
|
|
35
|
+
}
|
package/dist/rebase.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LocalOperation, RowValue, ServerChange } from "./types.js";
|
|
2
|
+
export type RebaseInput = {
|
|
3
|
+
readonly canonicalRows: readonly RowValue[];
|
|
4
|
+
readonly serverChanges: readonly ServerChange[];
|
|
5
|
+
readonly pendingOperations: readonly LocalOperation[];
|
|
6
|
+
};
|
|
7
|
+
export type RebaseOutput = {
|
|
8
|
+
readonly rows: readonly RowValue[];
|
|
9
|
+
readonly conflicts: readonly {
|
|
10
|
+
opId: string;
|
|
11
|
+
message: string;
|
|
12
|
+
}[];
|
|
13
|
+
};
|
|
14
|
+
export declare function rebaseAndReplay(input: RebaseInput): RebaseOutput;
|
package/dist/rebase.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mergePatch } from "./setMerge.js";
|
|
2
|
+
export function rebaseAndReplay(input) {
|
|
3
|
+
const rows = new Map();
|
|
4
|
+
const conflicts = [];
|
|
5
|
+
for (const row of input.canonicalRows) {
|
|
6
|
+
rows.set(`${row._table ?? ""}:${row._id}`, { ...row });
|
|
7
|
+
}
|
|
8
|
+
for (const change of input.serverChanges) {
|
|
9
|
+
const key = `${change.table}:${change.id}`;
|
|
10
|
+
if (change.kind === "delete") {
|
|
11
|
+
const current = rows.get(key) ?? { _id: change.id, _table: change.table };
|
|
12
|
+
rows.set(key, { ...current, _deleted: true, _version: change.version });
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const current = rows.get(key) ?? { _id: change.id, _table: change.table };
|
|
16
|
+
// Server patches are pre-materialized (the server resolves set/counter deltas before
|
|
17
|
+
// appending), so this matches a plain spread today — but routing through mergePatch (the
|
|
18
|
+
// one shared apply rule, same as the pending-op path below) makes server changes delta-safe
|
|
19
|
+
// by construction, so a re-delivered or future delta-bearing change can never clobber.
|
|
20
|
+
const next = change.kind === "patch" ? mergePatch(current, change.patch ?? {}) : { ...current, ...change.value };
|
|
21
|
+
rows.set(key, { ...next, _id: change.id, _table: change.table, _version: change.version, _deleted: false });
|
|
22
|
+
}
|
|
23
|
+
for (const operation of input.pendingOperations) {
|
|
24
|
+
const key = `${operation.table}:${operation.id}`;
|
|
25
|
+
const current = rows.get(key);
|
|
26
|
+
if (operation.kind === "insert") {
|
|
27
|
+
rows.set(key, {
|
|
28
|
+
...(operation.value ?? {}),
|
|
29
|
+
_id: operation.id,
|
|
30
|
+
_table: operation.table,
|
|
31
|
+
_pending: true,
|
|
32
|
+
_deleted: false
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (operation.kind === "patch") {
|
|
37
|
+
if (!current || current._deleted) {
|
|
38
|
+
conflicts.push({ opId: operation.opId, message: "Cannot replay patch over a missing row" });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
// mergePatch set-merges any SetDelta field (declared set fields, computed at commit)
|
|
42
|
+
// and LWW-overwrites the rest — so a pending set-field add replays as a merge over
|
|
43
|
+
// the current value, not a clobber. For plain patches it's identical to a spread.
|
|
44
|
+
rows.set(key, { ...mergePatch(current, operation.patch ?? {}), _pending: true });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!current) {
|
|
48
|
+
conflicts.push({ opId: operation.opId, message: "Cannot replay delete over a missing row" });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
rows.set(key, { ...current, _deleted: true, _pending: true });
|
|
52
|
+
}
|
|
53
|
+
return { rows: Array.from(rows.values()), conflicts };
|
|
54
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { RowValue } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Client-side relations for the local query builder. Because sync is scope-based
|
|
4
|
+
* (every authorized row is already on the device), a "join" is just an in-memory
|
|
5
|
+
* association across collections that are already local — no SQL, no extra round
|
|
6
|
+
* trip, reactive through the same store subscription. We do NOT compile relations
|
|
7
|
+
* to a serializable form (Zero/TanStack must, to drive query-driven sync); we
|
|
8
|
+
* resolve them locally, which is simpler and more flexible.
|
|
9
|
+
*
|
|
10
|
+
* Three shapes cover what an app like Linear needs:
|
|
11
|
+
* - one issue.project (issue.projectId -> projects._id)
|
|
12
|
+
* - many issue.comments (comments.issueId -> issue._id)
|
|
13
|
+
* - manyToMany issue.labels (via an issue_labels join table)
|
|
14
|
+
*/
|
|
15
|
+
export type RelationSpec<_Target = unknown, _Many extends boolean = boolean> = {
|
|
16
|
+
readonly kind: "one" | "many" | "manyToMany";
|
|
17
|
+
readonly table: string;
|
|
18
|
+
/** one: the base row's FK field -> target._id. many: the target row's FK field -> base._id. */
|
|
19
|
+
readonly foreignKey: string;
|
|
20
|
+
/** manyToMany only: the join table and its two FK fields. */
|
|
21
|
+
readonly through?: string;
|
|
22
|
+
readonly localKey?: string;
|
|
23
|
+
readonly targetKey?: string;
|
|
24
|
+
};
|
|
25
|
+
/** base[foreignKey] === target._id. Returns the single target (or undefined). */
|
|
26
|
+
export declare function one<Target extends Record<string, unknown> = RowValue>(table: string, foreignKey: string): RelationSpec<Target, false>;
|
|
27
|
+
/** target[foreignKey] === base._id. Returns the matching targets as an array. */
|
|
28
|
+
export declare function many<Target extends Record<string, unknown> = RowValue>(table: string, foreignKey: string): RelationSpec<Target, true>;
|
|
29
|
+
/** base._id -> through[localKey], through[targetKey] -> target._id. Returns targets. */
|
|
30
|
+
export declare function manyToMany<Target extends Record<string, unknown> = RowValue>(table: string, through: string, localKey: string, targetKey: string): RelationSpec<Target, true>;
|
|
31
|
+
export type RelationEntry = {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly spec: RelationSpec;
|
|
34
|
+
};
|
|
35
|
+
/** Every table a set of relations reads from (targets + join tables). */
|
|
36
|
+
export declare function relationTables(relations: readonly RelationEntry[]): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Attach related rows to each base row, in memory. `rowsByTable` must hold every
|
|
39
|
+
* relation's target (and `through`) table. Each relation is indexed once, so the
|
|
40
|
+
* whole attach is O(base + targets), not O(base × targets). Pure.
|
|
41
|
+
*/
|
|
42
|
+
export declare function attachRelations(baseRows: readonly Record<string, unknown>[], relations: readonly RelationEntry[], rowsByTable: Record<string, readonly RowValue[]>): Record<string, unknown>[];
|