@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 +21 -0
- package/README.md +16 -0
- package/dist/check.d.ts +22 -0
- package/dist/check.js +293 -0
- package/dist/codegen.d.ts +44 -0
- package/dist/codegen.js +291 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +241 -0
- package/package.json +38 -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,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
|
package/dist/check.d.ts
ADDED
|
@@ -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 {};
|
package/dist/codegen.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}
|