@convex-localfirst/cli 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,16 @@
1
+ # @convex-localfirst/cli
2
+
3
+ Command-line tooling for convex-localfirst:
4
+
5
+ - **`codegen`** — derive the client manifest from your `lf.table` DSL.
6
+ - **`check`** — statically catch direct `ctx.db.insert/replace` writes to local-first
7
+ tables (a security guard; these must go through the generated wrappers).
8
+ - **`dev`** — run the codegen + check pipeline.
9
+
10
+ ```bash
11
+ npm install -D @convex-localfirst/cli
12
+ # run under a TS loader so it can read your TypeScript Convex modules:
13
+ node --import tsx node_modules/@convex-localfirst/cli/dist/index.js codegen
14
+ ```
15
+
16
+ Peer dependency: `typescript`. MIT
@@ -0,0 +1,22 @@
1
+ export type SourceFile = {
2
+ readonly path: string;
3
+ readonly content: string;
4
+ };
5
+ export type Violation = {
6
+ readonly file: string;
7
+ readonly line: number;
8
+ readonly table: string;
9
+ readonly method: string;
10
+ readonly snippet: string;
11
+ };
12
+ /** Collect table names declared local-first via the DSL (`lf.table("name", ...)`). */
13
+ export declare function collectLocalFirstTables(files: readonly SourceFile[]): string[];
14
+ /**
15
+ * Detect direct `ctx.db.insert("<lfTable>", …)` calls — writes that bypass the
16
+ * local-first wrappers and the sync ledger (Security: I10).
17
+ */
18
+ export declare function findDirectWrites(files: readonly SourceFile[], lfTables: readonly string[]): Violation[];
19
+ export declare function findIdBasedWrites(files: readonly SourceFile[], lfTables: readonly string[]): Violation[];
20
+ export declare function readSourceFiles(dir: string): SourceFile[];
21
+ /** Run the full check over a directory. Returns violations (empty = clean). */
22
+ export declare function runCheck(dir: string): Violation[];
package/dist/check.js ADDED
@@ -0,0 +1,293 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import * as ts from "typescript";
4
+ const TABLE_DECL = /\.table\s*\(\s*["'`]([^"'`]+)["'`]/g;
5
+ // High-confidence direct write: ctx.db.insert("<table>", …). `insert` is the only
6
+ // db write that names the table as a string literal; patch/delete/replace take a
7
+ // document id (covered by the AST pass below). Tolerates whitespace/newlines.
8
+ const DIRECT_INSERT = /ctx\s*\.\s*db\s*\.\s*(insert)\s*\(\s*["'`]([^"'`]+)["'`]/g;
9
+ /** Collect table names declared local-first via the DSL (`lf.table("name", ...)`). */
10
+ export function collectLocalFirstTables(files) {
11
+ const tables = new Set();
12
+ for (const file of files) {
13
+ for (const match of file.content.matchAll(TABLE_DECL)) {
14
+ tables.add(match[1]);
15
+ }
16
+ }
17
+ return [...tables];
18
+ }
19
+ function lineOf(content, index) {
20
+ return content.slice(0, index).split("\n").length;
21
+ }
22
+ /**
23
+ * Detect direct `ctx.db.insert("<lfTable>", …)` calls — writes that bypass the
24
+ * local-first wrappers and the sync ledger (Security: I10).
25
+ */
26
+ export function findDirectWrites(files, lfTables) {
27
+ const violations = [];
28
+ const lfSet = new Set(lfTables);
29
+ for (const file of files) {
30
+ for (const match of file.content.matchAll(DIRECT_INSERT)) {
31
+ const [, method, table] = match;
32
+ if (lfSet.has(table)) {
33
+ const line = lineOf(file.content, match.index ?? 0);
34
+ violations.push({
35
+ file: file.path,
36
+ line,
37
+ table,
38
+ method,
39
+ snippet: file.content.split("\n")[line - 1]?.trim() ?? ""
40
+ });
41
+ }
42
+ }
43
+ }
44
+ return violations;
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // AST pass: id-based writes (ctx.db.patch/delete/replace) to local-first rows.
48
+ //
49
+ // patch/delete/replace take a document id, not a table literal, so they can't be
50
+ // matched by regex. This pass tracks, *within a single function*, ids that
51
+ // provably come from a local-first table and flags writes that use them. It is
52
+ // SOUND by construction — it only flags when it can see the id's origin, so a
53
+ // clean result over recognized patterns is real (no false positives). It does
54
+ // NOT trace ids across function boundaries or through ctx.db.get(); those stay
55
+ // unflagged (false negatives), same coverage class as before but strictly more.
56
+ //
57
+ // ponytail: syntactic, function-scoped taint over `const`/handler-args only —
58
+ // no type checker, no cross-call dataflow. Upgrade path: a full ts.Program +
59
+ // TypeChecker pass if cross-function id passing shows up as a real miss.
60
+ // ---------------------------------------------------------------------------
61
+ const ID_WRITE_METHODS = new Set(["patch", "delete", "replace"]);
62
+ function freshScope() {
63
+ return { constDoc: new Map(), argTaint: new Map(), destructuredIds: new Map() };
64
+ }
65
+ function unwrap(node) {
66
+ if (ts.isAwaitExpression(node) || ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) {
67
+ return unwrap(node.expression);
68
+ }
69
+ return node;
70
+ }
71
+ /** `ctx.db.<method>`? Returns the method name. Hardcodes the `ctx.db` convention. */
72
+ function ctxDbMethod(callee) {
73
+ if (!ts.isPropertyAccessExpression(callee))
74
+ return undefined;
75
+ const obj = callee.expression;
76
+ if (ts.isPropertyAccessExpression(obj) &&
77
+ obj.name.text === "db" &&
78
+ ts.isIdentifier(obj.expression) &&
79
+ obj.expression.text === "ctx") {
80
+ return callee.name.text;
81
+ }
82
+ return undefined;
83
+ }
84
+ /** Walk a query chain down to `ctx.db.query("X")`; return X if it is an lf table. */
85
+ function queryRootTable(expr, lfSet) {
86
+ let cur = expr;
87
+ for (;;) {
88
+ if (ts.isCallExpression(cur)) {
89
+ const callee = cur.expression;
90
+ if (ts.isPropertyAccessExpression(callee) && ctxDbMethod(callee) === "query") {
91
+ const arg = cur.arguments[0];
92
+ if (arg && ts.isStringLiteralLike(arg) && lfSet.has(arg.text))
93
+ return arg.text;
94
+ return undefined;
95
+ }
96
+ cur = callee;
97
+ }
98
+ else if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur)) {
99
+ cur = cur.expression;
100
+ }
101
+ else if (ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur)) {
102
+ cur = cur.expression;
103
+ }
104
+ else {
105
+ return undefined;
106
+ }
107
+ }
108
+ }
109
+ /** A single-doc query (`…first()`/`…unique()`) rooted at an lf table -> the table. */
110
+ function singleDocQueryTable(expr, lfSet) {
111
+ const e = unwrap(expr);
112
+ if (!ts.isCallExpression(e))
113
+ return undefined;
114
+ const callee = e.expression;
115
+ if (!ts.isPropertyAccessExpression(callee))
116
+ return undefined;
117
+ if (callee.name.text !== "first" && callee.name.text !== "unique")
118
+ return undefined;
119
+ return queryRootTable(callee.expression, lfSet);
120
+ }
121
+ /** Classify the id argument of a patch/delete/replace call -> lf table, or undefined. */
122
+ function classifyId(arg, scope, lfSet) {
123
+ const a = unwrap(arg);
124
+ if (ts.isPropertyAccessExpression(a)) {
125
+ const base = a.expression;
126
+ if (a.name.text === "_id") {
127
+ // <constDoc>._id or (await ctx.db.query("lf")…first())._id
128
+ if (ts.isIdentifier(base) && scope.constDoc.has(base.text))
129
+ return scope.constDoc.get(base.text);
130
+ const inline = singleDocQueryTable(base, lfSet);
131
+ if (inline)
132
+ return inline;
133
+ }
134
+ else if (ts.isIdentifier(base) && base.text === scope.argParam) {
135
+ // args.<field> where field is a v.id("lf") validator
136
+ return scope.argTaint.get(a.name.text);
137
+ }
138
+ }
139
+ else if (ts.isIdentifier(a)) {
140
+ // handler destructured `({ id })` where id is a v.id("lf") validator
141
+ return scope.destructuredIds.get(a.text);
142
+ }
143
+ return undefined;
144
+ }
145
+ /** Extract field -> lf table from an `args: { f: v.id("lf") }` validator object. */
146
+ function readArgValidators(argsValue, lfSet, out) {
147
+ if (!ts.isObjectLiteralExpression(argsValue))
148
+ return;
149
+ for (const prop of argsValue.properties) {
150
+ if (!ts.isPropertyAssignment(prop))
151
+ continue;
152
+ const name = prop.name;
153
+ if (!ts.isIdentifier(name) && !ts.isStringLiteral(name))
154
+ continue;
155
+ const field = name.text;
156
+ const call = prop.initializer;
157
+ // v.id("lf") — PropertyAccess `.id` called with a single string literal.
158
+ if (ts.isCallExpression(call) &&
159
+ ts.isPropertyAccessExpression(call.expression) &&
160
+ call.expression.name.text === "id") {
161
+ const tbl = call.arguments[0];
162
+ if (tbl && ts.isStringLiteralLike(tbl) && lfSet.has(tbl.text))
163
+ out.set(field, tbl.text);
164
+ }
165
+ }
166
+ }
167
+ function scriptKindFor(path) {
168
+ if (path.endsWith(".tsx"))
169
+ return ts.ScriptKind.TSX;
170
+ if (path.endsWith(".jsx"))
171
+ return ts.ScriptKind.JSX;
172
+ if (path.endsWith(".js"))
173
+ return ts.ScriptKind.JS;
174
+ return ts.ScriptKind.TS;
175
+ }
176
+ export function findIdBasedWrites(files, lfTables) {
177
+ const lfSet = new Set(lfTables);
178
+ if (lfSet.size === 0)
179
+ return [];
180
+ const violations = [];
181
+ for (const file of files) {
182
+ const sf = ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest,
183
+ /* setParentNodes */ true, scriptKindFor(file.path));
184
+ const record = (node, table, method) => {
185
+ const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
186
+ violations.push({
187
+ file: file.path,
188
+ line,
189
+ table,
190
+ method,
191
+ snippet: file.content.split("\n")[line - 1]?.trim() ?? ""
192
+ });
193
+ };
194
+ const visit = (node, scope) => {
195
+ // Convex function definition shape: someWrapper({ args?, handler }).
196
+ if (ts.isCallExpression(node) && node.arguments.length === 1) {
197
+ const arg0 = node.arguments[0];
198
+ if (ts.isObjectLiteralExpression(arg0)) {
199
+ const handlerProp = arg0.properties.find((p) => ts.isPropertyAssignment(p) &&
200
+ ts.isIdentifier(p.name) &&
201
+ p.name.text === "handler" &&
202
+ (ts.isArrowFunction(p.initializer) || ts.isFunctionExpression(p.initializer)));
203
+ if (handlerProp) {
204
+ const handlerScope = freshScope();
205
+ const argsProp = arg0.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "args");
206
+ if (argsProp)
207
+ readArgValidators(argsProp.initializer, lfSet, handlerScope.argTaint);
208
+ const handler = handlerProp.initializer;
209
+ const argsParam = handler.parameters[1];
210
+ if (argsParam) {
211
+ if (ts.isIdentifier(argsParam.name)) {
212
+ handlerScope.argParam = argsParam.name.text;
213
+ }
214
+ else if (ts.isObjectBindingPattern(argsParam.name)) {
215
+ for (const el of argsParam.name.elements) {
216
+ if (!ts.isIdentifier(el.name))
217
+ continue;
218
+ const field = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
219
+ const tbl = handlerScope.argTaint.get(field);
220
+ if (tbl)
221
+ handlerScope.destructuredIds.set(el.name.text, tbl);
222
+ }
223
+ }
224
+ }
225
+ // Visit the handler body in its own scope; visit the rest (incl. the
226
+ // args validators) in the outer scope.
227
+ if (handler.body)
228
+ visit(handler.body, handlerScope);
229
+ for (const prop of arg0.properties) {
230
+ if (prop !== handlerProp)
231
+ visit(prop, scope);
232
+ }
233
+ return;
234
+ }
235
+ }
236
+ }
237
+ // `const x = await ctx.db.query("lf")…first()/.unique()` taints x as a doc.
238
+ if (ts.isVariableStatement(node) && (node.declarationList.flags & ts.NodeFlags.Const) !== 0) {
239
+ for (const decl of node.declarationList.declarations) {
240
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
241
+ const tbl = singleDocQueryTable(decl.initializer, lfSet);
242
+ if (tbl)
243
+ scope.constDoc.set(decl.name.text, tbl);
244
+ }
245
+ }
246
+ }
247
+ // ctx.db.patch/delete/replace(<id>, …)
248
+ if (ts.isCallExpression(node)) {
249
+ const method = ctxDbMethod(node.expression);
250
+ if (method && ID_WRITE_METHODS.has(method) && node.arguments[0]) {
251
+ const tbl = classifyId(node.arguments[0], scope, lfSet);
252
+ if (tbl)
253
+ record(node, tbl, method);
254
+ }
255
+ }
256
+ node.forEachChild((child) => visit(child, scope));
257
+ };
258
+ visit(sf, freshScope());
259
+ }
260
+ return violations;
261
+ }
262
+ export function readSourceFiles(dir) {
263
+ const files = [];
264
+ const walk = (current) => {
265
+ let entries;
266
+ try {
267
+ entries = readdirSync(current);
268
+ }
269
+ catch {
270
+ return;
271
+ }
272
+ for (const entry of entries) {
273
+ if (entry === "node_modules" || entry === "_generated" || entry === "dist") {
274
+ continue;
275
+ }
276
+ const full = join(current, entry);
277
+ if (statSync(full).isDirectory()) {
278
+ walk(full);
279
+ }
280
+ else if (/\.(ts|tsx|js|jsx)$/.test(entry)) {
281
+ files.push({ path: full, content: readFileSync(full, "utf8") });
282
+ }
283
+ }
284
+ };
285
+ walk(dir);
286
+ return files;
287
+ }
288
+ /** Run the full check over a directory. Returns violations (empty = clean). */
289
+ export function runCheck(dir) {
290
+ const files = readSourceFiles(dir);
291
+ const lfTables = collectLocalFirstTables(files);
292
+ return [...findDirectWrites(files, lfTables), ...findIdBasedWrites(files, lfTables)];
293
+ }
@@ -0,0 +1,44 @@
1
+ import type { DeclarativeInsert, DeclarativePatch, DeclarativeQuery, DeclarativeRemove } from "@convex-localfirst/core/internal";
2
+ type Scope = {
3
+ kind: "byUser";
4
+ field: string;
5
+ } | {
6
+ kind: "byWorkspace";
7
+ workspaceIdField: string;
8
+ membershipTable: string;
9
+ } | {
10
+ kind: "byProject";
11
+ projectIdField: string;
12
+ membershipTable: string;
13
+ };
14
+ export type TableMeta = {
15
+ table: string;
16
+ idField: string;
17
+ conflict: string;
18
+ scope: Scope;
19
+ indexes: Record<string, readonly string[]>;
20
+ setFields?: readonly string[];
21
+ counterFields?: readonly string[];
22
+ };
23
+ export type ManifestEntry = {
24
+ type: "query";
25
+ tableMeta: TableMeta;
26
+ descriptor: DeclarativeQuery;
27
+ } | {
28
+ type: "insert";
29
+ tableMeta: TableMeta;
30
+ descriptor: DeclarativeInsert;
31
+ } | {
32
+ type: "patch";
33
+ tableMeta: TableMeta;
34
+ descriptor: DeclarativePatch;
35
+ } | {
36
+ type: "remove";
37
+ tableMeta: TableMeta;
38
+ descriptor: DeclarativeRemove;
39
+ };
40
+ /** Introspect one module's exports into manifest entries. Names become "moduleName:exportName". */
41
+ export declare function introspectExports(moduleName: string, exports: Record<string, unknown>): ManifestEntry[];
42
+ /** Emit a browser-safe manifest module (pure data + generic interpreters). */
43
+ export declare function emitManifestSource(schemaVersion: number, entries: readonly ManifestEntry[]): string;
44
+ export {};
@@ -0,0 +1,291 @@
1
+ // --- Source-based introspection (parses the closure with fn.toString) ---------
2
+ // We read how each output field is built rather than executing the closure, so
3
+ // wrappers like String()/Boolean()/Number() are handled and nothing is guessed.
4
+ /** Split top-level segments of `body` on `delim`, respecting (), {}, [] and quotes. */
5
+ function splitTopLevel(body, delim) {
6
+ const out = [];
7
+ let depth = 0;
8
+ let quote = null;
9
+ let current = "";
10
+ for (let i = 0; i < body.length; i++) {
11
+ const ch = body[i];
12
+ if (quote) {
13
+ current += ch;
14
+ if (ch === quote && body[i - 1] !== "\\")
15
+ quote = null;
16
+ continue;
17
+ }
18
+ if (ch === '"' || ch === "'" || ch === "`") {
19
+ quote = ch;
20
+ current += ch;
21
+ continue;
22
+ }
23
+ if (ch === "(" || ch === "{" || ch === "[")
24
+ depth++;
25
+ if (ch === ")" || ch === "}" || ch === "]")
26
+ depth--;
27
+ if (depth === 0 && ch === delim) {
28
+ out.push(current);
29
+ current = "";
30
+ continue;
31
+ }
32
+ current += ch;
33
+ }
34
+ if (current.trim())
35
+ out.push(current);
36
+ return out;
37
+ }
38
+ /** Extract the returned object-literal body (the text between the outer braces). */
39
+ function returnedObjectBody(fnSource, fnName) {
40
+ const arrow = fnSource.indexOf("=>");
41
+ let body = arrow >= 0 ? fnSource.slice(arrow + 2).trim() : fnSource;
42
+ if (body.startsWith("("))
43
+ body = body.slice(1, body.lastIndexOf(")")).trim();
44
+ if (body.startsWith("{") && !body.includes("return")) {
45
+ return body.slice(1, body.lastIndexOf("}"));
46
+ }
47
+ // Block body: take the expression after `return`.
48
+ const ret = body.indexOf("return");
49
+ if (ret >= 0) {
50
+ let expr = body.slice(ret + 6).trim();
51
+ if (expr.endsWith(";"))
52
+ expr = expr.slice(0, -1).trim();
53
+ if (expr.startsWith("("))
54
+ expr = expr.slice(1, expr.lastIndexOf(")")).trim();
55
+ if (expr.startsWith("{"))
56
+ return expr.slice(1, expr.lastIndexOf("}"));
57
+ }
58
+ throw new Error(`codegen: cannot parse the object returned by "${fnName}".`);
59
+ }
60
+ /** Classify a single value expression into a FieldSource. */
61
+ function classifyExpr(raw, fnName, field) {
62
+ let expr = raw.trim();
63
+ const wrap = /^(?:String|Number|Boolean)\((.*)\)$/.exec(expr);
64
+ if (wrap)
65
+ expr = wrap[1].trim();
66
+ const argDot = /^args\.([A-Za-z_$][\w$]*)$/.exec(expr);
67
+ if (argDot)
68
+ return { from: "arg", arg: argDot[1] };
69
+ const argIdx = /^args\[\s*['"]([^'"]+)['"]\s*\]$/.exec(expr);
70
+ if (argIdx)
71
+ return { from: "arg", arg: argIdx[1] };
72
+ if (/^auth\.userId$/.test(expr))
73
+ return { from: "auth" };
74
+ if (/^now$/.test(expr))
75
+ return { from: "now" };
76
+ if (expr === "true")
77
+ return { from: "const", value: true };
78
+ if (expr === "false")
79
+ return { from: "const", value: false };
80
+ if (expr === "null")
81
+ return { from: "const", value: null };
82
+ if (/^-?\d+(\.\d+)?$/.test(expr))
83
+ return { from: "const", value: Number(expr) };
84
+ const str = /^['"](.*)['"]$/.exec(expr);
85
+ if (str)
86
+ return { from: "const", value: str[1] };
87
+ throw new Error(`codegen: field "${field}" in "${fnName}" uses an unsupported expression \`${raw.trim()}\` — use a literal, args.x, auth.userId, or now.`);
88
+ }
89
+ function parseFields(fnSource, fnName) {
90
+ const body = returnedObjectBody(fnSource, fnName);
91
+ const fields = {};
92
+ for (const pair of splitTopLevel(body, ",")) {
93
+ const trimmed = pair.trim();
94
+ if (!trimmed)
95
+ continue; // trailing comma / empty segment
96
+ const colon = trimmed.indexOf(":");
97
+ if (colon < 0) {
98
+ // Shorthand ({ x }), spread ({ ...args }), or a method can't be statically
99
+ // mapped to a FieldSource. FAIL CLOSED — silently skipping would emit a manifest
100
+ // missing fields, which then pushes wrong/empty values to the server.
101
+ throw new Error(`codegen: field segment \`${trimmed}\` in "${fnName}" is not a "key: expr" pair. ` +
102
+ `Shorthand, spread (...), and methods are unsupported — write explicit "field: args.x" entries.`);
103
+ }
104
+ const rawKey = trimmed.slice(0, colon).trim();
105
+ if (rawKey.startsWith("[") || rawKey.includes("(")) {
106
+ throw new Error(`codegen: computed/method key \`${rawKey}\` in "${fnName}" is unsupported — use a static field name.`);
107
+ }
108
+ const key = rawKey.replace(/^['"]|['"]$/g, "");
109
+ fields[key] = classifyExpr(trimmed.slice(colon + 1), fnName, key);
110
+ }
111
+ return fields;
112
+ }
113
+ function idArgOf(idFn, fnName) {
114
+ const source = String(idFn);
115
+ const arrow = source.indexOf("=>");
116
+ let expr = arrow >= 0 ? source.slice(arrow + 2).trim() : source;
117
+ if (expr.endsWith(";"))
118
+ expr = expr.slice(0, -1).trim();
119
+ const wrap = /^(?:String|Number)\((.*)\)$/.exec(expr);
120
+ if (wrap)
121
+ expr = wrap[1].trim();
122
+ const m = /^args\.([A-Za-z_$][\w$]*)$/.exec(expr) ?? /^args\[\s*['"]([^'"]+)['"]\s*\]$/.exec(expr);
123
+ if (m)
124
+ return m[1];
125
+ throw new Error(`codegen: cannot determine the id arg for "${fnName}" (id() must return args.<field>).`);
126
+ }
127
+ /** The id arg from an explicit `id()` closure, or — when omitted — by convention: the arg
128
+ * named "id" (the dominant REST-y convention; note the id ARG and the row idField are
129
+ * different axes), else the arg named after the table's idField. FAILS CLOSED if neither
130
+ * exists: defaulting to a non-existent arg would emit a descriptor whose patch/remove has
131
+ * no id at runtime, so demand an explicit id(). */
132
+ function idArgOrDefault(spec, idField, fnName) {
133
+ if (spec.id)
134
+ return idArgOf(spec.id, fnName);
135
+ const argKeys = Object.keys(spec.args ?? {});
136
+ if (argKeys.includes("id"))
137
+ return "id";
138
+ if (argKeys.includes(idField))
139
+ return idField;
140
+ throw new Error(`codegen: "${fnName}" omits id() but has neither an "id" arg nor one named "${idField}" (the table's idField). ` +
141
+ `Add id: ({ args }) => args.<field>.`);
142
+ }
143
+ /** Default patch when `patch()` is omitted: forward every declared arg 1:1 (field === arg
144
+ * name) EXCEPT the id arg (you never patch the id). This is the common "update these
145
+ * fields" case; a table that also sets computed fields (e.g. updated_at: now) writes an
146
+ * explicit patch() instead. */
147
+ function defaultPatchFields(spec, idArg) {
148
+ const fields = {};
149
+ for (const key of Object.keys(spec.args ?? {})) {
150
+ if (key === idArg)
151
+ continue;
152
+ fields[key] = { from: "arg", arg: key };
153
+ }
154
+ return fields;
155
+ }
156
+ function scopeField(scope) {
157
+ if (scope.kind === "byUser")
158
+ return scope.field;
159
+ if (scope.kind === "byWorkspace")
160
+ return scope.workspaceIdField;
161
+ if (scope.kind === "byProject")
162
+ return scope.projectIdField;
163
+ return undefined;
164
+ }
165
+ function tableMetaOf(meta) {
166
+ return {
167
+ table: meta.tableName,
168
+ idField: meta.idField,
169
+ conflict: meta.conflict,
170
+ scope: meta.scope,
171
+ indexes: meta.indexes,
172
+ setFields: meta.setFields,
173
+ counterFields: meta.counterFields
174
+ };
175
+ }
176
+ /** Introspect one module's exports into manifest entries. Names become "moduleName:exportName". */
177
+ export function introspectExports(moduleName, exports) {
178
+ const entries = [];
179
+ for (const [exportName, value] of Object.entries(exports)) {
180
+ const meta = value?.__convexLocalFirst;
181
+ if (!meta)
182
+ continue;
183
+ const name = `${moduleName}:${exportName}`;
184
+ const tableMeta = tableMetaOf(meta);
185
+ const spec = meta.spec;
186
+ if (meta.kind === "query") {
187
+ const argKeys = Object.keys(spec.args ?? {});
188
+ const indexCols = (meta.indexes[String(spec.index)] ?? []);
189
+ const sortField = indexCols.find((c) => c !== scopeField(meta.scope) && !argKeys.includes(c));
190
+ entries.push({
191
+ type: "query",
192
+ tableMeta,
193
+ descriptor: {
194
+ name,
195
+ table: meta.tableName,
196
+ filters: argKeys,
197
+ orderBy: sortField,
198
+ order: spec.order ?? "asc",
199
+ initial: spec.initial,
200
+ // Workspace/project queries carry their pull scope (value comes from the
201
+ // arg named after the scope field). byUser is derived by the engine.
202
+ scope: meta.scope.kind === "byWorkspace"
203
+ ? { kind: "byWorkspace", valueArg: meta.scope.workspaceIdField }
204
+ : meta.scope.kind === "byProject"
205
+ ? { kind: "byProject", valueArg: meta.scope.projectIdField }
206
+ : undefined
207
+ }
208
+ });
209
+ }
210
+ else if (meta.kind === "insert") {
211
+ entries.push({
212
+ type: "insert",
213
+ tableMeta,
214
+ descriptor: { name, table: meta.tableName, fields: parseFields(String(spec.value), name) }
215
+ });
216
+ }
217
+ else if (meta.kind === "patch") {
218
+ const idArg = idArgOrDefault(spec, meta.idField, name);
219
+ entries.push({
220
+ type: "patch",
221
+ tableMeta,
222
+ descriptor: {
223
+ name,
224
+ table: meta.tableName,
225
+ idArg,
226
+ fields: spec.patch ? parseFields(String(spec.patch), name) : defaultPatchFields(spec, idArg)
227
+ }
228
+ });
229
+ }
230
+ else if (meta.kind === "remove") {
231
+ entries.push({
232
+ type: "remove",
233
+ tableMeta,
234
+ descriptor: { name, table: meta.tableName, idArg: idArgOrDefault(spec, meta.idField, name) }
235
+ });
236
+ }
237
+ }
238
+ return entries;
239
+ }
240
+ const BUILDER = {
241
+ query: "declarativeQuery",
242
+ insert: "declarativeInsert",
243
+ patch: "declarativePatch",
244
+ remove: "declarativeRemove"
245
+ };
246
+ /** Emit a browser-safe manifest module (pure data + generic interpreters). */
247
+ export function emitManifestSource(schemaVersion, entries) {
248
+ const tables = new Map();
249
+ for (const entry of entries)
250
+ tables.set(entry.tableMeta.table, entry.tableMeta);
251
+ const tableLines = [...tables.values()].map((t) => ` ${JSON.stringify(t.table)}: localTable(${JSON.stringify({
252
+ table: t.table,
253
+ idField: t.idField,
254
+ scope: t.scope,
255
+ conflict: t.conflict,
256
+ indexes: t.indexes,
257
+ // Only emitted when declared, so tables without set/counter fields stay byte-identical.
258
+ ...(t.setFields && t.setFields.length ? { setFields: t.setFields } : {}),
259
+ ...(t.counterFields && t.counterFields.length ? { counterFields: t.counterFields } : {})
260
+ })})`);
261
+ const queryLines = entries
262
+ .filter((e) => e.type === "query")
263
+ .map((e) => ` ${JSON.stringify(e.descriptor.name)}: declarativeQuery(${JSON.stringify(e.descriptor)})`);
264
+ const mutationLines = entries
265
+ .filter((e) => e.type !== "query")
266
+ .map((e) => ` ${JSON.stringify(e.descriptor.name)}: ${BUILDER[e.type]}(${JSON.stringify(e.descriptor)})`);
267
+ return `// GENERATED by convex-localfirst codegen. Do not edit by hand.
268
+ import { defineLocalFirstManifest, localTable } from "@convex-localfirst/core";
269
+ // Manifest interpreters are internal (I13 / GOAL §6 "no manifest interpreters" in the
270
+ // public surface); generated code is a legitimate internal consumer of them.
271
+ import {
272
+ declarativeInsert,
273
+ declarativePatch,
274
+ declarativeQuery,
275
+ declarativeRemove
276
+ } from "@convex-localfirst/core/internal";
277
+
278
+ export const localFirstManifest = defineLocalFirstManifest({
279
+ schemaVersion: ${schemaVersion},
280
+ tables: {
281
+ ${tableLines.join(",\n")}
282
+ },
283
+ queries: {
284
+ ${queryLines.join(",\n")}
285
+ },
286
+ mutations: {
287
+ ${mutationLines.join(",\n")}
288
+ }
289
+ });
290
+ `;
291
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { runCheck } from "./check.js";
6
+ import { emitManifestSource, introspectExports } from "./codegen.js";
7
+ const command = process.argv[2] ?? "help";
8
+ if (command === "init") {
9
+ init();
10
+ }
11
+ else if (command === "codegen") {
12
+ await codegen();
13
+ }
14
+ else if (command === "check") {
15
+ check();
16
+ }
17
+ else if (command === "dev") {
18
+ await dev();
19
+ }
20
+ else {
21
+ help();
22
+ }
23
+ function init() {
24
+ mkdirSync("convex", { recursive: true });
25
+ mkdirSync("src", { recursive: true });
26
+ // A COMPLETE, compiling, working starter: schema + factory + component mount +
27
+ // server sync surface + one example table. Running `init` then `codegen` then
28
+ // `npx convex dev` gives a live local-first backend with no hand-written sync.
29
+ // Every file is write-if-absent, so re-running init never clobbers your edits.
30
+ const files = [
31
+ {
32
+ path: join("convex", "schema.ts"),
33
+ content: `import { defineSchema, defineTable } from "convex/server";
34
+ import { v } from "convex/values";
35
+
36
+ // Your app's own tables. ALL local-first sync bookkeeping (ledger / change log /
37
+ // id map / cursors / tombstones) lives in the mounted @convex-localfirst/component,
38
+ // so your schema only declares domain tables. Each local-first table needs a
39
+ // "localId" field + a "by_localId" index; scope indexes power the per-scope pull.
40
+ export default defineSchema({
41
+ todos: defineTable({
42
+ localId: v.string(),
43
+ ownerId: v.string(),
44
+ text: v.string(),
45
+ done: v.boolean(),
46
+ createdAt: v.number(),
47
+ updatedAt: v.number()
48
+ })
49
+ .index("by_localId", ["localId"])
50
+ .index("byOwner", ["ownerId", "createdAt"])
51
+ });
52
+ `
53
+ },
54
+ {
55
+ path: join("convex", "localfirst.ts"),
56
+ content: `import { createLocalFirst } from "@convex-localfirst/server";
57
+ import schema from "./schema";
58
+
59
+ // Auth is resolved server-side at sync time (convex/sync.ts → createSyncFunctions),
60
+ // not here — this factory only declares the local-first tables.
61
+ export const lf = createLocalFirst({
62
+ schema,
63
+ defaults: { idField: "localId", conflict: "fieldLww" }
64
+ });
65
+ `
66
+ },
67
+ {
68
+ path: join("convex", "convex.config.ts"),
69
+ content: `import { defineApp } from "convex/server";
70
+ import localfirst from "@convex-localfirst/component/convex.config.js";
71
+
72
+ // Mount the local-first component — the whole "no hand-written backend" promise:
73
+ // the sync ledger / change log / id map / cursors / tombstones come as a drop-in,
74
+ // referenced via components.convexLocalFirst.* in convex/sync.ts.
75
+ const app = defineApp();
76
+ app.use(localfirst);
77
+
78
+ export default app;
79
+ `
80
+ },
81
+ {
82
+ path: join("convex", "sync.ts"),
83
+ content: `import { createSyncFunctions } from "@convex-localfirst/server";
84
+ import { components } from "./_generated/api";
85
+ import { mutation, query } from "./_generated/server";
86
+
87
+ // The entire server sync surface: declare which app tables are local-first + how
88
+ // they're scoped. createSyncFunctions wires app rows to ctx.db and all sync
89
+ // bookkeeping to the mounted component. Add a table here AND in convex/<table>.ts.
90
+ export const { push, pull } = createSyncFunctions({
91
+ component: components.convexLocalFirst,
92
+ mutation,
93
+ query,
94
+ tables: {
95
+ todos: { scope: { kind: "byUser" as const, field: "ownerId" }, idField: "localId", conflict: "fieldLww" as const }
96
+ },
97
+ // Local dev with no auth provider trusts the client-supplied userId. In
98
+ // production, DELETE this line and resolve identity from ctx.auth instead.
99
+ devUnsafeAllowClientUserId: true
100
+ });
101
+ `
102
+ },
103
+ {
104
+ path: join("convex", "todos.ts"),
105
+ content: `import { v } from "convex/values";
106
+ import { lf } from "./localfirst";
107
+
108
+ // One local-first table. query/insert/patch/remove run OPTIMISTICALLY on the client
109
+ // and sync via convex/sync.ts — never call these handlers server-side.
110
+ const todos = lf.table("todos", {
111
+ scope: lf.byUser("ownerId"),
112
+ indexes: { byOwner: ["ownerId", "createdAt"] }
113
+ });
114
+
115
+ export const list = todos.query({
116
+ args: {},
117
+ index: "byOwner",
118
+ key: ({ auth }) => [auth.userId],
119
+ order: "asc",
120
+ initial: []
121
+ });
122
+
123
+ export const create = todos.insert({
124
+ args: { text: v.string() },
125
+ value: ({ auth, args, now }) => ({
126
+ ownerId: auth.userId,
127
+ text: String(args.text),
128
+ done: false,
129
+ createdAt: now,
130
+ updatedAt: now
131
+ })
132
+ });
133
+
134
+ // id() defaults to the "id" arg; patch() is explicit only because it computes updatedAt.
135
+ export const toggle = todos.patch({
136
+ args: { id: v.string(), done: v.boolean() },
137
+ patch: ({ args, now }) => ({ done: Boolean(args.done), updatedAt: now })
138
+ });
139
+
140
+ // No id() / patch() needed — remove defaults to the "id" arg.
141
+ export const remove = todos.remove({ args: { id: v.string() } });
142
+ `
143
+ }
144
+ ];
145
+ const created = [];
146
+ const skipped = [];
147
+ for (const { path, content } of files) {
148
+ if (existsSync(path)) {
149
+ skipped.push(path);
150
+ continue;
151
+ }
152
+ writeFileSync(path, content);
153
+ created.push(path);
154
+ }
155
+ console.log("convex-localfirst init: scaffolded a complete local-first starter.");
156
+ if (created.length)
157
+ console.log(` created: ${created.join(", ")}`);
158
+ if (skipped.length)
159
+ console.log(` kept (already existed): ${skipped.join(", ")}`);
160
+ console.log(`
161
+ Next:
162
+ 1. npx convex-localfirst codegen # generate src/convex-localfirst/generated.ts from the DSL
163
+ 2. npx convex dev # run the backend + live sync
164
+ 3. Wire the React client: <ConvexProvider client={convex} localFirst={{ manifest, transport, store }}>
165
+ then useLiveQuery / useMutation (see the README "React hooks" section).`);
166
+ }
167
+ async function codegen() {
168
+ const convexDir = existsSync("convex") ? "convex" : ".";
169
+ const skip = new Set(["localfirst.ts", "schema.ts", "convex.config.ts"]);
170
+ const moduleFiles = readdirSync(convexDir).filter((f) => /\.(ts|js)$/.test(f) && !f.endsWith(".d.ts") && !skip.has(f));
171
+ const entries = [];
172
+ const schemaVersion = 1;
173
+ for (const file of moduleFiles) {
174
+ const moduleName = file.replace(/\.(ts|js)$/, "");
175
+ try {
176
+ const mod = await import(pathToFileURL(resolve(convexDir, file)).href);
177
+ entries.push(...introspectExports(moduleName, mod));
178
+ }
179
+ catch (error) {
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ if (/Unknown file extension|ERR_UNKNOWN_FILE_EXTENSION/.test(message)) {
182
+ console.error(`convex-localfirst codegen: cannot import ${file} as raw TypeScript. Run codegen under a TS loader, e.g.\n node --import tsx node_modules/@convex-localfirst/cli/dist/index.js codegen`);
183
+ process.exitCode = 1;
184
+ return;
185
+ }
186
+ // A module that can't be imported standalone (e.g. it imports ./_generated
187
+ // or a mounted component — Convex-runtime-only) is not a local-first table
188
+ // module, so skip it rather than failing the whole codegen run.
189
+ console.warn(`convex-localfirst codegen: skipped ${file} (not importable standalone: ${message.split("\n")[0]})`);
190
+ }
191
+ }
192
+ if (entries.length === 0) {
193
+ console.error("convex-localfirst codegen: found no local-first functions. Define tables with `lf.table(...).query/insert/patch/remove`.");
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ const outDir = join("src", "convex-localfirst");
198
+ const outFile = join(outDir, "generated.ts");
199
+ mkdirSync(outDir, { recursive: true });
200
+ writeFileSync(outFile, emitManifestSource(schemaVersion, entries));
201
+ const tables = new Set(entries.map((e) => e.tableMeta.table));
202
+ console.log(`convex-localfirst codegen: wrote ${outFile} (${entries.length} functions across ${tables.size} table(s)).`);
203
+ }
204
+ // Coverage: regex catches ctx.db.insert("<lfTable>", …); an AST taint pass catches
205
+ // ctx.db.patch/delete/replace on ids that provably come from a local-first table
206
+ // (handler `v.id("lf")` args, `const doc = await ctx.db.query("lf")…first()`, and
207
+ // inline query-then-write). The pass is sound (no false positives) but function-scoped:
208
+ // ids passed across function boundaries or through ctx.db.get() are not traced.
209
+ const CHECK_SCOPE_NOTE = "note: catches ctx.db.insert + id-based patch/delete/replace whose id is traceable to a local-first table within one function; ids passed across functions or via ctx.db.get() are not traced — review those manually.";
210
+ function check() {
211
+ const dir = existsSync("convex") ? "convex" : ".";
212
+ const violations = runCheck(dir);
213
+ if (violations.length === 0) {
214
+ console.log("convex-localfirst check: no direct writes to local-first tables found");
215
+ console.log(`convex-localfirst check: ${CHECK_SCOPE_NOTE}`);
216
+ return;
217
+ }
218
+ console.error(`convex-localfirst check: found ${violations.length} direct write(s) to local-first tables:`);
219
+ for (const v of violations) {
220
+ console.error(` ${v.file}:${v.line} ctx.db.${v.method}("${v.table}", ...) — use the generated wrapper`);
221
+ console.error(` ${v.snippet}`);
222
+ }
223
+ console.error(`convex-localfirst check: ${CHECK_SCOPE_NOTE}`);
224
+ process.exitCode = 1;
225
+ }
226
+ async function dev() {
227
+ // The dev-time pipeline: regenerate the client manifest from the DSL, then
228
+ // statically verify nothing writes a local-first table directly. The Convex
229
+ // backend itself is run separately with `npx convex dev`.
230
+ console.log("convex-localfirst dev: regenerating manifest + checking for direct local-first writes…");
231
+ await codegen();
232
+ check();
233
+ console.log("convex-localfirst dev: done. Run `npx convex dev` for the backend and live sync.");
234
+ }
235
+ function help() {
236
+ console.log(`convex-localfirst commands:
237
+ init
238
+ codegen
239
+ check
240
+ dev`);
241
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@convex-localfirst/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for convex-localfirst: codegen (client manifest from lf.table), check (catch direct LF-table writes), dev.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "convex",
8
+ "local-first",
9
+ "cli",
10
+ "codegen"
11
+ ],
12
+ "type": "module",
13
+ "bin": {
14
+ "convex-localfirst": "./dist/index.js"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "peerDependencies": {
23
+ "typescript": ">=5.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.10.0",
27
+ "convex": ">=1.0.0",
28
+ "typescript": "^5.7.0",
29
+ "vitest": "^2.1.0",
30
+ "@convex-localfirst/server": "^0.1.0",
31
+ "@convex-localfirst/core": "^0.1.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json --noEmit false --emitDeclarationOnly false",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "test": "vitest run --passWithNoTests"
37
+ }
38
+ }