@convex-localfirst/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/collection.d.ts +101 -0
  4. package/dist/collection.js +100 -0
  5. package/dist/declarative.d.ts +56 -0
  6. package/dist/declarative.js +86 -0
  7. package/dist/engine.d.ts +237 -0
  8. package/dist/engine.js +934 -0
  9. package/dist/functionName.d.ts +3 -0
  10. package/dist/functionName.js +15 -0
  11. package/dist/id.d.ts +5 -0
  12. package/dist/id.js +22 -0
  13. package/dist/index.d.ts +14 -0
  14. package/dist/index.js +27 -0
  15. package/dist/indexedDbStore.d.ts +53 -0
  16. package/dist/indexedDbStore.js +328 -0
  17. package/dist/internal.d.ts +12 -0
  18. package/dist/internal.js +22 -0
  19. package/dist/leadership.d.ts +48 -0
  20. package/dist/leadership.js +69 -0
  21. package/dist/manifest.d.ts +84 -0
  22. package/dist/manifest.js +28 -0
  23. package/dist/memoryStore.d.ts +33 -0
  24. package/dist/memoryStore.js +130 -0
  25. package/dist/multiTab.d.ts +69 -0
  26. package/dist/multiTab.js +96 -0
  27. package/dist/mutationCall.d.ts +20 -0
  28. package/dist/mutationCall.js +40 -0
  29. package/dist/ordering.d.ts +14 -0
  30. package/dist/ordering.js +35 -0
  31. package/dist/rebase.d.ts +14 -0
  32. package/dist/rebase.js +54 -0
  33. package/dist/relations.d.ts +42 -0
  34. package/dist/relations.js +89 -0
  35. package/dist/setMerge.d.ts +63 -0
  36. package/dist/setMerge.js +93 -0
  37. package/dist/status.d.ts +2 -0
  38. package/dist/status.js +10 -0
  39. package/dist/storage.d.ts +53 -0
  40. package/dist/storage.js +1 -0
  41. package/dist/transport.d.ts +43 -0
  42. package/dist/transport.js +93 -0
  43. package/dist/types.d.ts +173 -0
  44. package/dist/types.js +1 -0
  45. package/dist/view.d.ts +12 -0
  46. package/dist/view.js +74 -0
  47. package/package.json +42 -0
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
+ }
@@ -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;