@convex-localfirst/server 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 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,26 @@
1
+ # @convex-localfirst/server
2
+
3
+ The Convex-side DSL for local-first tables. Declare which tables are local-first with
4
+ `lf.table`, and generate the sync `push`/`pull` functions with `createSyncFunctions` — the
5
+ server enforces scope, ownership, membership, and idempotency. Convex stays authoritative.
6
+
7
+ ```bash
8
+ npm install @convex-localfirst/server
9
+ ```
10
+
11
+ ```ts
12
+ import { lf } from "./localfirst";
13
+ import { v } from "convex/values";
14
+
15
+ const todos = lf.table("todos", {
16
+ scope: lf.byUser("ownerId"),
17
+ idField: "localId",
18
+ conflict: lf.fieldLww(),
19
+ indexes: { byList: ["ownerId", "listId", "createdAt"] }
20
+ });
21
+
22
+ export const list = todos.query({ args: { listId: v.string() }, index: "byList", initial: [] });
23
+ export const create = todos.insert({ args: { text: v.string() }, value: ({ auth, args, now }) => ({ ... }) });
24
+ ```
25
+
26
+ Peer dependency: `convex`. MIT
@@ -0,0 +1,58 @@
1
+ import type { RegisteredMutation, RegisteredQuery } from "convex/server";
2
+ import type { SyncConfig } from "./serverSync.js";
3
+ type AnyCtx = any;
4
+ type ComponentApi = any;
5
+ /** App-supplied membership check (the server decides, never the client — I7). */
6
+ export type IsMember = (ctx: AnyCtx, info: {
7
+ userId: string;
8
+ scopeValue: string;
9
+ membershipTable: string;
10
+ }) => Promise<boolean>;
11
+ export type CreateSyncFunctionsOptions = {
12
+ /** `components.convexLocalFirst` from your app's generated api. */
13
+ readonly component: ComponentApi;
14
+ /** `mutation` / `query` from your app's `_generated/server`. */
15
+ readonly mutation: (definition: {
16
+ args: any;
17
+ handler: (ctx: AnyCtx, args: any) => any;
18
+ }) => any;
19
+ readonly query: (definition: {
20
+ args: any;
21
+ handler: (ctx: AnyCtx, args: any) => any;
22
+ }) => any;
23
+ /** Which app tables are local-first, and how they are scoped. */
24
+ readonly tables: SyncConfig["tables"];
25
+ readonly schemaVersion?: number;
26
+ readonly now?: () => number;
27
+ /** Required if any table is scoped `byWorkspace` / `byProject`. */
28
+ readonly isMember?: IsMember;
29
+ /**
30
+ * UNSAFE. When Convex auth returns no identity, trust the client-supplied
31
+ * `userId` instead of failing closed. Only for a local demo backend with no
32
+ * auth provider — NEVER in production (any client could read another user's
33
+ * data, violating I7). Defaults to false (fail closed).
34
+ */
35
+ readonly devUnsafeAllowClientUserId?: boolean;
36
+ };
37
+ /** The two endpoints local-first clients talk to. Opaque to app code — they are
38
+ * driven by the client transport, not called via `useMutation`/`useQuery`. */
39
+ export type SyncFunctions = {
40
+ readonly push: RegisteredMutation<"public", Record<string, unknown>, unknown>;
41
+ readonly pull: RegisteredQuery<"public", Record<string, unknown>, unknown>;
42
+ };
43
+ /**
44
+ * Compose the `push` mutation + `pull` query that local-first clients sync
45
+ * against. App rows go to `ctx.db`; every sync bookkeeping operation is
46
+ * delegated to the mounted component. This replaces ~150 lines of hand-written
47
+ * `ServerStore` wiring with a single call.
48
+ *
49
+ * ```ts
50
+ * export const { push, pull } = createSyncFunctions({
51
+ * component: components.convexLocalFirst,
52
+ * mutation, query,
53
+ * tables: { todos: { scope: { kind: "byUser", field: "ownerId" }, idField: "localId", conflict: "fieldLww" } }
54
+ * });
55
+ * ```
56
+ */
57
+ export declare function createSyncFunctions(options: CreateSyncFunctionsOptions): SyncFunctions;
58
+ export {};
@@ -0,0 +1,241 @@
1
+ import { convexToJson, jsonToConvex, v } from "convex/values";
2
+ import { handlePull, handlePush } from "./serverSync.js";
3
+ // Lossless Convex-value <-> JSON-string codec for the component's text columns.
4
+ // convexToJson/jsonToConvex preserve bigint/bytes/nested-undefined where JSON.stringify
5
+ // would throw or change shape; plain JSON values pass through unchanged (back-compatible
6
+ // with rows written before this codec existed). Top-level undefined is not a Convex
7
+ // value, so it maps to null (call sites only encode present values anyway).
8
+ const valueCodec = {
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ encode: (value) => JSON.stringify(convexToJson((value === undefined ? null : value))),
11
+ decode: (json) => jsonToConvex(JSON.parse(json))
12
+ };
13
+ function storedFromComponent(r) {
14
+ return {
15
+ changeId: r.changeId,
16
+ scopeKey: r.scopeKey,
17
+ table: r.table,
18
+ localId: r.localId,
19
+ kind: r.kind,
20
+ data: r.dataJson ? valueCodec.decode(r.dataJson) : undefined,
21
+ patch: r.patchJson ? valueCodec.decode(r.patchJson) : undefined,
22
+ version: r.version,
23
+ serverTime: r.serverTime,
24
+ opId: r.opId
25
+ };
26
+ }
27
+ /**
28
+ * Compose the `push` mutation + `pull` query that local-first clients sync
29
+ * against. App rows go to `ctx.db`; every sync bookkeeping operation is
30
+ * delegated to the mounted component. This replaces ~150 lines of hand-written
31
+ * `ServerStore` wiring with a single call.
32
+ *
33
+ * ```ts
34
+ * export const { push, pull } = createSyncFunctions({
35
+ * component: components.convexLocalFirst,
36
+ * mutation, query,
37
+ * tables: { todos: { scope: { kind: "byUser", field: "ownerId" }, idField: "localId", conflict: "fieldLww" } }
38
+ * });
39
+ * ```
40
+ */
41
+ export function createSyncFunctions(options) {
42
+ const lf = options.component;
43
+ const config = {
44
+ schemaVersion: options.schemaVersion ?? 1,
45
+ now: options.now ?? (() => Date.now()),
46
+ tables: options.tables,
47
+ valueCodec
48
+ };
49
+ // I7: the pull side resolves ONE membership table per scope kind (scope keys
50
+ // are per-value, shared across every table of that kind). Mixed membership
51
+ // tables within a kind would let a member of one pull another's rows — forbid
52
+ // that ambiguity at config time rather than fail silently insecure.
53
+ for (const kind of ["byWorkspace", "byProject"]) {
54
+ const membershipTables = new Set(Object.values(options.tables)
55
+ .filter((t) => t.scope.kind === kind)
56
+ .map((t) => t.scope.membershipTable));
57
+ if (membershipTables.size > 1) {
58
+ throw new Error(`createSyncFunctions: all ${kind} tables must share one membershipTable (found: ${[...membershipTables].join(", ")}). Scope keys are shared per value, so mixed membership tables would cross-authorize reads.`);
59
+ }
60
+ }
61
+ const needsMembership = Object.values(options.tables).some((t) => t.scope.kind === "byWorkspace" || t.scope.kind === "byProject");
62
+ const isMember = options.isMember ??
63
+ (async () => {
64
+ if (needsMembership) {
65
+ throw new Error("createSyncFunctions: a byWorkspace/byProject table requires an `isMember` callback (the server must decide membership — I7).");
66
+ }
67
+ return true;
68
+ });
69
+ // Push-side store: app rows hit ctx.db; all bookkeeping goes to the component.
70
+ function pushStore(ctx) {
71
+ return {
72
+ async getRow(_table, serverId) {
73
+ return (await ctx.db.get(serverId)) ?? null;
74
+ },
75
+ async insertRow(table, data) {
76
+ return await ctx.db.insert(table, data);
77
+ },
78
+ async patchRow(_table, serverId, patch) {
79
+ await ctx.db.patch(serverId, patch);
80
+ },
81
+ async deleteRow(_table, serverId) {
82
+ await ctx.db.delete(serverId);
83
+ },
84
+ async getLedger(userId, opId) {
85
+ return await ctx.runQuery(lf.ops.getByOpId, { userId, opId });
86
+ },
87
+ async putLedger(userId, clientId, op, entry) {
88
+ await ctx.runMutation(lf.ops.record, {
89
+ userId,
90
+ clientId,
91
+ opId: op.opId,
92
+ schemaVersion: op.schemaVersion,
93
+ functionName: op.functionName,
94
+ table: op.table,
95
+ localId: op.localId,
96
+ status: entry.status,
97
+ argsJson: valueCodec.encode(op.value ?? op.patch ?? {}),
98
+ operationJson: valueCodec.encode(op),
99
+ resultJson: entry.resultJson,
100
+ changesJson: entry.changesJson,
101
+ error: entry.error,
102
+ committedAt: config.now ? config.now() : Date.now()
103
+ });
104
+ },
105
+ async getServerId(table, localId) {
106
+ return await ctx.runQuery(lf.idMaps.get, { table, localId });
107
+ },
108
+ async putIdMap(userId, table, localId, serverId) {
109
+ await ctx.runMutation(lf.idMaps.put, { userId, table, localId, serverId });
110
+ },
111
+ async appendChange(change) {
112
+ return await ctx.runMutation(lf.changes.append, {
113
+ scopeKey: change.scopeKey,
114
+ table: change.table,
115
+ localId: change.localId,
116
+ kind: change.kind,
117
+ dataJson: change.data ? valueCodec.encode(change.data) : undefined,
118
+ patchJson: change.patch ? valueCodec.encode(change.patch) : undefined,
119
+ version: change.version,
120
+ serverTime: change.serverTime,
121
+ opId: change.opId
122
+ });
123
+ },
124
+ async changesAfter(scopeKey, cursor, limit) {
125
+ const rows = await ctx.runQuery(lf.changes.listAfter, { scopeKey, cursor: cursor ?? undefined, limit });
126
+ return rows.map(storedFromComponent);
127
+ },
128
+ async latestChangeVersion(table, localId) {
129
+ return await ctx.runQuery(lf.changes.latestVersion, { table, localId });
130
+ },
131
+ async scopeForLocalId(table, localId) {
132
+ return await ctx.runQuery(lf.changes.scopeForLocal, { table, localId });
133
+ },
134
+ // Per-field write clocks for `timestampLww` tables. clocks is a plain JSON map
135
+ // (field -> {ts, tiebreaker}: number + string), so JSON.stringify/parse is lossless —
136
+ // no valueCodec needed. Absent these two, serverSync rejects a timestamped timestampLww
137
+ // op (loud) rather than silently degrading to arrival-order.
138
+ async getFieldClocks(table, localId) {
139
+ const json = await ctx.runQuery(lf.fieldClocks.get, { table, localId });
140
+ return json ? JSON.parse(json) : {};
141
+ },
142
+ async putFieldClocks(table, localId, clocks) {
143
+ await ctx.runMutation(lf.fieldClocks.put, { table, localId, clocksJson: JSON.stringify(clocks) });
144
+ },
145
+ async isMember(userId, scopeValue, membershipTable) {
146
+ return await isMember(ctx, { userId, scopeValue, membershipTable });
147
+ }
148
+ };
149
+ }
150
+ // Pull-side store: read-only, so only the change log + membership are wired.
151
+ function pullStore(ctx) {
152
+ const unsupported = () => {
153
+ throw new Error("pull is read-only");
154
+ };
155
+ return {
156
+ getRow: unsupported,
157
+ insertRow: unsupported,
158
+ patchRow: unsupported,
159
+ deleteRow: unsupported,
160
+ getLedger: unsupported,
161
+ putLedger: unsupported,
162
+ getServerId: unsupported,
163
+ putIdMap: unsupported,
164
+ appendChange: unsupported,
165
+ latestChangeVersion: unsupported,
166
+ scopeForLocalId: unsupported,
167
+ async changesAfter(scopeKey, cursor, limit) {
168
+ const rows = await ctx.runQuery(lf.changes.listAfter, { scopeKey, cursor: cursor ?? undefined, limit });
169
+ return rows.map(storedFromComponent);
170
+ },
171
+ async isMember(userId, scopeValue, membershipTable) {
172
+ return await isMember(ctx, { userId, scopeValue, membershipTable });
173
+ }
174
+ };
175
+ }
176
+ const mutationFields = {
177
+ opId: v.string(),
178
+ clientId: v.string(),
179
+ schemaVersion: v.number(),
180
+ functionName: v.string(),
181
+ table: v.string(),
182
+ kind: v.union(v.literal("insert"), v.literal("patch"), v.literal("delete")),
183
+ localId: v.string(),
184
+ value: v.optional(v.any()),
185
+ patch: v.optional(v.any()),
186
+ // The op's logical timestamp (client monotonic clock). Optional — only `timestampLww`
187
+ // tables read it; older clients that omit it fall back to arrival-order LWW.
188
+ timestamp: v.optional(v.number())
189
+ };
190
+ async function resolveUserId(ctx, fallback) {
191
+ // I7: identity comes from auth, never the client. Fail closed when there is
192
+ // no authenticated identity, unless the app explicitly opts into the unsafe
193
+ // demo fallback (a local backend with no auth provider).
194
+ const identity = await ctx.auth.getUserIdentity();
195
+ if (identity?.subject) {
196
+ return identity.subject;
197
+ }
198
+ if (options.devUnsafeAllowClientUserId) {
199
+ return fallback;
200
+ }
201
+ throw new Error("convex-localfirst: no authenticated identity. Configure Convex auth, or set devUnsafeAllowClientUserId: true for a local demo backend (unsafe).");
202
+ }
203
+ const push = options.mutation({
204
+ args: {
205
+ clientId: v.string(),
206
+ userId: v.string(),
207
+ schemaVersion: v.number(),
208
+ mutations: v.array(v.object(mutationFields))
209
+ },
210
+ handler: async (ctx, args) => {
211
+ const userId = await resolveUserId(ctx, args.userId);
212
+ return await handlePush(pushStore(ctx), config, {
213
+ userId,
214
+ clientId: args.clientId,
215
+ schemaVersion: args.schemaVersion,
216
+ mutations: args.mutations
217
+ });
218
+ }
219
+ });
220
+ const pull = options.query({
221
+ args: {
222
+ clientId: v.string(),
223
+ userId: v.string(),
224
+ schemaVersion: v.number(),
225
+ scopes: v.array(v.object({ kind: v.string(), value: v.optional(v.string()) })),
226
+ cursors: v.any()
227
+ },
228
+ handler: async (ctx, args) => {
229
+ const userId = await resolveUserId(ctx, args.userId);
230
+ return await handlePull(pullStore(ctx), config, {
231
+ userId,
232
+ clientId: args.clientId,
233
+ schemaVersion: args.schemaVersion,
234
+ scopes: args.scopes,
235
+ cursors: args.cursors ?? {}
236
+ });
237
+ }
238
+ });
239
+ return { push, pull };
240
+ }
241
+ /* eslint-enable @typescript-eslint/no-explicit-any */
@@ -0,0 +1,107 @@
1
+ import type { DataModelFromSchemaDefinition, DocumentByName, GenericSchema, RegisteredMutation, RegisteredQuery, SchemaDefinition, TableNamesInDataModel } from "convex/server";
2
+ import type { ObjectType, PropertyValidators } from "convex/values";
3
+ import type { ConflictPolicyName, ScopeDefinition } from "@convex-localfirst/core";
4
+ import type { ServerTableConfig } from "./serverSync.js";
5
+ export * from "./serverSync.js";
6
+ export * from "./createSyncFunctions.js";
7
+ export type CreateLocalFirstOptions<Schema extends SchemaDefinition<GenericSchema, boolean>> = {
8
+ readonly schema: Schema;
9
+ readonly defaults?: {
10
+ readonly idField?: string;
11
+ readonly conflict?: ConflictPolicyName;
12
+ };
13
+ };
14
+ export type TableOptions = {
15
+ readonly scope: ScopeDefinition;
16
+ readonly idField?: string;
17
+ readonly conflict?: ConflictPolicyName;
18
+ readonly indexes?: Record<string, readonly string[]>;
19
+ readonly setFields?: readonly string[];
20
+ readonly counterFields?: readonly string[];
21
+ };
22
+ export type QuerySpec<A extends PropertyValidators, Row> = {
23
+ readonly args: A;
24
+ readonly index: string;
25
+ readonly key: (input: {
26
+ auth: {
27
+ userId: string;
28
+ };
29
+ args: ObjectType<A>;
30
+ }) => readonly unknown[];
31
+ readonly order?: "asc" | "desc";
32
+ readonly initial?: Row[];
33
+ };
34
+ export type InsertSpec<A extends PropertyValidators, Ctx> = {
35
+ readonly args: A;
36
+ readonly value: (input: {
37
+ ctx: Ctx;
38
+ auth: {
39
+ userId: string;
40
+ };
41
+ args: ObjectType<A>;
42
+ now: number;
43
+ localId: string;
44
+ }) => Record<string, unknown>;
45
+ };
46
+ export type PatchSpec<A extends PropertyValidators, Ctx> = {
47
+ readonly args: A;
48
+ readonly id?: (input: {
49
+ args: ObjectType<A>;
50
+ }) => string;
51
+ readonly patch?: (input: {
52
+ ctx: Ctx;
53
+ auth: {
54
+ userId: string;
55
+ };
56
+ args: ObjectType<A>;
57
+ now: number;
58
+ }) => Record<string, unknown>;
59
+ };
60
+ export type RemoveSpec<A extends PropertyValidators> = {
61
+ readonly args: A;
62
+ readonly id?: (input: {
63
+ args: ObjectType<A>;
64
+ }) => string;
65
+ };
66
+ export declare function createLocalFirst<Ctx = unknown, Schema extends SchemaDefinition<GenericSchema, boolean> = SchemaDefinition<GenericSchema, boolean>>(options: CreateLocalFirstOptions<Schema>): {
67
+ byUser(field: string): ScopeDefinition;
68
+ byWorkspace(input: {
69
+ workspaceIdField: string;
70
+ membershipTable: string;
71
+ }): ScopeDefinition;
72
+ byProject(input: {
73
+ projectIdField: string;
74
+ membershipTable: string;
75
+ }): ScopeDefinition;
76
+ fieldLww(): ConflictPolicyName;
77
+ /** Timestamp-ordered LWW: a scalar field-write carries the op's logical timestamp +
78
+ * clientId tiebreaker, so a NEWER edit wins regardless of arrival order (the offline-first
79
+ * fix). Set/counter delta fields stay convergent and are exempt. Requires the bundled
80
+ * component (or a store implementing get/putFieldClocks). */
81
+ timestampLww(): ConflictPolicyName;
82
+ table<T extends TableNamesInDataModel<DataModelFromSchemaDefinition<Schema>>>(tableName: T, tableOptions: TableOptions): {
83
+ query<A extends PropertyValidators>(spec: QuerySpec<A, DocumentByName<DataModelFromSchemaDefinition<Schema>, T>>): RegisteredQuery<"public", ObjectType<A>, DocumentByName<DataModelFromSchemaDefinition<Schema>, T>[]>;
84
+ insert<A extends PropertyValidators>(spec: InsertSpec<A, Ctx>): RegisteredMutation<"public", ObjectType<A>, null>;
85
+ patch<A extends PropertyValidators>(spec: PatchSpec<A, Ctx>): RegisteredMutation<"public", ObjectType<A>, null>;
86
+ remove<A extends PropertyValidators>(spec: RemoveSpec<A>): RegisteredMutation<"public", ObjectType<A>, null>;
87
+ };
88
+ };
89
+ /**
90
+ * Derive the `createSyncFunctions({ tables })` config from your imported `lf.table`
91
+ * modules, so scope / idField / conflict live in ONE place instead of being restated (and
92
+ * drifting) in `sync.ts`:
93
+ *
94
+ * ```ts
95
+ * import * as issues from "./issues";
96
+ * import * as labels from "./labels";
97
+ * export const { push, pull } = createSyncFunctions({
98
+ * component: components.convexLocalFirst, mutation, query,
99
+ * tables: collectTables({ issues, labels }),
100
+ * isMember
101
+ * });
102
+ * ```
103
+ *
104
+ * Throws if two functions of one table carry conflicting config, or if no local-first
105
+ * tables are found.
106
+ */
107
+ export declare function collectTables(modules: Record<string, unknown>): Record<string, ServerTableConfig>;
package/dist/index.js ADDED
@@ -0,0 +1,148 @@
1
+ import { mutationGeneric as mutation, queryGeneric as query } from "convex/server";
2
+ export * from "./serverSync.js";
3
+ export * from "./createSyncFunctions.js";
4
+ // Non-enumerable key under which each lf.table fn carries its sync config (scope /
5
+ // idField / conflict / …). Shared by attachMetadata (writer) and collectTables
6
+ // (reader) so the magic string lives in exactly one place.
7
+ const LF_METADATA_KEY = "__convexLocalFirst";
8
+ export function createLocalFirst(options) {
9
+ return {
10
+ byUser(field) {
11
+ return { kind: "byUser", field };
12
+ },
13
+ byWorkspace(input) {
14
+ return { kind: "byWorkspace", ...input };
15
+ },
16
+ byProject(input) {
17
+ return { kind: "byProject", ...input };
18
+ },
19
+ fieldLww() {
20
+ return "fieldLww";
21
+ },
22
+ /** Timestamp-ordered LWW: a scalar field-write carries the op's logical timestamp +
23
+ * clientId tiebreaker, so a NEWER edit wins regardless of arrival order (the offline-first
24
+ * fix). Set/counter delta fields stay convergent and are exempt. Requires the bundled
25
+ * component (or a store implementing get/putFieldClocks). */
26
+ timestampLww() {
27
+ return "timestampLww";
28
+ },
29
+ table(tableName, tableOptions) {
30
+ const idField = tableOptions.idField ?? options.defaults?.idField ?? "localId";
31
+ const conflict = tableOptions.conflict ?? options.defaults?.conflict ?? "fieldLww";
32
+ const metadata = {
33
+ tableName,
34
+ idField,
35
+ conflict,
36
+ scope: tableOptions.scope,
37
+ indexes: tableOptions.indexes ?? {},
38
+ setFields: tableOptions.setFields,
39
+ counterFields: tableOptions.counterFields
40
+ };
41
+ return {
42
+ query(spec) {
43
+ const fn = query({
44
+ args: spec.args,
45
+ handler: async () => unsupportedLocalFirstCall("query", tableName)
46
+ });
47
+ // ponytail: the handler throws by design (local-first reads run on the
48
+ // client); the declared return type is what the engine actually
49
+ // delivers, so we assert it here rather than leak `never` to callers.
50
+ return attachMetadata(fn, { kind: "query", ...metadata, spec });
51
+ },
52
+ insert(spec) {
53
+ const fn = mutation({
54
+ args: spec.args,
55
+ handler: async () => unsupportedLocalFirstCall("insert", tableName)
56
+ });
57
+ return attachMetadata(fn, { kind: "insert", ...metadata, spec });
58
+ },
59
+ patch(spec) {
60
+ const fn = mutation({
61
+ args: spec.args,
62
+ handler: async () => unsupportedLocalFirstCall("patch", tableName)
63
+ });
64
+ return attachMetadata(fn, { kind: "patch", ...metadata, spec });
65
+ },
66
+ remove(spec) {
67
+ const fn = mutation({
68
+ args: spec.args,
69
+ handler: async () => unsupportedLocalFirstCall("remove", tableName)
70
+ });
71
+ return attachMetadata(fn, { kind: "remove", ...metadata, spec });
72
+ }
73
+ };
74
+ }
75
+ // I8 "server-only by default" needs no wrapper: a mutation is local-first
76
+ // ONLY if declared via lf.table(...).insert/patch/remove and present in the
77
+ // manifest. Every other Convex mutation is server-only — it runs through the
78
+ // normal Convex client and never enters the local outbox.
79
+ };
80
+ }
81
+ /**
82
+ * Local-first table functions are never executed server-side: reads/writes flow
83
+ * through the generated client (optimistic local) and are synchronized via
84
+ * sync.push / sync.pull. The function still exists in the deployment — the client
85
+ * references it by name and codegen introspects its attached metadata — but
86
+ * invoking the handler directly is a bug, so it refuses loudly instead of
87
+ * returning fabricated data (GOAL G7: real, or explicitly throw "unsupported").
88
+ */
89
+ function unsupportedLocalFirstCall(kind, tableName) {
90
+ throw new Error(`Local-first ${kind} for "${tableName}" is not directly callable server-side. Call it from the ` +
91
+ `client instead — the React hooks (useMutation/useQuery) or, headless, ` +
92
+ `engine.mutate(api.<module>.<fn>, args) / engine.query(...) — which applies it optimistically and ` +
93
+ `synchronizes via sync.push / sync.pull. (Invoking the server handler directly is always a bug.)`);
94
+ }
95
+ function attachMetadata(value, metadata) {
96
+ Object.defineProperty(value, LF_METADATA_KEY, {
97
+ value: metadata,
98
+ enumerable: false,
99
+ configurable: false
100
+ });
101
+ return value;
102
+ }
103
+ /**
104
+ * Derive the `createSyncFunctions({ tables })` config from your imported `lf.table`
105
+ * modules, so scope / idField / conflict live in ONE place instead of being restated (and
106
+ * drifting) in `sync.ts`:
107
+ *
108
+ * ```ts
109
+ * import * as issues from "./issues";
110
+ * import * as labels from "./labels";
111
+ * export const { push, pull } = createSyncFunctions({
112
+ * component: components.convexLocalFirst, mutation, query,
113
+ * tables: collectTables({ issues, labels }),
114
+ * isMember
115
+ * });
116
+ * ```
117
+ *
118
+ * Throws if two functions of one table carry conflicting config, or if no local-first
119
+ * tables are found.
120
+ */
121
+ export function collectTables(modules) {
122
+ const out = {};
123
+ for (const mod of Object.values(modules)) {
124
+ if (!mod || (typeof mod !== "object" && typeof mod !== "function"))
125
+ continue;
126
+ for (const exported of Object.values(mod)) {
127
+ const meta = exported?.[LF_METADATA_KEY];
128
+ if (!meta || typeof meta.tableName !== "string")
129
+ continue;
130
+ const config = { scope: meta.scope, idField: meta.idField, conflict: meta.conflict };
131
+ const existing = out[meta.tableName];
132
+ if (!existing) {
133
+ out[meta.tableName] = config;
134
+ }
135
+ else if (existing.idField !== config.idField ||
136
+ existing.conflict !== config.conflict ||
137
+ JSON.stringify(existing.scope) !== JSON.stringify(config.scope)) {
138
+ // Fail closed: a divergent config for the same table can only come from
139
+ // hand-tampering the metadata — never from a single lf.table definition.
140
+ throw new Error(`collectTables: conflicting config for table "${meta.tableName}" — every lf.table function for a table must share one definition.`);
141
+ }
142
+ }
143
+ }
144
+ if (Object.keys(out).length === 0) {
145
+ throw new Error("collectTables: no local-first tables found in the provided modules. Import the table modules and pass them, e.g. collectTables({ issues, labels }).");
146
+ }
147
+ return out;
148
+ }
@@ -0,0 +1,159 @@
1
+ import type { ConflictPolicyName, ScopeDefinition } from "@convex-localfirst/core";
2
+ import { type FieldClock } from "@convex-localfirst/core/internal";
3
+ /**
4
+ * Pure, runtime-agnostic server sync engine. It enforces scope/ownership,
5
+ * dedupes via the operation ledger, appends to the change log (deletes included),
6
+ * and maintains the id map. The Convex component supplies a ServerStore backed by
7
+ * ctx.db; tests supply an in-memory one. Pull cursors are client-driven (sent in the
8
+ * request, advanced in the response) — the server keeps no per-client cursor state.
9
+ *
10
+ * Security invariants enforced here (the client never decides authorization):
11
+ * - I7: pull scope and push ownership/membership are derived from the
12
+ * authenticated userId, never from client-supplied scope/owner values.
13
+ * - I2: a re-pushed opId is idempotent (ledger lookup short-circuits).
14
+ */
15
+ export type ServerTableConfig = {
16
+ readonly scope: ScopeDefinition;
17
+ readonly idField: string;
18
+ readonly conflict: ConflictPolicyName;
19
+ };
20
+ export type ServerOperation = {
21
+ readonly opId: string;
22
+ readonly clientId: string;
23
+ readonly schemaVersion: number;
24
+ readonly functionName: string;
25
+ readonly table: string;
26
+ readonly kind: "insert" | "patch" | "delete";
27
+ readonly localId: string;
28
+ readonly value?: Record<string, unknown>;
29
+ readonly patch?: Record<string, unknown>;
30
+ readonly timestamp?: number;
31
+ };
32
+ export type StoredChange = {
33
+ readonly changeId: string;
34
+ readonly scopeKey: string;
35
+ readonly table: string;
36
+ readonly localId: string;
37
+ readonly kind: "insert" | "patch" | "delete";
38
+ readonly data?: Record<string, unknown>;
39
+ readonly patch?: Record<string, unknown>;
40
+ readonly version: number;
41
+ readonly serverTime: number;
42
+ readonly opId?: string;
43
+ };
44
+ export type LedgerEntry = {
45
+ readonly status: "accepted" | "rejected";
46
+ readonly resultJson?: string;
47
+ readonly error?: string;
48
+ readonly changesJson?: string;
49
+ };
50
+ /**
51
+ * Storage contract the sync engine drives. TRANSACTIONAL REQUIREMENT (I2/I5): each push
52
+ * does read-then-write sequences that need transactional isolation —
53
+ * - idempotency: getLedger → apply → putLedger must be atomic, else concurrent pushes of
54
+ * the same opId both miss the ledger and double-apply;
55
+ * - per-row version monotonicity: latestChangeVersion → appendChange(version+1).
56
+ * The Convex adapter gets this free (every method shares the parent mutation's tx + OCC).
57
+ * A CUSTOM ServerStore MUST provide the same per-push isolation or it can race.
58
+ */
59
+ export type ServerStore = {
60
+ getRow(table: string, serverId: string): Promise<Record<string, unknown> | null>;
61
+ insertRow(table: string, data: Record<string, unknown>): Promise<string>;
62
+ patchRow(table: string, serverId: string, patch: Record<string, unknown>): Promise<void>;
63
+ deleteRow(table: string, serverId: string): Promise<void>;
64
+ getLedger(userId: string, opId: string): Promise<LedgerEntry | null>;
65
+ putLedger(userId: string, clientId: string, op: ServerOperation, entry: LedgerEntry): Promise<void>;
66
+ getServerId(table: string, localId: string): Promise<string | null>;
67
+ putIdMap(userId: string, table: string, localId: string, serverId: string): Promise<void>;
68
+ appendChange(change: Omit<StoredChange, "changeId">): Promise<string>;
69
+ changesAfter(scopeKey: string, cursor: string | null, limit: number): Promise<readonly StoredChange[]>;
70
+ /** Highest change version recorded for a row (0 if none). Row versions live in
71
+ * the change log, never on the user row, so user schemas stay clean.
72
+ *
73
+ * CONCURRENCY (I5 monotonicity): handlePush reads this then appendChange writes
74
+ * `version+1`. In the real Convex adapter both are component calls FROM a mutation,
75
+ * which share the parent's transaction + OCC — a concurrent push to the same row
76
+ * invalidates this read and retries, so two writers can't commit a duplicate or
77
+ * regressing per-row version. (A non-transactional custom/in-memory ServerStore has
78
+ * no such guarantee; the bundled tests run sequentially so they never race.) */
79
+ latestChangeVersion(table: string, localId: string): Promise<number>;
80
+ /** The scope a row last lived in (from the change log), or null if never seen.
81
+ * Authorizes an idempotent no-op delete of an already-gone row (whose scope can
82
+ * no longer come from the row itself) so it can't become a cross-scope oracle. */
83
+ scopeForLocalId(table: string, localId: string): Promise<string | null>;
84
+ /** OPTIONAL on the type, but REQUIRED for any `timestampLww` table: per-field write clocks
85
+ * (field → {ts, tiebreaker}). A timestampLww table whose store lacks these REJECTS the op
86
+ * loudly (no silent arrival-order degrade — see applyOp). A store with only `fieldLww` tables
87
+ * never needs them. Like the version RMW, the get→put pair must share the push mutation's
88
+ * transaction (it does in the real Convex adapter) so concurrent writers can't lose a clock update. */
89
+ getFieldClocks?(table: string, localId: string): Promise<Record<string, FieldClock>>;
90
+ putFieldClocks?(table: string, localId: string, clocks: Record<string, FieldClock>): Promise<void>;
91
+ isMember(userId: string, scopeValue: string, membershipTable: string): Promise<boolean>;
92
+ };
93
+ export type PushOp = ServerOperation;
94
+ export type PushInput = {
95
+ readonly userId: string;
96
+ readonly clientId: string;
97
+ readonly schemaVersion: number;
98
+ readonly mutations: readonly PushOp[];
99
+ };
100
+ export type PushResult = {
101
+ readonly accepted: Array<{
102
+ opId: string;
103
+ serverResult?: unknown;
104
+ }>;
105
+ readonly rejected: Array<{
106
+ opId: string;
107
+ message: string;
108
+ }>;
109
+ readonly idMaps: Array<{
110
+ table: string;
111
+ localId: string;
112
+ serverId: string;
113
+ }>;
114
+ readonly changes: StoredChange[];
115
+ readonly serverTime: number;
116
+ readonly schemaMismatch?: boolean;
117
+ };
118
+ export type PullScope = {
119
+ readonly kind: ScopeDefinition["kind"];
120
+ readonly value?: string;
121
+ };
122
+ export type PullInput = {
123
+ readonly userId: string;
124
+ readonly clientId: string;
125
+ readonly schemaVersion: number;
126
+ readonly scopes: readonly PullScope[];
127
+ readonly cursors: Record<string, string | null>;
128
+ };
129
+ export type PullResult = {
130
+ readonly changes: StoredChange[];
131
+ readonly cursors: Record<string, string>;
132
+ readonly serverTime: number;
133
+ readonly schemaMismatch?: boolean;
134
+ /** Per-scope: true when this page hit the pull limit, so more changes remain. */
135
+ readonly hasMore: Record<string, boolean>;
136
+ };
137
+ /**
138
+ * Codec for serializing local-first row values to/from the JSON-string columns in
139
+ * the component. The default is plain JSON (fine for tests + JSON-only values); the
140
+ * Convex adapter injects one built on convexToJson/jsonToConvex so synced rows can
141
+ * carry the FULL Convex value range (bigint, bytes, nested undefined) losslessly
142
+ * rather than throwing (bigint) or silently changing shape.
143
+ */
144
+ export type ValueCodec = {
145
+ encode(value: unknown): string;
146
+ decode(json: string): unknown;
147
+ };
148
+ export declare const JSON_VALUE_CODEC: ValueCodec;
149
+ export type SyncConfig = {
150
+ readonly schemaVersion: number;
151
+ readonly tables: Record<string, ServerTableConfig>;
152
+ readonly now?: () => number;
153
+ readonly pullLimit?: number;
154
+ readonly valueCodec?: ValueCodec;
155
+ };
156
+ export declare function scopeKeyForUser(userId: string): string;
157
+ export declare function scopeKeyForValue(kind: string, value: string): string;
158
+ export declare function handlePush(store: ServerStore, config: SyncConfig, input: PushInput): Promise<PushResult>;
159
+ export declare function handlePull(store: ServerStore, config: SyncConfig, input: PullInput): Promise<PullResult>;
@@ -0,0 +1,447 @@
1
+ import { applyCounterDelta, applySetDelta, isCounterDelta, isSetDelta, lwwWins } from "@convex-localfirst/core/internal";
2
+ export const JSON_VALUE_CODEC = {
3
+ encode: (value) => JSON.stringify(value ?? null),
4
+ decode: (json) => JSON.parse(json)
5
+ };
6
+ class RejectOp extends Error {
7
+ }
8
+ export function scopeKeyForUser(userId) {
9
+ return `u:${userId}`;
10
+ }
11
+ export function scopeKeyForValue(kind, value) {
12
+ return `${kind}:${value}`;
13
+ }
14
+ /** The field that decides a row's partition. A write must never change it (a row
15
+ * cannot move scopes), otherwise membership — checked against the row's CURRENT
16
+ * scope — would be bypassed (I7). */
17
+ function partitionFieldOf(scope) {
18
+ if (scope.kind === "byUser")
19
+ return scope.field;
20
+ if (scope.kind === "byWorkspace")
21
+ return scope.workspaceIdField;
22
+ if (scope.kind === "byProject")
23
+ return scope.projectIdField;
24
+ return null;
25
+ }
26
+ /**
27
+ * Resolve the scope key for an op, enforcing ownership/membership against the
28
+ * authenticated userId. CRUCIAL (I7): for a patch/delete the scope is derived
29
+ * from the EXISTING SERVER ROW, never from client-supplied `op.value` — otherwise
30
+ * a client could name a scope it belongs to and so authorize a write to a row in
31
+ * a scope it does not. Only an insert declares its own scope (and membership on
32
+ * that claimed value is checked).
33
+ */
34
+ async function resolveScopeForWrite(store, config, userId, op) {
35
+ const scope = config.scope;
36
+ const value = { ...(op.value ?? {}) };
37
+ if (scope.kind === "byUser") {
38
+ if (op.kind === "insert") {
39
+ // Ignore any client-supplied owner: ownership is the authenticated user.
40
+ value[scope.field] = userId;
41
+ return { scopeKey: scopeKeyForUser(userId), value };
42
+ }
43
+ // patch/delete: the row must exist AND be owned by this user. The id map is
44
+ // global (by table+localId), so without this check a user who guesses another
45
+ // user's localId could write their row.
46
+ const existing = await loadRow(store, op);
47
+ if (existing[scope.field] !== userId) {
48
+ throw new RejectOp("Not the owner of this row");
49
+ }
50
+ return { scopeKey: scopeKeyForUser(userId), value };
51
+ }
52
+ if (scope.kind === "byWorkspace" || scope.kind === "byProject") {
53
+ const field = scope.kind === "byWorkspace" ? scope.workspaceIdField : scope.projectIdField;
54
+ let scopeValue;
55
+ if (op.kind === "insert") {
56
+ // An insert declares the scope it targets; membership on it is checked below.
57
+ scopeValue = typeof value[field] === "string" ? String(value[field]) : null;
58
+ }
59
+ else {
60
+ // patch/delete: scope comes from the existing row, NEVER from op.value.
61
+ const existing = await loadRow(store, op);
62
+ scopeValue = typeof existing[field] === "string" ? String(existing[field]) : null;
63
+ }
64
+ if (!scopeValue) {
65
+ throw new RejectOp(`Missing ${field} for scoped write`);
66
+ }
67
+ const member = await store.isMember(userId, scopeValue, scope.membershipTable);
68
+ if (!member) {
69
+ throw new RejectOp("Not a member of the target scope");
70
+ }
71
+ return { scopeKey: scopeKeyForValue(scope.kind, scopeValue), value };
72
+ }
73
+ throw new RejectOp("Custom scopes require a server resolver");
74
+ }
75
+ /** True if `userId` may write rows in `scopeKey`. The boolean core of scope
76
+ * authorization — the caller decides whether to throw and with what message, so a
77
+ * delete can reject every denial (missing/foreign/gone) with ONE generic message
78
+ * and avoid leaking which case it was (an existence/ownership oracle, I7). */
79
+ async function isAuthorizedForScope(store, config, userId, scopeKey) {
80
+ const scope = config.scope;
81
+ if (scope.kind === "byUser") {
82
+ return scopeKey === scopeKeyForUser(userId);
83
+ }
84
+ if (scope.kind === "byWorkspace" || scope.kind === "byProject") {
85
+ const value = scopeKey.slice(scopeKey.indexOf(":") + 1);
86
+ return await store.isMember(userId, value, scope.membershipTable);
87
+ }
88
+ return false; // defensive: fail closed on any unknown scope kind
89
+ }
90
+ /** The scopeKey a stored row belongs to, from its partition field (the inverse of
91
+ * resolveScopeForWrite). Returns null if the row lacks a usable partition value. */
92
+ function scopeKeyForRow(config, row) {
93
+ const scope = config.scope;
94
+ if (scope.kind === "byUser") {
95
+ const owner = row[scope.field];
96
+ return typeof owner === "string" ? scopeKeyForUser(owner) : null;
97
+ }
98
+ if (scope.kind === "byWorkspace" || scope.kind === "byProject") {
99
+ const field = scope.kind === "byWorkspace" ? scope.workspaceIdField : scope.projectIdField;
100
+ const value = row[field];
101
+ return typeof value === "string" ? scopeKeyForValue(scope.kind, value) : null;
102
+ }
103
+ return null;
104
+ }
105
+ /** Load the existing server row for a non-insert op, or reject if it's gone. */
106
+ async function loadRow(store, op) {
107
+ const serverId = await store.getServerId(op.table, op.localId);
108
+ const row = serverId ? await store.getRow(op.table, serverId) : null;
109
+ if (!row) {
110
+ throw new RejectOp(`No server row for ${op.table}:${op.localId}`);
111
+ }
112
+ return row;
113
+ }
114
+ export async function handlePush(store, config, input) {
115
+ const now = config.now ?? (() => Date.now());
116
+ const serverTime = now();
117
+ const codec = config.valueCodec ?? JSON_VALUE_CODEC;
118
+ const result = { accepted: [], rejected: [], idMaps: [], changes: [], serverTime };
119
+ if (input.schemaVersion !== config.schemaVersion) {
120
+ return { ...result, schemaMismatch: true };
121
+ }
122
+ for (const op of input.mutations) {
123
+ // I2: idempotency — a known opId is never re-applied (keyed by (userId, opId) so a
124
+ // reload/new-tab replay under a different envelope clientId is still deduped).
125
+ const prior = await store.getLedger(input.userId, op.opId);
126
+ if (prior) {
127
+ if (prior.status === "accepted") {
128
+ result.accepted.push({ opId: op.opId, serverResult: prior.resultJson ? JSON.parse(prior.resultJson) : undefined });
129
+ // Re-deliver the confirming change so a replayed (already-committed) op can
130
+ // leave _pending even if its original ack was lost. The client version-checks
131
+ // it (nextCanonicalRow), so re-applying a now-stale change is ignored — never a
132
+ // regression. The server log is NOT re-appended (this is read from the ledger).
133
+ if (prior.changesJson) {
134
+ result.changes.push(...codec.decode(prior.changesJson));
135
+ }
136
+ }
137
+ else {
138
+ result.rejected.push({ opId: op.opId, message: prior.error ?? "rejected" });
139
+ }
140
+ continue;
141
+ }
142
+ // I8: an op carries the schema it was BUILT under. The envelope already matches the
143
+ // server (checked above), so a per-op mismatch means a stale offline op queued before
144
+ // a client schema upgrade. Applying it under the new schema can silently corrupt
145
+ // (changed field meaning, new required field). Reject it loudly — the client must
146
+ // migrate queued ops before pushing — rather than commit semantically stale data.
147
+ if (op.schemaVersion !== input.schemaVersion) {
148
+ const message = `Operation ${op.opId} was created under schema v${op.schemaVersion} but the client now declares v${input.schemaVersion}; migrate queued operations before pushing.`;
149
+ result.rejected.push({ opId: op.opId, message });
150
+ await store.putLedger(input.userId, input.clientId, op, { status: "rejected", error: message });
151
+ continue;
152
+ }
153
+ const tableConfig = config.tables[op.table];
154
+ if (!tableConfig) {
155
+ const message = `Unknown local-first table: ${op.table}`;
156
+ result.rejected.push({ opId: op.opId, message });
157
+ await store.putLedger(input.userId, input.clientId, op, { status: "rejected", error: message });
158
+ continue;
159
+ }
160
+ try {
161
+ const change = await applyOp(store, tableConfig, input.userId, op, serverTime);
162
+ if (change) {
163
+ result.changes.push(change);
164
+ }
165
+ const serverId = await store.getServerId(op.table, op.localId);
166
+ if (op.kind === "insert" && serverId) {
167
+ result.idMaps.push({ table: op.table, localId: op.localId, serverId });
168
+ }
169
+ // change === null is an idempotent no-op delete (row already gone): still ack it
170
+ // so the client stops retrying, and ledger it for opId idempotency. Do NOT echo
171
+ // serverId on a no-op — the row is gone, so its internal id must not leak.
172
+ const serverResult = change === null
173
+ ? { ok: true, localId: op.localId, noop: true }
174
+ : { ok: true, localId: op.localId, serverId };
175
+ result.accepted.push({ opId: op.opId, serverResult });
176
+ await store.putLedger(input.userId, input.clientId, op, {
177
+ status: "accepted",
178
+ resultJson: JSON.stringify(serverResult),
179
+ // Persist the confirming change so a later duplicate replay can recover it.
180
+ changesJson: change ? codec.encode([change]) : undefined
181
+ });
182
+ }
183
+ catch (error) {
184
+ const message = error instanceof Error ? error.message : String(error);
185
+ result.rejected.push({ opId: op.opId, message });
186
+ await store.putLedger(input.userId, input.clientId, op, { status: "rejected", error: message });
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+ // Bound how far a client's logical write-clock may lead the server clock. op.timestamp
192
+ // is the client's own clock (legitimately ahead of serverTime by real skew), so we don't
193
+ // clamp to serverTime exactly — but an UNBOUNDED future timestamp would let an authorized
194
+ // writer pin a timestampLww field forever (every later honest write has a smaller ts and
195
+ // loses). Clamping to serverTime + this bound caps that abuse to a small window while
196
+ // leaving every real write (op.createdAt ≈ now) untouched.
197
+ const MAX_CLOCK_SKEW_MS = 5 * 60_000;
198
+ async function applyOp(store, tableConfig, userId, op, serverTime) {
199
+ // Delete is fully handled here so EVERY denial — never-seen localId, foreign live
200
+ // row, foreign already-deleted row — rejects with ONE generic message. Splitting
201
+ // them ("No server row" vs "Not the owner") would be an existence/ownership oracle
202
+ // (I7). Authorize against the row's scope: the live row's partition field if it
203
+ // still exists, else the scope it last lived in (the append-only change log).
204
+ if (op.kind === "delete") {
205
+ const existingId = await store.getServerId(op.table, op.localId);
206
+ const existingRow = existingId ? await store.getRow(op.table, existingId) : null;
207
+ const scopeKey = existingRow
208
+ ? scopeKeyForRow(tableConfig, existingRow)
209
+ : await store.scopeForLocalId(op.table, op.localId);
210
+ if (scopeKey === null || !(await isAuthorizedForScope(store, tableConfig, userId, scopeKey))) {
211
+ throw new RejectOp(`Cannot delete ${op.table}:${op.localId}`);
212
+ }
213
+ // Deletes commute: an authorized delete of an already-gone row acks as a no-op
214
+ // (the EXPECTED case for an insert-only log with compaction, where two clients
215
+ // can concurrently prune the same subsumed rows, and for any replayed delete).
216
+ if (!existingRow) {
217
+ return null;
218
+ }
219
+ const version = (await store.latestChangeVersion(op.table, op.localId)) + 1;
220
+ await store.deleteRow(op.table, existingId);
221
+ const changeId = await store.appendChange({
222
+ scopeKey,
223
+ table: op.table,
224
+ localId: op.localId,
225
+ kind: "delete",
226
+ version,
227
+ serverTime,
228
+ opId: op.opId
229
+ });
230
+ return changeAsStored(changeId, scopeKey, op, "delete", version, serverTime, undefined, undefined);
231
+ }
232
+ let scopeKey;
233
+ let value;
234
+ try {
235
+ ({ scopeKey, value } = await resolveScopeForWrite(store, tableConfig, userId, op));
236
+ }
237
+ catch (error) {
238
+ // A patch's denials (never-seen "No server row", foreign owner/member) would be
239
+ // an existence/ownership oracle — collapse them ALL into one generic message
240
+ // (mirrors delete). Insert has no such oracle (its row does not exist yet), so
241
+ // its messages pass through unchanged.
242
+ if (op.kind === "patch" && error instanceof RejectOp) {
243
+ throw new RejectOp(`Cannot patch ${op.table}:${op.localId}`);
244
+ }
245
+ throw error;
246
+ }
247
+ if (op.kind === "insert") {
248
+ // Record the client's stable local id under the table's idField so the row
249
+ // satisfies the app schema and can be correlated back to the client.
250
+ value[tableConfig.idField] = op.localId;
251
+ // A re-push of the SAME op is short-circuited by the ledger before applyOp, so
252
+ // reaching here with an existing (table, localId) means a DIFFERENT op reused
253
+ // the localId. localId must be globally unique — treat a collision as invalid
254
+ // rather than as license to append a change against an existing row.
255
+ if (await store.getServerId(op.table, op.localId)) {
256
+ throw new RejectOp(`Duplicate localId for ${op.table}:${op.localId}`);
257
+ }
258
+ const serverId = await store.insertRow(op.table, value);
259
+ await store.putIdMap(userId, op.table, op.localId, serverId);
260
+ // First change for a fresh row is version 1 (I5 monotonicity).
261
+ const version = (await store.latestChangeVersion(op.table, op.localId)) + 1;
262
+ const changeId = await store.appendChange({
263
+ scopeKey,
264
+ table: op.table,
265
+ localId: op.localId,
266
+ kind: "insert",
267
+ data: value,
268
+ version,
269
+ serverTime,
270
+ opId: op.opId
271
+ });
272
+ return changeAsStored(changeId, scopeKey, op, "insert", version, serverTime, value, undefined);
273
+ }
274
+ const serverId = await store.getServerId(op.table, op.localId);
275
+ if (!serverId) {
276
+ // Only patch reaches here (delete/insert returned above), and resolveScopeForWrite
277
+ // already loaded the row — so this is a defensive guard; keep it generic (no oracle).
278
+ throw new RejectOp(`Cannot patch ${op.table}:${op.localId}`);
279
+ }
280
+ // Row version is derived from the append-only change log — the single source of
281
+ // truth — never written onto the user row (which has no `_version` column).
282
+ const version = (await store.latestChangeVersion(op.table, op.localId)) + 1;
283
+ if (op.kind === "patch") {
284
+ // I7 defense-in-depth: a patch must never move a row across scopes or rewrite
285
+ // its id. Membership was checked against the row's CURRENT scope; letting the
286
+ // patch change the partition field (workspaceId/ownerId/projectId) or idField
287
+ // would bypass that check and corrupt the change log's scope key.
288
+ let patch = op.patch ?? {};
289
+ const guarded = partitionFieldOf(tableConfig.scope);
290
+ for (const field of [guarded, tableConfig.idField]) {
291
+ if (field && Object.prototype.hasOwnProperty.call(patch, field)) {
292
+ throw new RejectOp(`Cannot patch the ${field === tableConfig.idField ? "id" : "scope"} field "${field}"`);
293
+ }
294
+ }
295
+ // Set/counter-field merge: a patch field carrying a SetDelta or CounterDelta is
296
+ // materialized against the CURRENT row → a plain array/number, so concurrent edits from
297
+ // other clients merge (not last-writer-wins clobber) and the change log + pull stay
298
+ // delta-free (clients pull concrete values). Shape-driven (the delta is self-describing),
299
+ // so no per-table server config; only loads the row when a delta is actually present
300
+ // (zero overhead otherwise). A delta over a wrong-typed field is a client bug/forge — reject.
301
+ // Convergent (set/counter) delta fields are merged commutatively below and are EXEMPT
302
+ // from timestamp-LWW (they never clobber by design). Capture them before materialization.
303
+ const deltaFields = new Set(Object.keys(patch).filter((f) => isSetDelta(patch[f]) || isCounterDelta(patch[f])));
304
+ if (deltaFields.size > 0) {
305
+ const current = await loadRow(store, op);
306
+ const materialized = { ...patch };
307
+ for (const [field, value] of Object.entries(patch)) {
308
+ if (isSetDelta(value)) {
309
+ if (current[field] !== undefined && !Array.isArray(current[field])) {
310
+ throw new RejectOp(`Set delta on non-array field "${field}" of ${op.table}:${op.localId}`);
311
+ }
312
+ materialized[field] = applySetDelta(current[field], value.__lfSet);
313
+ }
314
+ else if (isCounterDelta(value)) {
315
+ if (current[field] !== undefined && typeof current[field] !== "number") {
316
+ throw new RejectOp(`Counter delta on non-number field "${field}" of ${op.table}:${op.localId}`);
317
+ }
318
+ materialized[field] = applyCounterDelta(current[field], value.__lfCounter);
319
+ }
320
+ }
321
+ patch = materialized;
322
+ }
323
+ // Timestamp-ordered LWW: for a `timestampLww` table, resolve each plain scalar field-write
324
+ // against the field's last-write clock so a newer edit wins regardless of arrival order; a
325
+ // stale write is dropped. Delta fields (set/counter) are exempt (they never clobber), so a
326
+ // delta-only patch skips this. A patch writing ANY plain field MUST carry a timestamp and a
327
+ // field-clock store — applying by arrival order without updating the clock would desync it
328
+ // and wrongly drop a later write — so we fail closed loudly rather than corrupt.
329
+ if (tableConfig.conflict === "timestampLww") {
330
+ const plainFields = Object.keys(patch).filter((f) => !deltaFields.has(f));
331
+ if (plainFields.length > 0) {
332
+ if (op.timestamp === undefined) {
333
+ throw new RejectOp(`Table "${op.table}" uses conflict: "timestampLww" but this op writes scalar field(s) [${plainFields.join(", ")}] without a timestamp — upgrade the client (the local-first transport sends one automatically).`);
334
+ }
335
+ if (!store.getFieldClocks || !store.putFieldClocks) {
336
+ throw new RejectOp(`Table "${op.table}" uses conflict: "timestampLww" but the ServerStore has no getFieldClocks/putFieldClocks. Use the bundled component or implement both.`);
337
+ }
338
+ const ts = Math.min(op.timestamp, serverTime + MAX_CLOCK_SKEW_MS); // cap far-future pinning
339
+ const incoming = { ts, tiebreaker: op.clientId };
340
+ const clocks = await store.getFieldClocks(op.table, op.localId);
341
+ const winners = {};
342
+ const updated = {};
343
+ for (const [field, value] of Object.entries(patch)) {
344
+ if (deltaFields.has(field)) {
345
+ winners[field] = value; // convergent delta — always kept, no clock
346
+ }
347
+ else if (lwwWins(incoming, clocks[field])) {
348
+ winners[field] = value;
349
+ updated[field] = incoming;
350
+ }
351
+ // else: a stale plain field-write — drop it, keeping the current (newer) value.
352
+ }
353
+ if (Object.keys(updated).length > 0) {
354
+ await store.putFieldClocks(op.table, op.localId, { ...clocks, ...updated });
355
+ }
356
+ patch = winners;
357
+ }
358
+ }
359
+ // A patch that resolved to no fields — every plain field lost the timestamp-LWW race,
360
+ // or the op carried an empty patch — is an ACCEPTED no-op (like an already-gone delete):
361
+ // skip the write so it never appends a spurious empty change, consumes a version, or
362
+ // gets re-delivered to every puller. Do NOT leak the serverId (handlePush noop path).
363
+ if (Object.keys(patch).length === 0) {
364
+ return null;
365
+ }
366
+ await store.patchRow(op.table, serverId, patch);
367
+ const changeId = await store.appendChange({
368
+ scopeKey,
369
+ table: op.table,
370
+ localId: op.localId,
371
+ kind: "patch",
372
+ patch,
373
+ version,
374
+ serverTime,
375
+ opId: op.opId
376
+ });
377
+ return changeAsStored(changeId, scopeKey, op, "patch", version, serverTime, undefined, patch);
378
+ }
379
+ // insert/patch return above; delete is fully handled at the top. This is
380
+ // unreachable for the three known kinds — guard any future kind explicitly.
381
+ throw new RejectOp(`Unsupported op kind for ${op.table}:${op.localId}`);
382
+ }
383
+ function changeAsStored(changeId, scopeKey, op, kind, version, serverTime, data, patch) {
384
+ return { changeId, scopeKey, table: op.table, localId: op.localId, kind, data, patch, version, serverTime, opId: op.opId };
385
+ }
386
+ /**
387
+ * The membership table to enforce for a workspace/project scope kind. Scope keys
388
+ * are per-value and SHARED across every table of a kind, so the kind must map to
389
+ * exactly one membership table — otherwise a member of the laxer table could pull
390
+ * the stricter table's changes (I7). Reject the ambiguity here (defense for direct
391
+ * serverSync callers; createSyncFunctions also asserts this at config time).
392
+ */
393
+ function membershipTableForScope(config, kind) {
394
+ const tables = new Set();
395
+ for (const table of Object.values(config.tables)) {
396
+ if (table.scope.kind === kind) {
397
+ tables.add(table.scope.membershipTable);
398
+ }
399
+ }
400
+ if (tables.size > 1) {
401
+ throw new Error(`serverSync: all ${kind} tables must share one membershipTable (found: ${[...tables].join(", ")}). Mixed membership tables would cross-authorize reads.`);
402
+ }
403
+ return tables.size === 1 ? [...tables][0] : null;
404
+ }
405
+ export async function handlePull(store, config, input) {
406
+ const now = config.now ?? (() => Date.now());
407
+ const limit = config.pullLimit ?? 500;
408
+ const out = { changes: [], cursors: {}, hasMore: {}, serverTime: now() };
409
+ if (input.schemaVersion !== config.schemaVersion) {
410
+ return { ...out, schemaMismatch: true };
411
+ }
412
+ for (const scope of input.scopes) {
413
+ let scopeKey;
414
+ if (scope.kind === "byUser") {
415
+ // I7: derive from identity; ignore any client-supplied user value.
416
+ scopeKey = scopeKeyForUser(input.userId);
417
+ }
418
+ else if (scope.kind === "byWorkspace" || scope.kind === "byProject") {
419
+ if (!scope.value) {
420
+ continue;
421
+ }
422
+ // Membership is required to read a workspace/project scope. The membership
423
+ // table must be the SAME one push enforces against (the configured value),
424
+ // not a synthesized name — otherwise read and write check different tables.
425
+ const membershipTable = membershipTableForScope(config, scope.kind);
426
+ if (!membershipTable) {
427
+ continue;
428
+ }
429
+ const member = await store.isMember(input.userId, scope.value, membershipTable);
430
+ if (!member) {
431
+ continue;
432
+ }
433
+ scopeKey = scopeKeyForValue(scope.kind, scope.value);
434
+ }
435
+ else {
436
+ continue;
437
+ }
438
+ const cursor = input.cursors[scopeKey] ?? null;
439
+ const changes = await store.changesAfter(scopeKey, cursor, limit);
440
+ out.changes.push(...changes);
441
+ const last = changes[changes.length - 1];
442
+ out.cursors[scopeKey] = last ? last.changeId : cursor ?? "";
443
+ // A full page means the server capped this scope; more may remain past the cursor.
444
+ out.hasMore[scopeKey] = changes.length >= limit;
445
+ }
446
+ return out;
447
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@convex-localfirst/server",
3
+ "version": "0.1.0",
4
+ "description": "Convex-side DSL (lf.table) and runtime-agnostic sync engine for local-first tables: scopes, ownership, idempotency.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "convex",
8
+ "local-first",
9
+ "sync",
10
+ "server",
11
+ "dsl"
12
+ ],
13
+ "type": "module",
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "peerDependencies": {
29
+ "convex": ">=1.0.0"
30
+ },
31
+ "dependencies": {
32
+ "@convex-localfirst/core": "0.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.7.0",
36
+ "vitest": "^2.1.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.json --noEmit false --emitDeclarationOnly false",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit",
41
+ "test": "vitest run --passWithNoTests"
42
+ }
43
+ }