@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 +21 -0
- package/README.md +26 -0
- package/dist/createSyncFunctions.d.ts +58 -0
- package/dist/createSyncFunctions.js +241 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.js +148 -0
- package/dist/serverSync.d.ts +159 -0
- package/dist/serverSync.js +447 -0
- package/package.json +43 -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,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 */
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|