@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fanzzzd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @convex-localfirst/core
|
|
2
|
+
|
|
3
|
+
The local-first engine for [Convex](https://convex.dev): a canonical-centric store
|
|
4
|
+
where the live view is derived (`canonical + replay(pending)`), so server changes never
|
|
5
|
+
clobber pending local ops. Includes the sync protocol, deterministic op ordering, an
|
|
6
|
+
IndexedDB adapter with migrations, Web Locks multi-tab leadership, and opt-in convergent
|
|
7
|
+
merges (set / counter / timestamp-LWW).
|
|
8
|
+
|
|
9
|
+
Most apps use [`@convex-localfirst/react`](https://www.npmjs.com/package/@convex-localfirst/react)
|
|
10
|
+
or [`@convex-localfirst/server`](https://www.npmjs.com/package/@convex-localfirst/server)
|
|
11
|
+
rather than this package directly.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @convex-localfirst/core
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
MIT
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { RelationEntry, RelationSpec } from "./relations.js";
|
|
2
|
+
import type { RowValue } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* A compiled local-first query plan: a pure where/order/limit refinement over the
|
|
5
|
+
* rows a table already holds locally (its authorized, server-pulled scope), plus
|
|
6
|
+
* optional in-memory relations attached from other local tables. It NEVER reaches
|
|
7
|
+
* unsynced data — local query is refinement, not authorization. The server still
|
|
8
|
+
* decides what syncs into the scope (Invariant I7), so a client predicate/relation
|
|
9
|
+
* can only narrow/join what is already permitted, never widen it.
|
|
10
|
+
*
|
|
11
|
+
* `Row` is the base table row; `Rel` is the shape attached by .related() — the
|
|
12
|
+
* result rows are `Row & Rel`.
|
|
13
|
+
*/
|
|
14
|
+
export type LocalQueryPlan<Row extends Record<string, unknown> = Record<string, unknown>, Rel = unknown> = {
|
|
15
|
+
readonly __localFirstQuery: true;
|
|
16
|
+
/**
|
|
17
|
+
* Phantom: carries the `.related()` result shape `Rel` so `useLiveQuery` can
|
|
18
|
+
* infer `Row & Rel` structurally. `Rel` appears in no other member (run() only
|
|
19
|
+
* returns the base `Row[]`), so without this the parameter would be unbindable
|
|
20
|
+
* and silently fall back to `unknown`. Never set at runtime.
|
|
21
|
+
*/
|
|
22
|
+
readonly __rel?: Rel;
|
|
23
|
+
readonly table: string;
|
|
24
|
+
/** Scope field values (e.g. { workspaceId }); the engine builds the pull scope. */
|
|
25
|
+
readonly scopeValues?: Record<string, unknown>;
|
|
26
|
+
/** Relations to attach in memory (resolved by the engine across local tables). */
|
|
27
|
+
readonly relations: readonly RelationEntry[];
|
|
28
|
+
/** Apply where/order/limit to the base table's live rows. Pure. Relations are
|
|
29
|
+
* attached afterwards by the engine (they need other tables' rows). */
|
|
30
|
+
run(rows: readonly RowValue[]): Row[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The result shape contributed by a named map of relation specs: each key maps
|
|
34
|
+
* to `Target[]` for many/manyToMany, or `Target | undefined` for one. Lets
|
|
35
|
+
* `.withRelations({...})` attach a reusable relation map in one typed call.
|
|
36
|
+
*/
|
|
37
|
+
export type RelationsResult<Specs extends Record<string, RelationSpec>> = {
|
|
38
|
+
[K in keyof Specs]: Specs[K] extends RelationSpec<infer Target, infer Many> ? Many extends true ? Target[] : Target | undefined : never;
|
|
39
|
+
};
|
|
40
|
+
type Ops<Row> = {
|
|
41
|
+
readonly scopeValues?: Record<string, unknown>;
|
|
42
|
+
readonly predicates: ReadonlyArray<(row: Row) => boolean>;
|
|
43
|
+
readonly orderKey?: keyof Row;
|
|
44
|
+
readonly orderDir: "asc" | "desc";
|
|
45
|
+
readonly limitN?: number;
|
|
46
|
+
readonly relations: readonly RelationEntry[];
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Chainable, fully-typed client query builder — Zero/TanStack-style ergonomics,
|
|
50
|
+
* Convex-idiomatic. `where` takes a plain typed JS predicate (we refine locally,
|
|
51
|
+
* so no serializable filter DSL is needed); `.related()` attaches related local
|
|
52
|
+
* tables with full type inference. Pass the result to `useLiveQuery`, or run it
|
|
53
|
+
* directly via `engine.runLocalQuery`.
|
|
54
|
+
*/
|
|
55
|
+
export declare class LocalQuery<Row extends Record<string, unknown> = RowValue, Rel = unknown> implements LocalQueryPlan<Row, Rel> {
|
|
56
|
+
readonly table: string;
|
|
57
|
+
private readonly ops;
|
|
58
|
+
readonly __localFirstQuery: true;
|
|
59
|
+
/** Phantom carrier for `Rel` (see LocalQueryPlan.__rel) — `declare` ⇒ no runtime field. */
|
|
60
|
+
readonly __rel: Rel;
|
|
61
|
+
constructor(table: string, ops?: Ops<Row>);
|
|
62
|
+
/** Narrow to a workspace/project scope (typed to the row's scope fields). */
|
|
63
|
+
scope(values: Partial<Row>): LocalQuery<Row, Rel>;
|
|
64
|
+
/** Keep rows matching a typed predicate. Chains as AND. */
|
|
65
|
+
where(predicate: (row: Row) => boolean): LocalQuery<Row, Rel>;
|
|
66
|
+
/** Sort by a field; defaults to ascending. */
|
|
67
|
+
order<K extends keyof Row>(field: K, direction?: "asc" | "desc"): LocalQuery<Row, Rel>;
|
|
68
|
+
/** Cap the number of rows returned. */
|
|
69
|
+
limit(n: number): LocalQuery<Row, Rel>;
|
|
70
|
+
/**
|
|
71
|
+
* Attach a related local table under `name`. `one(...)` yields a single row (or
|
|
72
|
+
* undefined); `many(...)`/`manyToMany(...)` yield an array. Fully typed: the
|
|
73
|
+
* result rows become `Row & { [name]: Target | Target[] }`.
|
|
74
|
+
*/
|
|
75
|
+
related<Name extends string, Target extends Record<string, unknown>, Many extends boolean>(name: Name, spec: RelationSpec<Target, Many>): LocalQuery<Row, Rel & {
|
|
76
|
+
[K in Name]: Many extends true ? Target[] : Target | undefined;
|
|
77
|
+
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Attach a whole map of named relations at once — the lazy path for the common
|
|
80
|
+
* case where relations belong to the model, not the query. Define the map once
|
|
81
|
+
* (next to your row type) and reuse it everywhere:
|
|
82
|
+
*
|
|
83
|
+
* const issueRelations = {
|
|
84
|
+
* project: one<Doc<"projects">>("projects", "projectId"),
|
|
85
|
+
* comments: many<Doc<"comments">>("comments", "issueId"),
|
|
86
|
+
* labels: manyToMany<Doc<"labels">>("labels", "issue_labels", "issueId", "labelId")
|
|
87
|
+
* };
|
|
88
|
+
* collection<Issue>("issues").scope({ workspaceId }).withRelations(issueRelations)
|
|
89
|
+
*
|
|
90
|
+
* Fully typed: result rows become `Row & { project: ...; comments: ...[]; ... }`.
|
|
91
|
+
* Equivalent to chaining `.related(name, spec)` for each entry.
|
|
92
|
+
*/
|
|
93
|
+
withRelations<Specs extends Record<string, RelationSpec>>(specs: Specs): LocalQuery<Row, Rel & RelationsResult<Specs>>;
|
|
94
|
+
get scopeValues(): Record<string, unknown> | undefined;
|
|
95
|
+
get relations(): readonly RelationEntry[];
|
|
96
|
+
run(rows: readonly RowValue[]): Row[];
|
|
97
|
+
private with;
|
|
98
|
+
}
|
|
99
|
+
/** Start a typed local-first query over a table: `collection<Doc<"issues">>("issues")`. */
|
|
100
|
+
export declare function collection<Row extends Record<string, unknown> = RowValue>(table: string): LocalQuery<Row>;
|
|
101
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { compareValues } from "./ordering.js";
|
|
2
|
+
/**
|
|
3
|
+
* Chainable, fully-typed client query builder — Zero/TanStack-style ergonomics,
|
|
4
|
+
* Convex-idiomatic. `where` takes a plain typed JS predicate (we refine locally,
|
|
5
|
+
* so no serializable filter DSL is needed); `.related()` attaches related local
|
|
6
|
+
* tables with full type inference. Pass the result to `useLiveQuery`, or run it
|
|
7
|
+
* directly via `engine.runLocalQuery`.
|
|
8
|
+
*/
|
|
9
|
+
export class LocalQuery {
|
|
10
|
+
table;
|
|
11
|
+
ops;
|
|
12
|
+
__localFirstQuery = true;
|
|
13
|
+
constructor(table, ops = { predicates: [], orderDir: "asc", relations: [] }) {
|
|
14
|
+
this.table = table;
|
|
15
|
+
this.ops = ops;
|
|
16
|
+
}
|
|
17
|
+
/** Narrow to a workspace/project scope (typed to the row's scope fields). */
|
|
18
|
+
scope(values) {
|
|
19
|
+
return this.with({ scopeValues: values });
|
|
20
|
+
}
|
|
21
|
+
/** Keep rows matching a typed predicate. Chains as AND. */
|
|
22
|
+
where(predicate) {
|
|
23
|
+
return this.with({ predicates: [...this.ops.predicates, predicate] });
|
|
24
|
+
}
|
|
25
|
+
/** Sort by a field; defaults to ascending. */
|
|
26
|
+
order(field, direction = "asc") {
|
|
27
|
+
return this.with({ orderKey: field, orderDir: direction });
|
|
28
|
+
}
|
|
29
|
+
/** Cap the number of rows returned. */
|
|
30
|
+
limit(n) {
|
|
31
|
+
return this.with({ limitN: n });
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Attach a related local table under `name`. `one(...)` yields a single row (or
|
|
35
|
+
* undefined); `many(...)`/`manyToMany(...)` yield an array. Fully typed: the
|
|
36
|
+
* result rows become `Row & { [name]: Target | Target[] }`.
|
|
37
|
+
*/
|
|
38
|
+
related(name, spec) {
|
|
39
|
+
return new LocalQuery(this.table, {
|
|
40
|
+
...this.ops,
|
|
41
|
+
relations: [...this.ops.relations, { name, spec }]
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Attach a whole map of named relations at once — the lazy path for the common
|
|
46
|
+
* case where relations belong to the model, not the query. Define the map once
|
|
47
|
+
* (next to your row type) and reuse it everywhere:
|
|
48
|
+
*
|
|
49
|
+
* const issueRelations = {
|
|
50
|
+
* project: one<Doc<"projects">>("projects", "projectId"),
|
|
51
|
+
* comments: many<Doc<"comments">>("comments", "issueId"),
|
|
52
|
+
* labels: manyToMany<Doc<"labels">>("labels", "issue_labels", "issueId", "labelId")
|
|
53
|
+
* };
|
|
54
|
+
* collection<Issue>("issues").scope({ workspaceId }).withRelations(issueRelations)
|
|
55
|
+
*
|
|
56
|
+
* Fully typed: result rows become `Row & { project: ...; comments: ...[]; ... }`.
|
|
57
|
+
* Equivalent to chaining `.related(name, spec)` for each entry.
|
|
58
|
+
*/
|
|
59
|
+
withRelations(specs) {
|
|
60
|
+
const entries = Object.keys(specs).map((name) => ({ name, spec: specs[name] }));
|
|
61
|
+
return new LocalQuery(this.table, {
|
|
62
|
+
...this.ops,
|
|
63
|
+
relations: [...this.ops.relations, ...entries]
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
get scopeValues() {
|
|
67
|
+
return this.ops.scopeValues;
|
|
68
|
+
}
|
|
69
|
+
get relations() {
|
|
70
|
+
return this.ops.relations;
|
|
71
|
+
}
|
|
72
|
+
run(rows) {
|
|
73
|
+
const scopeValues = this.ops.scopeValues;
|
|
74
|
+
let out = rows.filter((row) => {
|
|
75
|
+
if (scopeValues) {
|
|
76
|
+
for (const key in scopeValues) {
|
|
77
|
+
if (row[key] !== scopeValues[key])
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return this.ops.predicates.every((predicate) => predicate(row));
|
|
82
|
+
});
|
|
83
|
+
if (this.ops.orderKey !== undefined) {
|
|
84
|
+
const key = this.ops.orderKey;
|
|
85
|
+
const direction = this.ops.orderDir === "desc" ? -1 : 1;
|
|
86
|
+
out = [...out].sort((a, b) => compareValues(a[key], b[key]) * direction);
|
|
87
|
+
}
|
|
88
|
+
if (this.ops.limitN !== undefined) {
|
|
89
|
+
out = out.slice(0, Math.max(0, this.ops.limitN));
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
with(patch) {
|
|
94
|
+
return new LocalQuery(this.table, { ...this.ops, ...patch });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Start a typed local-first query over a table: `collection<Doc<"issues">>("issues")`. */
|
|
98
|
+
export function collection(table) {
|
|
99
|
+
return new LocalQuery(table);
|
|
100
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { LocalMutationDefinition, LocalQueryDefinition } from "./manifest.js";
|
|
2
|
+
import type { JsonValue, RowValue } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Declarative descriptors emitted by codegen. They make the client manifest pure
|
|
5
|
+
* data + generic interpreters (below), so no per-function code is generated and
|
|
6
|
+
* the manifest is browser-safe (no Convex server imports).
|
|
7
|
+
*/
|
|
8
|
+
export type FieldSource = {
|
|
9
|
+
readonly from: "arg";
|
|
10
|
+
readonly arg: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly from: "auth";
|
|
13
|
+
} | {
|
|
14
|
+
readonly from: "now";
|
|
15
|
+
} | {
|
|
16
|
+
readonly from: "const";
|
|
17
|
+
readonly value: JsonValue;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* How a query derives its pull scope. byUser is omitted (the engine derives it
|
|
21
|
+
* from the authed user); workspace/project scopes name the query arg that holds
|
|
22
|
+
* the scope value (e.g. the workspaceId arg).
|
|
23
|
+
*/
|
|
24
|
+
export type DeclarativeScope = {
|
|
25
|
+
readonly kind: "byWorkspace" | "byProject";
|
|
26
|
+
readonly valueArg: string;
|
|
27
|
+
};
|
|
28
|
+
export type DeclarativeQuery = {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly table: string;
|
|
31
|
+
readonly filters: readonly string[];
|
|
32
|
+
readonly orderBy?: string;
|
|
33
|
+
readonly order?: "asc" | "desc";
|
|
34
|
+
readonly initial?: unknown;
|
|
35
|
+
readonly scope?: DeclarativeScope;
|
|
36
|
+
};
|
|
37
|
+
export type DeclarativeInsert = {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly table: string;
|
|
40
|
+
readonly fields: Record<string, FieldSource>;
|
|
41
|
+
};
|
|
42
|
+
export type DeclarativePatch = {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly table: string;
|
|
45
|
+
readonly idArg: string;
|
|
46
|
+
readonly fields: Record<string, FieldSource>;
|
|
47
|
+
};
|
|
48
|
+
export type DeclarativeRemove = {
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly table: string;
|
|
51
|
+
readonly idArg: string;
|
|
52
|
+
};
|
|
53
|
+
export declare function declarativeQuery(descriptor: DeclarativeQuery): LocalQueryDefinition<Record<string, unknown>, RowValue[]>;
|
|
54
|
+
export declare function declarativeInsert(descriptor: DeclarativeInsert): LocalMutationDefinition<Record<string, unknown>>;
|
|
55
|
+
export declare function declarativePatch(descriptor: DeclarativePatch): LocalMutationDefinition<Record<string, unknown>>;
|
|
56
|
+
export declare function declarativeRemove(descriptor: DeclarativeRemove): LocalMutationDefinition<Record<string, unknown>>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { compareValues } from "./ordering.js";
|
|
2
|
+
function resolveField(source, args, ctx) {
|
|
3
|
+
switch (source.from) {
|
|
4
|
+
case "arg":
|
|
5
|
+
return args[source.arg];
|
|
6
|
+
case "auth":
|
|
7
|
+
return ctx.userId;
|
|
8
|
+
case "now":
|
|
9
|
+
return ctx.now;
|
|
10
|
+
case "const":
|
|
11
|
+
return source.value;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function declarativeQuery(descriptor) {
|
|
15
|
+
const definition = {
|
|
16
|
+
kind: "query",
|
|
17
|
+
name: descriptor.name,
|
|
18
|
+
table: descriptor.table,
|
|
19
|
+
initial: descriptor.initial ?? [],
|
|
20
|
+
run(rows, args) {
|
|
21
|
+
let out = rows.filter((row) => descriptor.filters.every((field) => row[field] === args[field]));
|
|
22
|
+
if (descriptor.orderBy) {
|
|
23
|
+
const key = descriptor.orderBy;
|
|
24
|
+
const direction = descriptor.order === "desc" ? -1 : 1;
|
|
25
|
+
out = [...out].sort((a, b) => compareValues(a[key], b[key]) * direction);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
// Workspace/project queries carry their pull scope so the engine pulls (and the
|
|
31
|
+
// server enforces membership on) the right scope. byUser is derived by the engine.
|
|
32
|
+
if (descriptor.scope) {
|
|
33
|
+
const sc = descriptor.scope;
|
|
34
|
+
return {
|
|
35
|
+
...definition,
|
|
36
|
+
scope: (args) => ({ kind: sc.kind, key: `${sc.kind}:${String(args[sc.valueArg])}`, table: descriptor.table })
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return definition;
|
|
40
|
+
}
|
|
41
|
+
export function declarativeInsert(descriptor) {
|
|
42
|
+
return {
|
|
43
|
+
kind: "mutation",
|
|
44
|
+
name: descriptor.name,
|
|
45
|
+
table: descriptor.table,
|
|
46
|
+
plan(args, ctx) {
|
|
47
|
+
const value = {};
|
|
48
|
+
for (const [field, source] of Object.entries(descriptor.fields)) {
|
|
49
|
+
value[field] = resolveField(source, args, ctx);
|
|
50
|
+
}
|
|
51
|
+
return { kind: "insert", table: descriptor.table, id: ctx.localId(descriptor.table), value };
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function declarativePatch(descriptor) {
|
|
56
|
+
return {
|
|
57
|
+
kind: "mutation",
|
|
58
|
+
name: descriptor.name,
|
|
59
|
+
table: descriptor.table,
|
|
60
|
+
plan(args, ctx) {
|
|
61
|
+
const patch = {};
|
|
62
|
+
for (const [field, source] of Object.entries(descriptor.fields)) {
|
|
63
|
+
const resolved = resolveField(source, args, ctx);
|
|
64
|
+
// A partial patch must not clobber fields the caller didn't set: skip an arg
|
|
65
|
+
// that resolved to `undefined` (an absent optional arg). This is what lets ONE
|
|
66
|
+
// `update` mutation with all-optional fields act as a generic partial patch
|
|
67
|
+
// (the seam Plane's `patchIssue(Partial<TIssue>)` needs). `null` is a real
|
|
68
|
+
// value (Plane uses it for "cleared") and still passes through.
|
|
69
|
+
if (resolved !== undefined) {
|
|
70
|
+
patch[field] = resolved;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { kind: "patch", table: descriptor.table, id: String(args[descriptor.idArg]), patch };
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function declarativeRemove(descriptor) {
|
|
78
|
+
return {
|
|
79
|
+
kind: "mutation",
|
|
80
|
+
name: descriptor.name,
|
|
81
|
+
table: descriptor.table,
|
|
82
|
+
plan(args) {
|
|
83
|
+
return { kind: "delete", table: descriptor.table, id: String(args[descriptor.idArg]) };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { LocalQueryPlan } from "./collection.js";
|
|
2
|
+
import { type IdFactory } from "./id.js";
|
|
3
|
+
import type { FunctionNameResolver } from "./functionName.js";
|
|
4
|
+
import type { LocalFirstManifest } from "./manifest.js";
|
|
5
|
+
import { type LocalFirstMutationCall } from "./mutationCall.js";
|
|
6
|
+
import type { LocalStore } from "./storage.js";
|
|
7
|
+
import type { SyncTransport } from "./transport.js";
|
|
8
|
+
import type { RowValue, SyncScope, SyncStatus } from "./types.js";
|
|
9
|
+
export type LocalFirstEngineOptions = {
|
|
10
|
+
readonly manifest: LocalFirstManifest;
|
|
11
|
+
readonly store: LocalStore;
|
|
12
|
+
readonly clientId: string;
|
|
13
|
+
readonly userId?: string | null;
|
|
14
|
+
readonly transport?: SyncTransport;
|
|
15
|
+
readonly nameOf?: FunctionNameResolver;
|
|
16
|
+
readonly idFactory?: IdFactory;
|
|
17
|
+
readonly clock?: () => number;
|
|
18
|
+
/** Network retry policy for background sync. */
|
|
19
|
+
readonly retry?: {
|
|
20
|
+
readonly retries: number;
|
|
21
|
+
readonly baseDelayMs: number;
|
|
22
|
+
};
|
|
23
|
+
/** Injectable delay (tests pass a no-op to avoid real waits). */
|
|
24
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Hard cap (ms) on a single push/pull, so an unreachable server (online but not
|
|
27
|
+
* responding) can't hang sync — or an awaited read — forever. On timeout the call fails
|
|
28
|
+
* fast (ops stay pending). 0 disables. Default 15000. Complements the navigator.onLine
|
|
29
|
+
* guard, which handles a hard OS-offline.
|
|
30
|
+
*/
|
|
31
|
+
readonly syncTimeoutMs?: number;
|
|
32
|
+
};
|
|
33
|
+
export declare class LocalFirstEngine {
|
|
34
|
+
readonly manifest: LocalFirstManifest;
|
|
35
|
+
private readonly store;
|
|
36
|
+
readonly clientId: string;
|
|
37
|
+
private readonly userId;
|
|
38
|
+
private readonly transport;
|
|
39
|
+
private readonly nameOf;
|
|
40
|
+
private readonly idFactory;
|
|
41
|
+
private readonly clock;
|
|
42
|
+
private readonly retry;
|
|
43
|
+
private readonly syncTimeoutMs;
|
|
44
|
+
private readonly sleep;
|
|
45
|
+
private status;
|
|
46
|
+
private readonly opStatuses;
|
|
47
|
+
private readonly statusListeners;
|
|
48
|
+
private readonly scopeWatchers;
|
|
49
|
+
private disposeConnectivity;
|
|
50
|
+
private syncEnabled;
|
|
51
|
+
private tsHighWater;
|
|
52
|
+
constructor(options: LocalFirstEngineOptions);
|
|
53
|
+
/** Wall-clock timestamp that never goes backward within this engine, so a backward
|
|
54
|
+
* clock step cannot reorder two local edits (I4). */
|
|
55
|
+
private monotonicNow;
|
|
56
|
+
/** Seed the high-water from durable ops so monotonic order holds across reloads.
|
|
57
|
+
* ponytail: an op created before this async seed resolves could predate it — needs a
|
|
58
|
+
* backward clock step AND reload AND a same-row edit in that window; acceptable. */
|
|
59
|
+
private seedTimestampHighWater;
|
|
60
|
+
/** Merge a status patch and notify subscribers so useSyncStatus re-renders. */
|
|
61
|
+
private setStatus;
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a function reference to its stable name. The React adapter keys effects on
|
|
64
|
+
* this string, not the reference object, because Convex's `api` proxy returns a fresh
|
|
65
|
+
* object per access — keying on identity would re-run the effect every render (a sync loop).
|
|
66
|
+
*/
|
|
67
|
+
functionName(reference: unknown): string | null;
|
|
68
|
+
/**
|
|
69
|
+
* Run a transport call and reflect connectivity in status.online (threw → offline,
|
|
70
|
+
* returned → online). ponytail: heuristic — a server-side error also reads as offline;
|
|
71
|
+
* the navigator online/offline events give finer detection.
|
|
72
|
+
*/
|
|
73
|
+
private tracked;
|
|
74
|
+
hasLocalQuery(reference: unknown): boolean;
|
|
75
|
+
hasLocalMutation(reference: unknown): boolean;
|
|
76
|
+
query<TArgs, TResult>(reference: unknown, args: TArgs): Promise<TResult | undefined>;
|
|
77
|
+
/**
|
|
78
|
+
* Keep only rows in the active scope (owner==userId for byUser, field==value for
|
|
79
|
+
* byWorkspace/byProject). The client caches every scope the user can see, so a query
|
|
80
|
+
* with an incomplete filter could otherwise observe another scope's rows; enforcing it
|
|
81
|
+
* here mirrors the server's I7. `custom` scopes have no client-known field → server-only.
|
|
82
|
+
*/
|
|
83
|
+
private filterToScope;
|
|
84
|
+
/** True when `table` is workspace/project-scoped but `args` carry no scope value. */
|
|
85
|
+
private scopedQueryMissingScope;
|
|
86
|
+
/**
|
|
87
|
+
* @internal All visible (non-deleted) rows for a table, from the derived view
|
|
88
|
+
* (I1). UNSCOPED plumbing for useLiveQuery's subscription — the hook only ever
|
|
89
|
+
* returns these through `applyLocalQuery` (the scoped guard). Not an app API.
|
|
90
|
+
*/
|
|
91
|
+
tableRows(table: string): Promise<readonly RowValue[]>;
|
|
92
|
+
/** Every table a plan reads: its base table plus any relation targets/join tables. */
|
|
93
|
+
tablesForPlan(plan: LocalQueryPlan): string[];
|
|
94
|
+
/**
|
|
95
|
+
* Apply a query plan to already-fetched rows (keyed by table), enforcing the
|
|
96
|
+
* scoped fail-closed guard and attaching relations in memory. Synchronous so the
|
|
97
|
+
* React hook (useLiveQuery) can call it at render and cannot bypass the guard by
|
|
98
|
+
* running plan.run directly.
|
|
99
|
+
*/
|
|
100
|
+
applyLocalQuery<Row extends Record<string, unknown>, Rel>(plan: LocalQueryPlan<Row, Rel>, rowsByTable: Record<string, readonly RowValue[]>): Array<Row & Rel>;
|
|
101
|
+
runLocalQuery<Row extends Record<string, unknown>, Rel>(plan: LocalQueryPlan<Row, Rel>): Promise<Array<Row & Rel>>;
|
|
102
|
+
/**
|
|
103
|
+
* One-call imperative read for the service-layer path: background-refresh the plan's
|
|
104
|
+
* scope (offline-safe, never throws), then return the merged local rows (canonical +
|
|
105
|
+
* pending). Prefer over hand-orchestrating refreshPlan + runLocalQuery. For reactive UI
|
|
106
|
+
* use useLiveQuery instead.
|
|
107
|
+
*/
|
|
108
|
+
read<Row extends Record<string, unknown>, Rel>(plan: LocalQueryPlan<Row, Rel>): Promise<Array<Row & Rel>>;
|
|
109
|
+
/**
|
|
110
|
+
* Read a single live row by id (== row[idField] == _id), or undefined. Local-only, no
|
|
111
|
+
* server pull — for the "I just wrote id X, read it back" case (the write already flushes
|
|
112
|
+
* via its own .server push). Includes pending optimistic state. For a possibly-cold row
|
|
113
|
+
* (e.g. a deep link), use a scoped query so refreshPlan can pull it first.
|
|
114
|
+
*/
|
|
115
|
+
getRow<Row extends Record<string, unknown>>(table: string, id: string): Promise<Row | undefined>;
|
|
116
|
+
/**
|
|
117
|
+
* Pull scope for a query plan: the explicit workspace/project value when the
|
|
118
|
+
* table is scoped that way, else the authed user. Key format mirrors the
|
|
119
|
+
* declarative path so pull cursors and server membership checks line up.
|
|
120
|
+
*/
|
|
121
|
+
scopeForPlan(plan: LocalQueryPlan): SyncScope | null;
|
|
122
|
+
/** Background sync for a mounted plan (push pending + pull its scope). Never throws. */
|
|
123
|
+
refreshPlan(plan: LocalQueryPlan): Promise<void>;
|
|
124
|
+
/** True when the transport offers a reactive change feed (server push). When
|
|
125
|
+
* false, callers fall back to polling for real-time. */
|
|
126
|
+
get reactive(): boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Reactive sync for a mounted plan: subscribe to the transport's change feed for
|
|
129
|
+
* this plan's scope and drain (pull) on every server-side change — true server
|
|
130
|
+
* push, no polling. Returns an unsubscribe, or `null` when the transport is not
|
|
131
|
+
* reactive (the caller should fall back to polling) or the plan has no scope.
|
|
132
|
+
*/
|
|
133
|
+
watchPlan(plan: LocalQueryPlan): (() => void) | null;
|
|
134
|
+
/**
|
|
135
|
+
* Reactive sync for a declarative (server-defined) query — the `useQuery` path. Like
|
|
136
|
+
* `watchPlan` but resolves the scope from the query DEFINITION. Returns an unsubscribe,
|
|
137
|
+
* or `null` when not reactive / no scope. This is what makes our `useQuery` reactive
|
|
138
|
+
* like `convex/react`'s.
|
|
139
|
+
*/
|
|
140
|
+
watchQuery<TArgs>(reference: unknown, args: TArgs): (() => void) | null;
|
|
141
|
+
/**
|
|
142
|
+
* Refcounted entry point: many hooks watching the SAME scope share ONE watch + drain
|
|
143
|
+
* loop (started on the first watcher, torn down on the last). Returns an idempotent unwatch.
|
|
144
|
+
*/
|
|
145
|
+
private watchScope;
|
|
146
|
+
/**
|
|
147
|
+
* Drive one scope's subscription. The doorbell carries no data, so each fire triggers a
|
|
148
|
+
* real `pullScopes` drain, then re-subscribes at the advanced cursor: a fixed-cursor
|
|
149
|
+
* watch grows until it saturates the page limit and goes deaf, so re-pinning keeps the
|
|
150
|
+
* window small. Only resubscribing when the cursor moved avoids an empty-fire loop.
|
|
151
|
+
*/
|
|
152
|
+
private startScopeWatch;
|
|
153
|
+
/** Subscribe to local DATA changes (rows). Used by useQuery. */
|
|
154
|
+
subscribe(listener: () => void): () => void;
|
|
155
|
+
/** Subscribe to SYNC STATUS changes (online/syncing/pending). Used by useSyncStatus. */
|
|
156
|
+
subscribeStatus(listener: () => void): () => void;
|
|
157
|
+
mutate<TArgs, TResult = unknown>(reference: unknown, args: TArgs): LocalFirstMutationCall<TResult>;
|
|
158
|
+
syncOnce(scopes?: readonly SyncScope[]): Promise<void>;
|
|
159
|
+
getStatus(): SyncStatus;
|
|
160
|
+
/** Reflect externally-known connectivity (e.g. the browser's online/offline events). */
|
|
161
|
+
setOnline(online: boolean): void;
|
|
162
|
+
/**
|
|
163
|
+
* Self-wire browser connectivity so offline-first works with zero consumer setup: going
|
|
164
|
+
* offline makes sync a no-op (so reads/writes don't hang on a buffering socket), and
|
|
165
|
+
* reconnect flushes the outbox. Returns a remover; a noop outside a browser. Safe to
|
|
166
|
+
* double-wire with the React provider (setOnline is idempotent; flushPending dedupes).
|
|
167
|
+
*/
|
|
168
|
+
private wireConnectivity;
|
|
169
|
+
/** Remove engine-owned browser listeners. Optional: a singleton engine that lives
|
|
170
|
+
* for the page lifetime need not call this; provided for tests / teardown. */
|
|
171
|
+
dispose(): void;
|
|
172
|
+
/**
|
|
173
|
+
* A HARD offline signal only (navigator.onLine === false), where a push/pull would just
|
|
174
|
+
* hang on a buffering client. Deliberately NOT gated on the softer status.online, which a
|
|
175
|
+
* transient server error can flip false — that would wedge sync off while genuinely online.
|
|
176
|
+
*/
|
|
177
|
+
private isLikelyOffline;
|
|
178
|
+
/**
|
|
179
|
+
* Multi-tab leadership gate (wired by the React provider). Only the leader runs the
|
|
180
|
+
* background batch push; a follower keeps pulling but doesn't re-push the shared outbox.
|
|
181
|
+
* On regaining leadership we flush immediately so an inherited backlog isn't stranded.
|
|
182
|
+
*/
|
|
183
|
+
setSyncEnabled(enabled: boolean): void;
|
|
184
|
+
/**
|
|
185
|
+
* Explicit, UN-gated push of the outbox (reconnect flush, leadership handoff, or a
|
|
186
|
+
* cross-tab wake). Distinct from the background push so an offline-created op in a
|
|
187
|
+
* follower tab is not stranded waiting for the leader's next trigger. Never throws.
|
|
188
|
+
*/
|
|
189
|
+
flushPending(): void;
|
|
190
|
+
/**
|
|
191
|
+
* Cross-tab "db changed" poke: IndexedDB has no cross-tab change event, so when the
|
|
192
|
+
* leader pulls into the shared DB, follower tabs are told to re-derive. Safe to over-call
|
|
193
|
+
* (applyServerChanges is version-folded, so a re-read only surfaces equal-or-newer rows).
|
|
194
|
+
*/
|
|
195
|
+
pokeLocalChange(): void;
|
|
196
|
+
/**
|
|
197
|
+
* Background sync triggered by a mounted query: push pending ops and pull this
|
|
198
|
+
* query's scope (if the definition declares one). Never throws — failures are
|
|
199
|
+
* recorded in status.lastError for the UI.
|
|
200
|
+
*/
|
|
201
|
+
refreshQuery<TArgs>(reference: unknown, args: TArgs): Promise<void>;
|
|
202
|
+
private scopeForTable;
|
|
203
|
+
private getQueryDefinition;
|
|
204
|
+
private getMutationDefinition;
|
|
205
|
+
private safeName;
|
|
206
|
+
/**
|
|
207
|
+
* For a patch on a table with declared `setFields`/`counterFields`, rewrite each touched
|
|
208
|
+
* field into a DELTA vs the row's current value, so concurrent edits merge (see setMerge.ts)
|
|
209
|
+
* instead of clobbering: arrays → set deltas, numbers → counter deltas. Runs before the op
|
|
210
|
+
* is persisted/pushed. No-op for non-patches, undeclared fields, or wrong-typed/already-delta values.
|
|
211
|
+
*/
|
|
212
|
+
private applyFieldDeltas;
|
|
213
|
+
private commitLocal;
|
|
214
|
+
private markStatus;
|
|
215
|
+
private pushSingleOperation;
|
|
216
|
+
private pushPendingOperations;
|
|
217
|
+
private pullScopes;
|
|
218
|
+
private blockForSchemaMismatch;
|
|
219
|
+
/**
|
|
220
|
+
* Bound a transport call so an unreachable server can't hang sync forever. Races fn()
|
|
221
|
+
* against a timer (cleared on settle, unref'd so it can't keep a process alive).
|
|
222
|
+
* syncTimeoutMs <= 0 disables.
|
|
223
|
+
*/
|
|
224
|
+
private withTimeout;
|
|
225
|
+
/** Retry a network call with exponential backoff. */
|
|
226
|
+
private withRetry;
|
|
227
|
+
private operationStatus;
|
|
228
|
+
private refreshPendingCount;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Headless engine factory — build an engine outside React for imperative consumers (a
|
|
232
|
+
* service layer, a MobX/Zustand store, a worker). The same instance can be passed to the
|
|
233
|
+
* React `ConvexProvider` (its `localFirst.engine` option) to share one engine/outbox/cache.
|
|
234
|
+
* Reads: `query`/`runLocalQuery` (scope-enforced); writes: `mutate`; `subscribe` fires on
|
|
235
|
+
* every local data change.
|
|
236
|
+
*/
|
|
237
|
+
export declare function createLocalFirstEngine(options: LocalFirstEngineOptions): LocalFirstEngine;
|