@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,89 @@
|
|
|
1
|
+
/** base[foreignKey] === target._id. Returns the single target (or undefined). */
|
|
2
|
+
export function one(table, foreignKey) {
|
|
3
|
+
return { kind: "one", table, foreignKey };
|
|
4
|
+
}
|
|
5
|
+
/** target[foreignKey] === base._id. Returns the matching targets as an array. */
|
|
6
|
+
export function many(table, foreignKey) {
|
|
7
|
+
return { kind: "many", table, foreignKey };
|
|
8
|
+
}
|
|
9
|
+
/** base._id -> through[localKey], through[targetKey] -> target._id. Returns targets. */
|
|
10
|
+
export function manyToMany(table, through, localKey, targetKey) {
|
|
11
|
+
return { kind: "manyToMany", table, through, localKey, targetKey, foreignKey: "" };
|
|
12
|
+
}
|
|
13
|
+
/** Every table a set of relations reads from (targets + join tables). */
|
|
14
|
+
export function relationTables(relations) {
|
|
15
|
+
const tables = new Set();
|
|
16
|
+
for (const { spec } of relations) {
|
|
17
|
+
tables.add(spec.table);
|
|
18
|
+
if (spec.through) {
|
|
19
|
+
tables.add(spec.through);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return [...tables];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Attach related rows to each base row, in memory. `rowsByTable` must hold every
|
|
26
|
+
* relation's target (and `through`) table. Each relation is indexed once, so the
|
|
27
|
+
* whole attach is O(base + targets), not O(base × targets). Pure.
|
|
28
|
+
*/
|
|
29
|
+
export function attachRelations(baseRows, relations, rowsByTable) {
|
|
30
|
+
if (relations.length === 0) {
|
|
31
|
+
// No relations: hand back the same rows (and their identities) so the React
|
|
32
|
+
// hook's stable-reference optimization holds for the common case.
|
|
33
|
+
return baseRows;
|
|
34
|
+
}
|
|
35
|
+
const resolvers = relations.map(({ name, spec }) => {
|
|
36
|
+
const targets = rowsByTable[spec.table] ?? [];
|
|
37
|
+
if (spec.kind === "one") {
|
|
38
|
+
const byId = new Map(targets.map((t) => [t._id, t]));
|
|
39
|
+
return { name, resolve: (row) => byId.get(row[spec.foreignKey]) };
|
|
40
|
+
}
|
|
41
|
+
if (spec.kind === "many") {
|
|
42
|
+
const byFk = groupBy(targets, (t) => t[spec.foreignKey]);
|
|
43
|
+
return { name, resolve: (row) => byFk.get(row._id) ?? [] };
|
|
44
|
+
}
|
|
45
|
+
// manyToMany
|
|
46
|
+
const through = rowsByTable[spec.through] ?? [];
|
|
47
|
+
const targetIdsByLocal = new Map();
|
|
48
|
+
for (const link of through) {
|
|
49
|
+
const localId = link[spec.localKey];
|
|
50
|
+
let set = targetIdsByLocal.get(localId);
|
|
51
|
+
if (!set) {
|
|
52
|
+
set = new Set();
|
|
53
|
+
targetIdsByLocal.set(localId, set);
|
|
54
|
+
}
|
|
55
|
+
set.add(link[spec.targetKey]);
|
|
56
|
+
}
|
|
57
|
+
const byId = new Map(targets.map((t) => [t._id, t]));
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
resolve: (row) => {
|
|
61
|
+
const ids = targetIdsByLocal.get(row._id);
|
|
62
|
+
if (!ids) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return [...ids].map((id) => byId.get(id)).filter((t) => t !== undefined);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
return baseRows.map((row) => {
|
|
70
|
+
const out = { ...row };
|
|
71
|
+
for (const { name, resolve } of resolvers) {
|
|
72
|
+
out[name] = resolve(row);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function groupBy(rows, key) {
|
|
78
|
+
const map = new Map();
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const k = key(row);
|
|
81
|
+
let bucket = map.get(k);
|
|
82
|
+
if (!bucket) {
|
|
83
|
+
bucket = [];
|
|
84
|
+
map.set(k, bucket);
|
|
85
|
+
}
|
|
86
|
+
bucket.push(row);
|
|
87
|
+
}
|
|
88
|
+
return map;
|
|
89
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set-field merge: convergent add/remove for array fields declared as sets (e.g.
|
|
3
|
+
* `label_ids`). A set-field patch is recorded as an add/remove DELTA (vs the value the
|
|
4
|
+
* client saw); merge applies `(current ∪ add) \ remove` on both client replay and server,
|
|
5
|
+
* so concurrent adds/removes to different elements all survive instead of LWW-clobbering.
|
|
6
|
+
* Delta-based grow/shrink set (not a tagged OR-Set): concurrent add+remove of the SAME
|
|
7
|
+
* element is a genuine conflict resolved by apply order.
|
|
8
|
+
*/
|
|
9
|
+
/** A patch field carrying a set delta instead of a replacement value. */
|
|
10
|
+
export type SetDelta = {
|
|
11
|
+
readonly __lfSet: {
|
|
12
|
+
readonly add: readonly unknown[];
|
|
13
|
+
readonly remove: readonly unknown[];
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Counter-field merge: convergent add/subtract for numeric fields declared as counters
|
|
18
|
+
* (e.g. `vote_count`). A counter-field patch is recorded as a numeric DELTA (vs the value
|
|
19
|
+
* the client saw); merge ADDS deltas on both client replay and server, so concurrent
|
|
20
|
+
* increments accumulate instead of LWW-clobbering. Convergent because addition commutes,
|
|
21
|
+
* so no baseVersion is needed. A counter delta over a non-number field is rejected.
|
|
22
|
+
*/
|
|
23
|
+
export type CounterDelta = {
|
|
24
|
+
readonly __lfCounter: number;
|
|
25
|
+
};
|
|
26
|
+
export declare function isCounterDelta(value: unknown): value is CounterDelta;
|
|
27
|
+
/** The delta that turns `current` into `next` as a counter: `next - current` (absent/
|
|
28
|
+
* non-number current counts as 0). The app patches with the whole intended number; this
|
|
29
|
+
* derives the increment so concurrent edits accumulate instead of clobbering. */
|
|
30
|
+
export declare function computeCounterDelta(current: unknown, next: number): number;
|
|
31
|
+
/** Apply a counter delta: `(current ?? 0) + delta`. Absent/non-number current counts as 0.
|
|
32
|
+
* Commutative + associative, so applying deltas in any order converges. */
|
|
33
|
+
export declare function applyCounterDelta(current: unknown, delta: number): number;
|
|
34
|
+
/**
|
|
35
|
+
* Timestamp-ordered last-writer-wins for a scalar field (an LWW-register). A field write
|
|
36
|
+
* carries the originating op's logical timestamp + a stable tiebreaker (the clientId); the
|
|
37
|
+
* write with the higher `(ts, tiebreaker)` WINS, deterministically and regardless of the
|
|
38
|
+
* order writes arrive at the server. This fixes the offline-first hazard that plain
|
|
39
|
+
* arrival-order LWW has — an OLDER edit that syncs LATER must NOT overwrite a NEWER one.
|
|
40
|
+
*
|
|
41
|
+
* Provably convergent: `(ts, tiebreaker)` is a total order, so all replicas pick the same
|
|
42
|
+
* winner no matter the apply order. The tiebreaker breaks equal-timestamp ties (lexicographic
|
|
43
|
+
* on the clientId string) so two truly-concurrent writes still resolve deterministically.
|
|
44
|
+
*/
|
|
45
|
+
export type FieldClock = {
|
|
46
|
+
readonly ts: number;
|
|
47
|
+
readonly tiebreaker: string;
|
|
48
|
+
};
|
|
49
|
+
/** True if an incoming write `(ts, tiebreaker)` beats the current field clock (absent = wins). */
|
|
50
|
+
export declare function lwwWins(incoming: FieldClock, current: FieldClock | undefined): boolean;
|
|
51
|
+
export declare function isSetDelta(value: unknown): value is SetDelta;
|
|
52
|
+
/** The delta that turns `current` into `next` as a set: elements in next-not-current are
|
|
53
|
+
* adds, elements in current-not-next are removes. Order/duplicates in inputs don't matter. */
|
|
54
|
+
export declare function computeSetDelta(current: unknown, next: readonly unknown[]): SetDelta["__lfSet"];
|
|
55
|
+
/** Apply a set delta to a current value: keep current order, drop removed elements, append
|
|
56
|
+
* added elements not already present. Deterministic + idempotent (re-applying is a no-op). */
|
|
57
|
+
export declare function applySetDelta(current: unknown, delta: SetDelta["__lfSet"]): unknown[];
|
|
58
|
+
/**
|
|
59
|
+
* Merge one patch onto a row: for a field whose patch value is a SetDelta, apply the delta
|
|
60
|
+
* to the current field value (set merge); every other field overwrites (field-level LWW).
|
|
61
|
+
* This is the single shared apply rule used by the client view/replay AND the server.
|
|
62
|
+
*/
|
|
63
|
+
export declare function mergePatch<T extends Record<string, unknown>>(current: T, patch: Record<string, unknown>): T;
|
package/dist/setMerge.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set-field merge: convergent add/remove for array fields declared as sets (e.g.
|
|
3
|
+
* `label_ids`). A set-field patch is recorded as an add/remove DELTA (vs the value the
|
|
4
|
+
* client saw); merge applies `(current ∪ add) \ remove` on both client replay and server,
|
|
5
|
+
* so concurrent adds/removes to different elements all survive instead of LWW-clobbering.
|
|
6
|
+
* Delta-based grow/shrink set (not a tagged OR-Set): concurrent add+remove of the SAME
|
|
7
|
+
* element is a genuine conflict resolved by apply order.
|
|
8
|
+
*/
|
|
9
|
+
export function isCounterDelta(value) {
|
|
10
|
+
return (typeof value === "object" &&
|
|
11
|
+
value !== null &&
|
|
12
|
+
"__lfCounter" in value &&
|
|
13
|
+
typeof value.__lfCounter === "number");
|
|
14
|
+
}
|
|
15
|
+
/** The delta that turns `current` into `next` as a counter: `next - current` (absent/
|
|
16
|
+
* non-number current counts as 0). The app patches with the whole intended number; this
|
|
17
|
+
* derives the increment so concurrent edits accumulate instead of clobbering. */
|
|
18
|
+
export function computeCounterDelta(current, next) {
|
|
19
|
+
return next - (typeof current === "number" ? current : 0);
|
|
20
|
+
}
|
|
21
|
+
/** Apply a counter delta: `(current ?? 0) + delta`. Absent/non-number current counts as 0.
|
|
22
|
+
* Commutative + associative, so applying deltas in any order converges. */
|
|
23
|
+
export function applyCounterDelta(current, delta) {
|
|
24
|
+
return (typeof current === "number" ? current : 0) + delta;
|
|
25
|
+
}
|
|
26
|
+
/** True if an incoming write `(ts, tiebreaker)` beats the current field clock (absent = wins). */
|
|
27
|
+
export function lwwWins(incoming, current) {
|
|
28
|
+
if (!current)
|
|
29
|
+
return true;
|
|
30
|
+
if (incoming.ts !== current.ts)
|
|
31
|
+
return incoming.ts > current.ts;
|
|
32
|
+
return incoming.tiebreaker > current.tiebreaker;
|
|
33
|
+
}
|
|
34
|
+
/** Stable identity key for a set element. Strings (the common case: ids) key as-is;
|
|
35
|
+
* anything else by JSON so distinct shapes never collide and types don't alias. */
|
|
36
|
+
function keyOf(element) {
|
|
37
|
+
return typeof element === "string" ? element : JSON.stringify(element);
|
|
38
|
+
}
|
|
39
|
+
export function isSetDelta(value) {
|
|
40
|
+
return (typeof value === "object" &&
|
|
41
|
+
value !== null &&
|
|
42
|
+
"__lfSet" in value &&
|
|
43
|
+
typeof value.__lfSet === "object" &&
|
|
44
|
+
value.__lfSet !== null);
|
|
45
|
+
}
|
|
46
|
+
/** The delta that turns `current` into `next` as a set: elements in next-not-current are
|
|
47
|
+
* adds, elements in current-not-next are removes. Order/duplicates in inputs don't matter. */
|
|
48
|
+
export function computeSetDelta(current, next) {
|
|
49
|
+
const currentArr = Array.isArray(current) ? current : [];
|
|
50
|
+
const currentKeys = new Set(currentArr.map(keyOf));
|
|
51
|
+
const nextKeys = new Set(next.map(keyOf));
|
|
52
|
+
const add = next.filter((el) => !currentKeys.has(keyOf(el)));
|
|
53
|
+
const remove = currentArr.filter((el) => !nextKeys.has(keyOf(el)));
|
|
54
|
+
return { add, remove };
|
|
55
|
+
}
|
|
56
|
+
/** Apply a set delta to a current value: keep current order, drop removed elements, append
|
|
57
|
+
* added elements not already present. Deterministic + idempotent (re-applying is a no-op). */
|
|
58
|
+
export function applySetDelta(current, delta) {
|
|
59
|
+
const removeKeys = new Set(delta.remove.map(keyOf));
|
|
60
|
+
const result = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (const el of Array.isArray(current) ? current : []) {
|
|
63
|
+
const k = keyOf(el);
|
|
64
|
+
if (removeKeys.has(k) || seen.has(k))
|
|
65
|
+
continue;
|
|
66
|
+
seen.add(k);
|
|
67
|
+
result.push(el);
|
|
68
|
+
}
|
|
69
|
+
for (const el of delta.add) {
|
|
70
|
+
const k = keyOf(el);
|
|
71
|
+
if (removeKeys.has(k) || seen.has(k))
|
|
72
|
+
continue;
|
|
73
|
+
seen.add(k);
|
|
74
|
+
result.push(el);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Merge one patch onto a row: for a field whose patch value is a SetDelta, apply the delta
|
|
80
|
+
* to the current field value (set merge); every other field overwrites (field-level LWW).
|
|
81
|
+
* This is the single shared apply rule used by the client view/replay AND the server.
|
|
82
|
+
*/
|
|
83
|
+
export function mergePatch(current, patch) {
|
|
84
|
+
const next = { ...current };
|
|
85
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
86
|
+
next[field] = isSetDelta(value)
|
|
87
|
+
? applySetDelta(current[field], value.__lfSet)
|
|
88
|
+
: isCounterDelta(value)
|
|
89
|
+
? applyCounterDelta(current[field], value.__lfCounter)
|
|
90
|
+
: value;
|
|
91
|
+
}
|
|
92
|
+
return next;
|
|
93
|
+
}
|
package/dist/status.d.ts
ADDED
package/dist/status.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Cursor, LocalId, LocalOperation, OperationStatus, RowValue, ScopeKey, ServerChange, TableName } from "./types.js";
|
|
2
|
+
export type StoreListener = () => void;
|
|
3
|
+
export type StoreUnsubscribe = () => void;
|
|
4
|
+
/**
|
|
5
|
+
* Canonical-centric local store.
|
|
6
|
+
*
|
|
7
|
+
* Invariant I1: the live view returned by getRows/getRow is ALWAYS derived as
|
|
8
|
+
* `canonical snapshot + replay(pending ops)`. There is no separately-mutated
|
|
9
|
+
* "live" row map, so a server change physically cannot clobber a pending local
|
|
10
|
+
* operation. Local mutations only enqueue operations; the canonical snapshot
|
|
11
|
+
* only changes through applyServerChange.
|
|
12
|
+
*/
|
|
13
|
+
export type LocalStore = {
|
|
14
|
+
/**
|
|
15
|
+
* @internal Derived live view for a table (canonical + replayed pending ops);
|
|
16
|
+
* includes _deleted rows. The store is a persistence PRIMITIVE for the engine —
|
|
17
|
+
* its reads are UNSCOPED. App code must read through the React hooks
|
|
18
|
+
* (useQuery/useLiveQuery), which enforce the scoped fail-closed guard; do not
|
|
19
|
+
* call getRows/getRow directly to display data.
|
|
20
|
+
*/
|
|
21
|
+
getRows(table: TableName): Promise<readonly RowValue[]>;
|
|
22
|
+
getRow(table: TableName, id: LocalId): Promise<RowValue | null>;
|
|
23
|
+
/** Canonical server snapshot, for inspection/tests. */
|
|
24
|
+
getCanonicalRows(table: TableName): Promise<readonly RowValue[]>;
|
|
25
|
+
/** Apply one authoritative server change to the canonical snapshot (and prune a confirmed op). */
|
|
26
|
+
applyServerChange(change: ServerChange): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Apply a batch of server changes with a SINGLE notify (and, for durable stores,
|
|
29
|
+
* a single transaction). The hot path for sync pulls: applying N changes one at
|
|
30
|
+
* a time would fire N notifications — each re-deriving + re-cloning every row in
|
|
31
|
+
* every mounted query — turning a cold pull into O(N×rows). Order is preserved so
|
|
32
|
+
* repeated changes to the same row resolve correctly.
|
|
33
|
+
*/
|
|
34
|
+
applyServerChanges(changes: readonly ServerChange[]): Promise<void>;
|
|
35
|
+
/** Outbox. */
|
|
36
|
+
enqueueOperation(operation: LocalOperation): Promise<void>;
|
|
37
|
+
/** Ops still owed to the server (pending|pushing), ordered deterministically. */
|
|
38
|
+
getPendingOperations(): Promise<readonly LocalOperation[]>;
|
|
39
|
+
/** Every op still in the log, for conflict inspection. */
|
|
40
|
+
getAllOperations(): Promise<readonly LocalOperation[]>;
|
|
41
|
+
getOperation(opId: string): Promise<LocalOperation | null>;
|
|
42
|
+
updateOperationStatus(opId: string, status: OperationStatus, error?: string): Promise<void>;
|
|
43
|
+
/** Remove an op from the outbox. Used for an accepted op that produced NO canonical
|
|
44
|
+
* change (an idempotent no-op delete): applyServerChanges only prunes ops a change
|
|
45
|
+
* references, so without this the op lingers, replayed forever. */
|
|
46
|
+
dropOperation(opId: string): Promise<void>;
|
|
47
|
+
getCursor(scopeKey: ScopeKey): Promise<Cursor>;
|
|
48
|
+
setCursor(scopeKey: ScopeKey, cursor: string): Promise<void>;
|
|
49
|
+
/** Drop all data for this namespace (logout). */
|
|
50
|
+
clear(): Promise<void>;
|
|
51
|
+
subscribe(listener: StoreListener): StoreUnsubscribe;
|
|
52
|
+
notify(): void;
|
|
53
|
+
};
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { PullRequest, PullResponse, PushRequest, PushResponse } from "./types.js";
|
|
2
|
+
export type SyncTransport = {
|
|
3
|
+
push(request: PushRequest): Promise<PushResponse>;
|
|
4
|
+
pull(request: PullRequest): Promise<PullResponse>;
|
|
5
|
+
/**
|
|
6
|
+
* Optional reactive change feed: invoke `onChange` whenever the server has new
|
|
7
|
+
* changes for `request.scopes` after `request.cursors` (true server push — no
|
|
8
|
+
* polling). Returns an unsubscribe. `onChange` is a content-free doorbell: the
|
|
9
|
+
* engine pulls on each fire. Transports without a reactive channel omit this and
|
|
10
|
+
* the client falls back to polling (`useLiveQuery({ pollMs })`).
|
|
11
|
+
*/
|
|
12
|
+
subscribe?(request: PullRequest, onChange: () => void): () => void;
|
|
13
|
+
};
|
|
14
|
+
/** A subscription handle from `ConvexReactClient.watchQuery`. Internal — implementation
|
|
15
|
+
* shape consumed only by createConvexTransport, not part of the public API. */
|
|
16
|
+
type ConvexWatch = {
|
|
17
|
+
onUpdate(callback: () => void): () => void;
|
|
18
|
+
};
|
|
19
|
+
/** Minimal Convex client shape. `watchQuery` is present on ConvexReactClient (and
|
|
20
|
+
* enables reactive pull); ConvexHttpClient omits it (pull falls back to polling).
|
|
21
|
+
* Internal — not part of the public API. */
|
|
22
|
+
type ConvexLikeClient = {
|
|
23
|
+
mutation(reference: unknown, args: Record<string, unknown>): Promise<unknown>;
|
|
24
|
+
query(reference: unknown, args: Record<string, unknown>): Promise<unknown>;
|
|
25
|
+
watchQuery?(reference: unknown, args: Record<string, unknown>): ConvexWatch;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Adapt a Convex client + the generated sync.push/sync.pull references into the
|
|
29
|
+
* engine's SyncTransport: serializes local operations, calls Convex, and maps
|
|
30
|
+
* the server change log back into client ServerChange shape.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createConvexTransport(options: {
|
|
33
|
+
client: ConvexLikeClient;
|
|
34
|
+
push: unknown;
|
|
35
|
+
pull: unknown;
|
|
36
|
+
clientId: string;
|
|
37
|
+
userId: string;
|
|
38
|
+
}): SyncTransport;
|
|
39
|
+
export declare class OfflineTransportError extends Error {
|
|
40
|
+
constructor(message?: string);
|
|
41
|
+
}
|
|
42
|
+
export declare function createOfflineTransport(): SyncTransport;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
function toClientChange(change) {
|
|
2
|
+
return {
|
|
3
|
+
changeId: change.changeId,
|
|
4
|
+
scopeKey: change.scopeKey,
|
|
5
|
+
table: change.table,
|
|
6
|
+
id: change.localId, // the client keys rows by localId
|
|
7
|
+
kind: change.kind,
|
|
8
|
+
value: change.data,
|
|
9
|
+
patch: change.patch,
|
|
10
|
+
version: change.version,
|
|
11
|
+
serverTime: change.serverTime,
|
|
12
|
+
opId: change.opId
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function scopeValue(key) {
|
|
16
|
+
const idx = key.indexOf(":");
|
|
17
|
+
return idx >= 0 ? key.slice(idx + 1) : undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Adapt a Convex client + the generated sync.push/sync.pull references into the
|
|
21
|
+
* engine's SyncTransport: serializes local operations, calls Convex, and maps
|
|
22
|
+
* the server change log back into client ServerChange shape.
|
|
23
|
+
*/
|
|
24
|
+
export function createConvexTransport(options) {
|
|
25
|
+
return {
|
|
26
|
+
async push(request) {
|
|
27
|
+
const response = (await options.client.mutation(options.push, {
|
|
28
|
+
clientId: options.clientId,
|
|
29
|
+
userId: options.userId,
|
|
30
|
+
schemaVersion: request.schemaVersion,
|
|
31
|
+
mutations: request.mutations.map((op) => ({
|
|
32
|
+
opId: op.opId,
|
|
33
|
+
clientId: op.clientId,
|
|
34
|
+
schemaVersion: op.schemaVersion,
|
|
35
|
+
functionName: op.functionName,
|
|
36
|
+
table: op.table,
|
|
37
|
+
kind: op.kind,
|
|
38
|
+
localId: op.id,
|
|
39
|
+
value: op.value,
|
|
40
|
+
patch: op.patch,
|
|
41
|
+
// The op's logical timestamp — consumed only by `timestampLww` tables on the server
|
|
42
|
+
// to resolve same-field collisions by recency. Harmless extra field otherwise.
|
|
43
|
+
timestamp: op.createdAt
|
|
44
|
+
}))
|
|
45
|
+
}));
|
|
46
|
+
return { ...response, changes: response.changes.map(toClientChange) };
|
|
47
|
+
},
|
|
48
|
+
async pull(request) {
|
|
49
|
+
const response = (await options.client.query(options.pull, {
|
|
50
|
+
clientId: options.clientId,
|
|
51
|
+
userId: options.userId,
|
|
52
|
+
schemaVersion: request.schemaVersion,
|
|
53
|
+
scopes: request.scopes.map((scope) => ({ kind: scope.kind, value: scopeValue(scope.key) })),
|
|
54
|
+
cursors: request.cursors
|
|
55
|
+
}));
|
|
56
|
+
return { ...response, changes: response.changes.map(toClientChange) };
|
|
57
|
+
},
|
|
58
|
+
// Reactive pull: watch the SAME `pull` query (Convex queries are reactive) as a
|
|
59
|
+
// doorbell. It re-fires whenever a new change lands in these scopes after the
|
|
60
|
+
// given cursors — the engine then drains via the regular `pull` path. Reuses the
|
|
61
|
+
// already-audited pull endpoint, so it adds no new server surface (no I7 change).
|
|
62
|
+
// Only wired when the client is reactive (ConvexReactClient.watchQuery); the
|
|
63
|
+
// HTTP client omits it and the engine falls back to polling.
|
|
64
|
+
subscribe: options.client.watchQuery
|
|
65
|
+
? (request, onChange) => {
|
|
66
|
+
const watch = options.client.watchQuery(options.pull, {
|
|
67
|
+
clientId: options.clientId,
|
|
68
|
+
userId: options.userId,
|
|
69
|
+
schemaVersion: request.schemaVersion,
|
|
70
|
+
scopes: request.scopes.map((scope) => ({ kind: scope.kind, value: scopeValue(scope.key) })),
|
|
71
|
+
cursors: request.cursors
|
|
72
|
+
});
|
|
73
|
+
return watch.onUpdate(() => onChange());
|
|
74
|
+
}
|
|
75
|
+
: undefined
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export class OfflineTransportError extends Error {
|
|
79
|
+
constructor(message = "Local-first transport is offline") {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "OfflineTransportError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function createOfflineTransport() {
|
|
85
|
+
return {
|
|
86
|
+
async push() {
|
|
87
|
+
throw new OfflineTransportError();
|
|
88
|
+
},
|
|
89
|
+
async pull() {
|
|
90
|
+
throw new OfflineTransportError();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
2
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | {
|
|
3
|
+
[key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
export type JsonObject = {
|
|
6
|
+
[key: string]: JsonValue;
|
|
7
|
+
};
|
|
8
|
+
export type TableName = string;
|
|
9
|
+
export type FunctionName = string;
|
|
10
|
+
export type LocalId = string;
|
|
11
|
+
export type ClientId = string;
|
|
12
|
+
export type UserId = string;
|
|
13
|
+
export type ScopeKey = string;
|
|
14
|
+
export type Cursor = string | null;
|
|
15
|
+
export type RowValue = Record<string, unknown> & {
|
|
16
|
+
_id: LocalId;
|
|
17
|
+
_table?: TableName;
|
|
18
|
+
_version?: number;
|
|
19
|
+
_deleted?: boolean;
|
|
20
|
+
_pending?: boolean;
|
|
21
|
+
_conflict?: ConflictInfo;
|
|
22
|
+
};
|
|
23
|
+
export type ConflictInfo = {
|
|
24
|
+
readonly kind: "serverRejected" | "mergeFailed";
|
|
25
|
+
readonly message: string;
|
|
26
|
+
readonly opId?: string;
|
|
27
|
+
readonly serverVersion?: number;
|
|
28
|
+
};
|
|
29
|
+
export type OperationKind = "insert" | "patch" | "delete";
|
|
30
|
+
export type OperationStatus = "pending" | "pushing" | "acked" | "rejected";
|
|
31
|
+
export type OperationPlan = {
|
|
32
|
+
readonly kind: "insert";
|
|
33
|
+
readonly table: TableName;
|
|
34
|
+
readonly id?: LocalId;
|
|
35
|
+
readonly value: Record<string, unknown>;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: "patch";
|
|
38
|
+
readonly table: TableName;
|
|
39
|
+
readonly id: LocalId;
|
|
40
|
+
readonly patch: Record<string, unknown>;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: "delete";
|
|
43
|
+
readonly table: TableName;
|
|
44
|
+
readonly id: LocalId;
|
|
45
|
+
};
|
|
46
|
+
export type LocalOperation = {
|
|
47
|
+
readonly opId: string;
|
|
48
|
+
readonly clientId: ClientId;
|
|
49
|
+
readonly userId: UserId | null;
|
|
50
|
+
readonly schemaVersion: number;
|
|
51
|
+
readonly functionName: FunctionName;
|
|
52
|
+
readonly table: TableName;
|
|
53
|
+
readonly kind: OperationKind;
|
|
54
|
+
readonly id: LocalId;
|
|
55
|
+
readonly args: JsonValue;
|
|
56
|
+
readonly value?: Record<string, unknown>;
|
|
57
|
+
readonly patch?: Record<string, unknown>;
|
|
58
|
+
readonly baseVersion?: number;
|
|
59
|
+
readonly createdAt: number;
|
|
60
|
+
readonly status: OperationStatus;
|
|
61
|
+
readonly error?: string;
|
|
62
|
+
};
|
|
63
|
+
export type ServerChangeKind = "insert" | "patch" | "delete" | "replace";
|
|
64
|
+
export type ServerChange = {
|
|
65
|
+
readonly changeId: string;
|
|
66
|
+
readonly scopeKey: ScopeKey;
|
|
67
|
+
readonly table: TableName;
|
|
68
|
+
readonly id: LocalId;
|
|
69
|
+
readonly kind: ServerChangeKind;
|
|
70
|
+
readonly value?: Record<string, unknown>;
|
|
71
|
+
readonly patch?: Record<string, unknown>;
|
|
72
|
+
readonly version: number;
|
|
73
|
+
readonly serverTime: number;
|
|
74
|
+
readonly opId?: string;
|
|
75
|
+
};
|
|
76
|
+
export type SyncScope = {
|
|
77
|
+
readonly kind: "byUser" | "byWorkspace" | "byProject";
|
|
78
|
+
readonly key: ScopeKey;
|
|
79
|
+
readonly table?: TableName;
|
|
80
|
+
};
|
|
81
|
+
export type PushRequest = {
|
|
82
|
+
readonly clientId: ClientId;
|
|
83
|
+
readonly userId: UserId | null;
|
|
84
|
+
readonly schemaVersion: number;
|
|
85
|
+
readonly mutations: readonly LocalOperation[];
|
|
86
|
+
};
|
|
87
|
+
export type AcceptedMutation = {
|
|
88
|
+
readonly opId: string;
|
|
89
|
+
readonly serverResult?: unknown;
|
|
90
|
+
};
|
|
91
|
+
export type RejectedMutation = {
|
|
92
|
+
readonly opId: string;
|
|
93
|
+
readonly message: string;
|
|
94
|
+
readonly code?: string;
|
|
95
|
+
readonly rowId?: LocalId;
|
|
96
|
+
};
|
|
97
|
+
export type IdMapEntry = {
|
|
98
|
+
readonly table: TableName;
|
|
99
|
+
readonly localId: LocalId;
|
|
100
|
+
readonly serverId: string;
|
|
101
|
+
};
|
|
102
|
+
export type PushResponse = {
|
|
103
|
+
readonly accepted: readonly AcceptedMutation[];
|
|
104
|
+
readonly rejected: readonly RejectedMutation[];
|
|
105
|
+
readonly idMaps: readonly IdMapEntry[];
|
|
106
|
+
readonly changes: readonly ServerChange[];
|
|
107
|
+
readonly serverTime: number;
|
|
108
|
+
/** Server signals the client schema version is incompatible; client must not apply. */
|
|
109
|
+
readonly schemaMismatch?: boolean;
|
|
110
|
+
};
|
|
111
|
+
export type PullRequest = {
|
|
112
|
+
readonly clientId: ClientId;
|
|
113
|
+
readonly userId: UserId | null;
|
|
114
|
+
readonly schemaVersion: number;
|
|
115
|
+
readonly scopes: readonly SyncScope[];
|
|
116
|
+
readonly cursors: Record<ScopeKey, Cursor>;
|
|
117
|
+
};
|
|
118
|
+
export type PullResponse = {
|
|
119
|
+
readonly changes: readonly ServerChange[];
|
|
120
|
+
readonly cursors: Record<ScopeKey, string>;
|
|
121
|
+
readonly serverTime: number;
|
|
122
|
+
/** Server signals the client schema version is incompatible; client must not apply. */
|
|
123
|
+
readonly schemaMismatch?: boolean;
|
|
124
|
+
/** Per-scope: true if the server capped this page and more changes remain past the
|
|
125
|
+
* returned cursor. Lets the client drain to completion and report partial hydration
|
|
126
|
+
* (a large cold start) instead of silently stopping behind. */
|
|
127
|
+
readonly hasMore?: Record<ScopeKey, boolean>;
|
|
128
|
+
};
|
|
129
|
+
export type SyncStatus = {
|
|
130
|
+
readonly online: boolean;
|
|
131
|
+
readonly syncing: boolean;
|
|
132
|
+
readonly pendingMutations: number;
|
|
133
|
+
readonly lastPushAt: number | null;
|
|
134
|
+
readonly lastPullAt: number | null;
|
|
135
|
+
readonly lastError: string | null;
|
|
136
|
+
readonly blockedBySchemaMismatch: boolean;
|
|
137
|
+
/** True while the local cache is still catching up to the server for some scope (a
|
|
138
|
+
* large cold start drained past the per-pull cap). False once fully hydrated. */
|
|
139
|
+
readonly partial: boolean;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* The result of a durable local commit (the `mutate(ref, args).local` promise).
|
|
143
|
+
* `id` is the CANONICAL row id this mutation targets — the new row's id for an
|
|
144
|
+
* insert, or the edited/removed row's id otherwise. It equals `row[idField]` and
|
|
145
|
+
* `row._id` on every subsequent read (the engine stamps the idField on inserts;
|
|
146
|
+
* the server re-stamps it on sync; the client keys rows by this same id). So a
|
|
147
|
+
* headless consumer reads it back with no guesswork: `const id = (await
|
|
148
|
+
* engine.mutate(ref, args).local).id`.
|
|
149
|
+
*/
|
|
150
|
+
export type LocalCommit = {
|
|
151
|
+
readonly opId: string;
|
|
152
|
+
readonly table: TableName;
|
|
153
|
+
readonly id: LocalId;
|
|
154
|
+
readonly committedAt: number;
|
|
155
|
+
/**
|
|
156
|
+
* The resulting row, so a caller can use it directly instead of a readback round-trip:
|
|
157
|
+
* - INSERT: the optimistic row just written — `value` with `row[idField] === id`,
|
|
158
|
+
* identical to what a read returns immediately after (and what the server re-stamps
|
|
159
|
+
* on sync).
|
|
160
|
+
* - PATCH: the canonical-plus-pending merge AFTER this patch is applied (same as
|
|
161
|
+
* `getRow(table, id)` right after) — `undefined` only if the row isn't local yet.
|
|
162
|
+
* - REMOVE: `undefined` — the row is gone.
|
|
163
|
+
*
|
|
164
|
+
* Reading it is local-only (no server pull), since the write already flushes via its
|
|
165
|
+
* background `.server` push.
|
|
166
|
+
*/
|
|
167
|
+
readonly row?: Record<string, unknown>;
|
|
168
|
+
};
|
|
169
|
+
export type MutationStatus = {
|
|
170
|
+
readonly opId: string;
|
|
171
|
+
readonly status: OperationStatus;
|
|
172
|
+
readonly error?: string;
|
|
173
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/view.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LocalOperation, RowValue, ServerChange, TableName } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Derive the live view for one table (Invariant I1): canonical snapshot with the
|
|
4
|
+
* deterministically-ordered pending operations replayed on top. Shared by every
|
|
5
|
+
* LocalStore implementation so the rebase logic lives in exactly one place.
|
|
6
|
+
*/
|
|
7
|
+
export declare function deriveView(table: TableName, canonicalRows: readonly RowValue[], operations: readonly LocalOperation[]): RowValue[];
|
|
8
|
+
/**
|
|
9
|
+
* Compute the next canonical row for a server change, or "stale" if the change
|
|
10
|
+
* must be ignored because its version does not advance the row (Invariant I5).
|
|
11
|
+
*/
|
|
12
|
+
export declare function nextCanonicalRow(current: RowValue | null, change: ServerChange): RowValue | "stale";
|