@fargowealth/fargo-ai-cli 0.7.3 → 1.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ - Replaced regex-based D1 migration checks with token-structured parsing that
6
+ handles comments, literals, identifiers, parentheses, and statement shape.
7
+ - Require `--table-prefix` for every schema and controlled data migration plan
8
+ and apply; table names, index names, index targets, and foreign-key references
9
+ must all remain under that App prefix.
10
+ - Preserve the separate approval requirement for controlled data mutations;
11
+ the hosted service independently derives and verifies the authoritative App
12
+ prefix before executing the migration as an atomic D1 batch.
13
+
3
14
  ## 0.7.3
4
15
 
5
16
  - Added one-command Agent installation through `fargo agent install --agent`
package/README.md CHANGED
@@ -9,7 +9,7 @@ Requires Node.js 20.19 or newer.
9
9
  ## Install or upgrade
10
10
 
11
11
  ```sh
12
- npm install -g @fargowealth/fargo-ai-cli@0.7.3
12
+ npm install -g @fargowealth/fargo-ai-cli@latest
13
13
  fargo version
14
14
  ```
15
15
 
@@ -43,8 +43,8 @@ reused by both MCP and local CLI operations.
43
43
 
44
44
  ```sh
45
45
  fargo mcp tools
46
- fargo mcp call lego_get_app --args '{"appId":"app_xxx"}'
47
- fargo mcp call lego_get_app --args-file ./arguments.json
46
+ fargo mcp call lego_app_read --args '{"action":"get","appId":"app_xxx"}'
47
+ fargo mcp call lego_app_read --args-file ./arguments.json
48
48
  ```
49
49
 
50
50
  ## LEGO source snapshots
@@ -146,9 +146,9 @@ Deployment artifacts preserve binary bytes and include
146
146
  ## LEGO D1 migrations
147
147
 
148
148
  ```sh
149
- # Schema-only migrations keep the existing flow
150
- fargo lego d1 migrate plan --app app_xxx --dir migrations
151
- fargo lego d1 migrate apply --app app_xxx --dir migrations
149
+ # Schema-only migrations also require the authoritative local prefix assertion
150
+ fargo lego d1 migrate plan --app app_xxx --dir migrations --table-prefix app_code_
151
+ fargo lego d1 migrate apply --app app_xxx --dir migrations --table-prefix app_code_
152
152
 
153
153
  # Controlled data migrations: review the reported impact first
154
154
  fargo lego d1 migrate plan \
@@ -170,13 +170,15 @@ production registry; `apply` stops on content drift and submits only pending
170
170
  files. The plan reports statement types, data-mutation types, target tables,
171
171
  and whether separate data-migration approval is required.
172
172
 
173
- Schema DDL remains the default. The opt-in data mode accepts `INSERT ... VALUES`
174
- with optional `ON CONFLICT`, plus `UPDATE` and `DELETE` only when they contain a
175
- `WHERE` clause. Data targets must use the server-derived App table prefix;
173
+ Schema DDL remains the default. Every migration command requires
174
+ `--table-prefix`; the token parser applies it to table names, index names, index
175
+ targets, and foreign-key references before MCP is contacted. The opt-in data
176
+ mode accepts `INSERT ... VALUES` with optional `ON CONFLICT`, plus `UPDATE` and
177
+ `DELETE` only when they contain a `WHERE` clause. Data targets must use the server-derived App table prefix;
176
178
  system tables, cross-table queries, `PRAGMA`, `ATTACH`, and `DROP` remain
177
- blocked. `--table-prefix` is a local assertion and the service rejects it if it
178
- does not match the App's authoritative prefix. Migration execution remains
179
- server-side and is never coupled to deploy.
179
+ blocked. The service independently derives the authoritative prefix and rejects
180
+ a mismatched client assertion. Each migration file executes as one transactional
181
+ D1 batch, server-side and independently from deploy.
180
182
 
181
183
  ## Compatibility and repair
182
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fargowealth/fargo-ai-cli",
3
- "version": "0.7.3",
3
+ "version": "1.0.0",
4
4
  "description": "Agent-neutral Fargo AI CLI, MCP client, and LEGO project tooling",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,14 +7,15 @@ import { normalizeAgentType } from "./auth.mjs";
7
7
  import { CLI_VERSION } from "./constants.mjs";
8
8
 
9
9
  const PACKAGE_NAME = "@fargowealth/fargo-ai-cli";
10
- const SERVER_NAME = "fargo-ai";
10
+ const SERVER_NAME = "fargo-pass";
11
+ const LEGACY_SERVER_NAME = "fargo-ai";
11
12
 
12
13
  export async function installAgentMcp(rawAgent, options = {}) {
13
14
  const agent = normalizeAgentType(rawAgent);
14
15
  const platform = options.platform ?? process.platform;
15
16
  const env = options.env ?? process.env;
16
17
  const homeDirectory = options.home ?? homedir();
17
- const packageSpec = `${PACKAGE_NAME}@${options.packageVersion ?? CLI_VERSION}`;
18
+ const packageSpec = `${PACKAGE_NAME}@${options.packageVersion ?? "latest"}`;
18
19
  const npxCommand = options.npxCommand ?? (platform === "darwin"
19
20
  ? await resolveNpxCommand({ env, execPath: options.execPath, platform })
20
21
  : "npx");
@@ -96,10 +97,14 @@ async function installJsonMcp(agent, server, options) {
96
97
  env: server.env
97
98
  };
98
99
  const previousServer = config.mcpServers?.[SERVER_NAME];
99
- const changed = JSON.stringify(previousServer) !== JSON.stringify(nextServer);
100
+ const hadLegacyServer = Boolean(config.mcpServers?.[LEGACY_SERVER_NAME]);
101
+ const changed = hadLegacyServer || JSON.stringify(previousServer) !== JSON.stringify(nextServer);
100
102
 
101
103
  if (changed) {
102
- config.mcpServers = { ...(config.mcpServers ?? {}), [SERVER_NAME]: nextServer };
104
+ const nextServers = { ...(config.mcpServers ?? {}) };
105
+ delete nextServers[LEGACY_SERVER_NAME];
106
+ nextServers[SERVER_NAME] = nextServer;
107
+ config.mcpServers = nextServers;
103
108
  await writeConfig(options.configPath, config);
104
109
  }
105
110
 
@@ -114,9 +119,12 @@ async function installHostCliMcp(agent, server, options) {
114
119
  const removeArgs = agent === "codex"
115
120
  ? ["mcp", "remove", SERVER_NAME]
116
121
  : ["mcp", "remove", "--scope", "user", SERVER_NAME];
117
- const removeResult = await runHostCommand(command, removeArgs, options);
118
- if (removeResult.exitCode !== 0 && !isMissingServer(removeResult)) {
119
- throw new Error(`${agentLabel(agent)} could not replace the existing ${SERVER_NAME} MCP entry: ${commandError(removeResult)}`);
122
+ for (const candidate of [SERVER_NAME, LEGACY_SERVER_NAME]) {
123
+ const candidateRemoveArgs = removeArgs.map((value) => value === SERVER_NAME ? candidate : value);
124
+ const removeResult = await runHostCommand(command, candidateRemoveArgs, options);
125
+ if (removeResult.exitCode !== 0 && !isMissingServer(removeResult)) {
126
+ throw new Error(`${agentLabel(agent)} could not replace the existing ${candidate} MCP entry: ${commandError(removeResult)}`);
127
+ }
120
128
  }
121
129
 
122
130
  const environmentArgs = Object.entries(server.env).flatMap(([key, value]) => agent === "codex"
@@ -181,7 +189,7 @@ function installationResult(agent, packageSpec, changed, extra) {
181
189
  package: packageSpec,
182
190
  ...extra,
183
191
  next_action: agent === "workbuddy"
184
- ? "Open WorkBuddy Custom Connectors, trust and enable fargo-ai, then start a new task."
192
+ ? "Open WorkBuddy Custom Connectors, trust and enable fargo-pass, then start a new task."
185
193
  : `Restart ${agentLabel(agent)} or start a new Agent session, then call ping_who_am_i.`
186
194
  };
187
195
  }
package/src/cli.mjs CHANGED
@@ -112,7 +112,7 @@ export async function runCli(args, options = {}) {
112
112
  stdout.write(`${JSON.stringify(await applyLegoMigrations(store, input, options))}\n`);
113
113
  return;
114
114
  }
115
- throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> [--dir migrations] [--table-prefix app_prefix_] [--allow-data-migrations]");
115
+ throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> --table-prefix <app_prefix_> [--dir migrations] [--allow-data-migrations]");
116
116
  }
117
117
  if (args[0] === "agent" && args[1] === "install") {
118
118
  await installAgentConnection(args, requiredOption(args, "--agent"), stdout, store, options);
package/src/constants.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export const CLI_VERSION = "0.7.3";
1
+ export const CLI_VERSION = "1.0.0";
2
2
  export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
3
3
  export const DEFAULT_AUTH_URL = "https://auth.fargowealth.app";
4
4
  export const DEFAULT_API_URL = "https://mcp.fargowealth.app";
@@ -60,8 +60,8 @@ export async function deployLocalLegoApp(store, input, options = {}) {
60
60
  const deployArtifactId = uploadedArtifact.deployArtifact?.id;
61
61
  if (!deployArtifactId) throw new Error("Deployment artifact upload did not return deployArtifact.id");
62
62
 
63
- const tool = target === "pages" ? "lego_pages_deploy" : "lego_worker_deploy";
64
63
  const deploymentInput = {
64
+ action: target,
65
65
  appId: input.appId,
66
66
  deployArtifactId,
67
67
  entrypoint: artifact.entrypoint,
@@ -70,7 +70,7 @@ export async function deployLocalLegoApp(store, input, options = {}) {
70
70
  versionName: input.versionName,
71
71
  ...(sourceSnapshotId ? { sourceSnapshotId } : {})
72
72
  };
73
- const deployment = mcpToolPayload(await callMcpTool(store, tool, deploymentInput, options));
73
+ const deployment = mcpToolPayload(await callMcpTool(store, "lego_deploy_prepare", deploymentInput, options));
74
74
 
75
75
  return {
76
76
  appId: input.appId,
@@ -3,6 +3,7 @@ import { lstat, opendir, readFile } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  import { callMcpTool } from "./mcp-client.mjs";
5
5
  import { mcpToolPayload } from "./mcp-result.mjs";
6
+ import { analyzeMigrationStatement, parseMigrationStatements } from "./migration-sql.mjs";
6
7
 
7
8
  const MAX_MIGRATION_BYTES = 80_000;
8
9
  const MAX_MIGRATION_FILES = 500;
@@ -13,7 +14,7 @@ export async function planLegoMigrations(store, input, options = {}) {
13
14
 
14
15
  async function buildMigrationPlan(store, input, options) {
15
16
  const local = await readMigrationDirectory(input.directory, { tablePrefix: input.tablePrefix });
16
- const remote = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_list", { appId: input.appId }, options));
17
+ const remote = mcpToolPayload(await callMcpTool(store, "lego_database", { action: "migrations_list", appId: input.appId }, options));
17
18
  const applied = new Map((remote.migrations ?? []).map((migration) => [String(migration.version), migration]));
18
19
  const migrations = local.map((migration) => {
19
20
  const existing = applied.get(migration.version);
@@ -61,7 +62,8 @@ export async function applyLegoMigrations(store, input, options = {}) {
61
62
 
62
63
  const applied = [];
63
64
  for (const migration of plan.migrations.filter((item) => item.status === "pending")) {
64
- const result = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_apply", {
65
+ const result = mcpToolPayload(await callMcpTool(store, "lego_database", {
66
+ action: "migration_apply",
65
67
  appId: input.appId,
66
68
  allowDataMigrations: migration.impact.dataStatementCount > 0 && input.allowDataMigrations === true,
67
69
  contentSha256: migration.contentSha256,
@@ -163,166 +165,3 @@ export function analyzeLegoMigrationSql(sql, fileName = "migration.sql", options
163
165
  }
164
166
 
165
167
  export const validateLegoMigrationSql = analyzeLegoMigrationSql;
166
-
167
- function parseMigrationStatements(sql) {
168
- const statements = [];
169
- let current = "";
170
- let quote;
171
- let blockComment = false;
172
- let lineComment = false;
173
-
174
- for (let index = 0; index < sql.length; index += 1) {
175
- const char = sql[index];
176
- const next = sql[index + 1];
177
- if (blockComment) {
178
- if (char === "*" && next === "/") {
179
- blockComment = false;
180
- index += 1;
181
- }
182
- continue;
183
- }
184
- if (lineComment) {
185
- if (char === "\n") lineComment = false;
186
- continue;
187
- }
188
- if (!quote && char === "/" && next === "*") {
189
- blockComment = true;
190
- current += " ";
191
- index += 1;
192
- continue;
193
- }
194
- if (!quote && char === "-" && next === "-") {
195
- lineComment = true;
196
- current += " ";
197
- index += 1;
198
- continue;
199
- }
200
- if ((char === "'" || char === '"') && sql[index - 1] !== "\\") {
201
- quote = quote === char ? undefined : quote ?? char;
202
- }
203
- if (!quote && char === ";") {
204
- const statement = current.trim();
205
- if (statement) statements.push(statement);
206
- current = "";
207
- continue;
208
- }
209
- current += char;
210
- }
211
- if (blockComment) throw new Error("Migration SQL contains an unterminated block comment");
212
-
213
- const trailing = current.trim();
214
- if (trailing) statements.push(trailing);
215
- return statements;
216
- }
217
-
218
- function analyzeMigrationStatement(statement, options = {}) {
219
- const normalized = statement.replace(/\s+/g, " ").trim();
220
- const createTable = normalized.match(/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i);
221
- if (createTable?.[1]) {
222
- assertMigrationIdentifier(createTable[1]);
223
- assertOwnedReferences(normalized, options.tablePrefix);
224
- return { category: "schema", table: createTable[1], type: "create_table" };
225
- }
226
-
227
- const createIndex = normalized.match(/^CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+ON\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i);
228
- if (createIndex?.[1] && createIndex[2]) {
229
- assertMigrationIdentifier(createIndex[1]);
230
- assertMigrationIdentifier(createIndex[2]);
231
- return { category: "schema", table: createIndex[2], type: "create_index" };
232
- }
233
-
234
- const alterTable = normalized.match(/^ALTER\s+TABLE\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+ADD\s+COLUMN\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
235
- if (alterTable?.[1]) {
236
- assertMigrationIdentifier(alterTable[1]);
237
- assertOwnedReferences(normalized, options.tablePrefix);
238
- return { category: "schema", table: alterTable[1], type: "alter_table" };
239
- }
240
-
241
- const structure = stripQuotedSql(normalized);
242
- if (/^(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*\./i.test(structure)) {
243
- throw new Error("Data migrations cannot use qualified table names");
244
- }
245
- const insert = structure.match(/^INSERT\s+INTO\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
246
- if (insert?.[1]) {
247
- assertOwnedDataTable(insert[1], options.tablePrefix);
248
- assertNoCrossResourceDataQuery(structure, "insert");
249
- if (!/\bVALUES\s*\(/i.test(structure)) throw new Error("INSERT data migrations must use VALUES");
250
- if (/\bON\s+CONFLICT\b/i.test(structure) && !/\bDO\s+(?:NOTHING|UPDATE\s+SET)\b/i.test(structure)) {
251
- throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
252
- }
253
- return { category: "data", table: insert[1], type: "insert" };
254
- }
255
-
256
- const update = structure.match(/^UPDATE\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+SET\s+/i);
257
- if (update?.[1]) {
258
- assertOwnedDataTable(update[1], options.tablePrefix);
259
- if (!/\bWHERE\b/i.test(structure)) throw new Error("UPDATE data migrations must include WHERE");
260
- assertNoCrossResourceDataQuery(structure, "update");
261
- return { category: "data", table: update[1], type: "update" };
262
- }
263
-
264
- const deleteFrom = structure.match(/^DELETE\s+FROM\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
265
- if (deleteFrom?.[1]) {
266
- assertOwnedDataTable(deleteFrom[1], options.tablePrefix);
267
- if (!/\bWHERE\b/i.test(structure)) throw new Error("DELETE data migrations must include WHERE");
268
- assertNoCrossResourceDataQuery(structure, "delete");
269
- return { category: "data", table: deleteFrom[1], type: "delete" };
270
- }
271
- throw new Error("Statement type is not allowed");
272
- }
273
-
274
- function assertMigrationIdentifier(identifier) {
275
- if (identifier.toLowerCase().startsWith("sqlite_")) {
276
- throw new Error("reserved_migration_identifier");
277
- }
278
- }
279
-
280
- function assertOwnedDataTable(identifier, tablePrefix) {
281
- assertMigrationIdentifier(identifier);
282
- const prefix = String(tablePrefix ?? "").trim().toLowerCase();
283
- if (prefix && !identifier.toLowerCase().startsWith(prefix)) {
284
- throw new Error(`Data table ${identifier} is outside App prefix ${prefix}`);
285
- }
286
- }
287
-
288
- function assertOwnedReferences(statement, tablePrefix) {
289
- for (const match of statement.matchAll(/\bREFERENCES\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/gi)) {
290
- if (match[1]) assertOwnedDataTable(match[1], tablePrefix);
291
- }
292
- }
293
-
294
- function assertNoCrossResourceDataQuery(statement, kind) {
295
- const forbidden = kind === "delete"
296
- ? /\b(?:SELECT|WITH|JOIN|ATTACH|DETACH|PRAGMA|DROP)\b/i
297
- : /\b(?:SELECT|WITH|JOIN|FROM|ATTACH|DETACH|PRAGMA|DROP)\b/i;
298
- if (forbidden.test(statement)) {
299
- throw new Error("Data migrations cannot query other tables or use administrative SQL");
300
- }
301
- }
302
-
303
- function stripQuotedSql(value) {
304
- let result = "";
305
- let quote;
306
- for (let index = 0; index < value.length; index += 1) {
307
- const char = value[index];
308
- if (quote) {
309
- if (char === quote) {
310
- if (value[index + 1] === quote) {
311
- result += " ";
312
- index += 1;
313
- continue;
314
- }
315
- quote = undefined;
316
- }
317
- result += " ";
318
- continue;
319
- }
320
- if (char === "'" || char === '"') {
321
- quote = char;
322
- result += " ";
323
- continue;
324
- }
325
- result += char;
326
- }
327
- return result;
328
- }
@@ -0,0 +1,476 @@
1
+ const ADMINISTRATIVE_KEYWORDS = new Set([
2
+ "ANALYZE",
3
+ "ATTACH",
4
+ "DETACH",
5
+ "DROP",
6
+ "PRAGMA",
7
+ "REINDEX",
8
+ "VACUUM"
9
+ ]);
10
+ const CROSS_TABLE_QUERY_KEYWORDS = new Set(["JOIN", "SELECT", "WITH"]);
11
+ const ALTER_DEFINITION_STATEMENT_KEYWORDS = new Set(["ALTER", "CREATE", "DELETE", "INSERT", "UPDATE"]);
12
+
13
+ export function parseMigrationStatements(sql) {
14
+ const statements = [];
15
+ let current = "";
16
+ let quote;
17
+ let bracketIdentifier = false;
18
+ let blockComment = false;
19
+ let lineComment = false;
20
+
21
+ for (let index = 0; index < sql.length; index += 1) {
22
+ const char = sql[index];
23
+ const next = sql[index + 1];
24
+ if (blockComment) {
25
+ if (char === "*" && next === "/") {
26
+ blockComment = false;
27
+ index += 1;
28
+ }
29
+ continue;
30
+ }
31
+ if (lineComment) {
32
+ if (char === "\n") {
33
+ lineComment = false;
34
+ current += "\n";
35
+ }
36
+ continue;
37
+ }
38
+ if (!quote && !bracketIdentifier && char === "/" && next === "*") {
39
+ blockComment = true;
40
+ current += " ";
41
+ index += 1;
42
+ continue;
43
+ }
44
+ if (!quote && !bracketIdentifier && char === "-" && next === "-") {
45
+ lineComment = true;
46
+ current += " ";
47
+ index += 1;
48
+ continue;
49
+ }
50
+ if (!quote && char === "[") {
51
+ bracketIdentifier = true;
52
+ } else if (bracketIdentifier && char === "]") {
53
+ bracketIdentifier = false;
54
+ } else if (!bracketIdentifier && (char === "'" || char === "\"" || char === "`")) {
55
+ if (quote === char && next === char) {
56
+ current += char + next;
57
+ index += 1;
58
+ continue;
59
+ }
60
+ quote = quote === char ? undefined : quote ?? char;
61
+ }
62
+ if (!quote && !bracketIdentifier && char === ";") {
63
+ const statement = current.trim();
64
+ if (statement) statements.push(statement);
65
+ current = "";
66
+ continue;
67
+ }
68
+ current += char;
69
+ }
70
+
71
+ if (blockComment) throw new Error("Migration SQL contains an unterminated block comment");
72
+ if (quote || bracketIdentifier) throw new Error("Migration SQL contains an unterminated literal or identifier");
73
+ const trailing = current.trim();
74
+ if (trailing) statements.push(trailing);
75
+ return statements;
76
+ }
77
+
78
+ export function analyzeMigrationStatement(statement, options = {}) {
79
+ const tablePrefix = normalizeRequiredPrefix(options.tablePrefix);
80
+ const tokens = tokenizeSql(statement);
81
+ if (tokens.length === 0) throw new Error("Statement type is not allowed");
82
+ rejectAdministrativeKeywords(tokens);
83
+
84
+ if (isKeyword(tokens[0], "CREATE")) return validateCreate(tokens, tablePrefix);
85
+ if (isKeyword(tokens[0], "ALTER")) return validateAlterTable(tokens, tablePrefix);
86
+ if (isKeyword(tokens[0], "INSERT")) return validateInsert(tokens, tablePrefix);
87
+ if (isKeyword(tokens[0], "UPDATE")) return validateUpdate(tokens, tablePrefix);
88
+ if (isKeyword(tokens[0], "DELETE")) return validateDelete(tokens, tablePrefix);
89
+ throw new Error("Statement type is not allowed");
90
+ }
91
+
92
+ function validateCreate(tokens, tablePrefix) {
93
+ const cursor = new TokenCursor(tokens);
94
+ cursor.expectKeyword("CREATE");
95
+ if (cursor.matchKeyword("TABLE")) {
96
+ cursor.expectKeyword("IF");
97
+ cursor.expectKeyword("NOT");
98
+ cursor.expectKeyword("EXISTS");
99
+ const table = cursor.readIdentifier();
100
+ assertOwnedMigrationIdentifier(table, tablePrefix);
101
+ cursor.expectSymbol("(");
102
+ const closing = matchingParenIndex(tokens, cursor.position - 1);
103
+ if (closing !== tokens.length - 1 || closing === cursor.position) throw unsupportedStatement();
104
+ const definition = tokens.slice(cursor.position, closing);
105
+ assertOwnedReferences(definition, tablePrefix);
106
+ assertNoQueryKeywords(definition);
107
+ return { category: "schema", table, type: "create_table" };
108
+ }
109
+ if (cursor.matchKeyword("INDEX")) {
110
+ cursor.expectKeyword("IF");
111
+ cursor.expectKeyword("NOT");
112
+ cursor.expectKeyword("EXISTS");
113
+ const indexName = cursor.readIdentifier();
114
+ assertOwnedMigrationIdentifier(indexName, tablePrefix);
115
+ cursor.expectKeyword("ON");
116
+ const table = cursor.readIdentifier();
117
+ assertOwnedMigrationIdentifier(table, tablePrefix);
118
+ cursor.expectSymbol("(");
119
+ const closing = matchingParenIndex(tokens, cursor.position - 1);
120
+ if (closing === cursor.position) throw unsupportedStatement();
121
+ const trailing = tokens.slice(closing + 1);
122
+ if (trailing.length > 0 && (!isKeyword(trailing[0], "WHERE") || trailing.length === 1)) {
123
+ throw unsupportedStatement();
124
+ }
125
+ assertNoQueryKeywords(tokens.slice(cursor.position, closing));
126
+ assertNoQueryKeywords(trailing);
127
+ return { category: "schema", table, type: "create_index" };
128
+ }
129
+ throw unsupportedStatement();
130
+ }
131
+
132
+ function validateAlterTable(tokens, tablePrefix) {
133
+ const cursor = new TokenCursor(tokens);
134
+ cursor.expectKeyword("ALTER");
135
+ cursor.expectKeyword("TABLE");
136
+ const table = cursor.readIdentifier();
137
+ assertOwnedMigrationIdentifier(table, tablePrefix);
138
+ cursor.expectKeyword("ADD");
139
+ cursor.expectKeyword("COLUMN");
140
+ cursor.readIdentifier();
141
+ if (cursor.done()) throw unsupportedStatement();
142
+ const definition = tokens.slice(cursor.position);
143
+ assertOwnedReferences(definition, tablePrefix);
144
+ assertNoQueryKeywords(definition);
145
+ if (definition.some((token) => token.kind === "word" && ALTER_DEFINITION_STATEMENT_KEYWORDS.has(token.upper))) {
146
+ throw unsupportedStatement();
147
+ }
148
+ return { category: "schema", table, type: "alter_table" };
149
+ }
150
+
151
+ function validateInsert(tokens, tablePrefix) {
152
+ const cursor = new TokenCursor(tokens);
153
+ cursor.expectKeyword("INSERT");
154
+ cursor.expectKeyword("INTO");
155
+ const table = cursor.readIdentifier();
156
+ assertOwnedMigrationIdentifier(table, tablePrefix);
157
+ rejectQualifiedTarget(cursor);
158
+ assertNoDataQueryKeywords(tokens.slice(cursor.position));
159
+ if (cursor.peekSymbol("(")) cursor.skipBalancedGroup();
160
+ cursor.expectKeyword("VALUES", "INSERT data migrations must use VALUES");
161
+ if (!cursor.peekSymbol("(")) throw new Error("INSERT data migrations must use VALUES");
162
+ cursor.skipBalancedGroup();
163
+ while (cursor.matchSymbol(",")) {
164
+ if (!cursor.peekSymbol("(")) throw new Error("INSERT data migrations must use VALUES");
165
+ cursor.skipBalancedGroup();
166
+ }
167
+ if (cursor.done()) return { category: "data", table, type: "insert" };
168
+ cursor.expectKeyword("ON", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
169
+ cursor.expectKeyword("CONFLICT", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
170
+ if (cursor.peekSymbol("(")) cursor.skipBalancedGroup();
171
+ if (cursor.matchKeyword("WHERE")) {
172
+ const doIndex = findTopLevelKeyword(tokens, "DO", cursor.position);
173
+ if (doIndex <= cursor.position) throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
174
+ cursor.position = doIndex;
175
+ }
176
+ cursor.expectKeyword("DO", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
177
+ if (cursor.matchKeyword("NOTHING")) {
178
+ if (!cursor.done()) throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
179
+ return { category: "data", table, type: "insert" };
180
+ }
181
+ cursor.expectKeyword("UPDATE", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
182
+ cursor.expectKeyword("SET", "INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
183
+ if (cursor.done()) throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
184
+ return { category: "data", table, type: "insert" };
185
+ }
186
+
187
+ function validateUpdate(tokens, tablePrefix) {
188
+ const cursor = new TokenCursor(tokens);
189
+ cursor.expectKeyword("UPDATE");
190
+ const table = cursor.readIdentifier();
191
+ assertOwnedMigrationIdentifier(table, tablePrefix);
192
+ rejectQualifiedTarget(cursor);
193
+ cursor.expectKeyword("SET");
194
+ assertNoDataQueryKeywords(tokens.slice(cursor.position));
195
+ const whereIndex = findTopLevelKeyword(tokens, "WHERE", cursor.position);
196
+ if (whereIndex < 0 || whereIndex === tokens.length - 1) throw new Error("UPDATE data migrations must include WHERE");
197
+ if (whereIndex === cursor.position) throw unsupportedStatement();
198
+ return { category: "data", table, type: "update" };
199
+ }
200
+
201
+ function validateDelete(tokens, tablePrefix) {
202
+ const cursor = new TokenCursor(tokens);
203
+ cursor.expectKeyword("DELETE");
204
+ cursor.expectKeyword("FROM");
205
+ const table = cursor.readIdentifier();
206
+ assertOwnedMigrationIdentifier(table, tablePrefix);
207
+ rejectQualifiedTarget(cursor);
208
+ assertNoDataQueryKeywords(tokens.slice(cursor.position));
209
+ const whereIndex = findTopLevelKeyword(tokens, "WHERE", cursor.position);
210
+ if (whereIndex < 0 || whereIndex === tokens.length - 1) throw new Error("DELETE data migrations must include WHERE");
211
+ if (whereIndex !== cursor.position) throw unsupportedStatement();
212
+ return { category: "data", table, type: "delete" };
213
+ }
214
+
215
+ function tokenizeSql(statement) {
216
+ const tokens = [];
217
+ for (let index = 0; index < statement.length;) {
218
+ const char = statement[index];
219
+ if (isWhitespace(char)) {
220
+ index += 1;
221
+ continue;
222
+ }
223
+ if (isIdentifierStart(char)) {
224
+ const start = index;
225
+ index += 1;
226
+ while (index < statement.length && isIdentifierPart(statement[index])) index += 1;
227
+ const text = statement.slice(start, index);
228
+ tokens.push({ kind: "word", text, upper: text.toUpperCase() });
229
+ continue;
230
+ }
231
+ if (isDigit(char) || (char === "." && isDigit(statement[index + 1]))) {
232
+ const end = readNumberEnd(statement, index);
233
+ tokens.push({ kind: "number", text: statement.slice(index, end) });
234
+ index = end;
235
+ continue;
236
+ }
237
+ if (char === "'") {
238
+ const start = index;
239
+ index += 1;
240
+ let terminated = false;
241
+ while (index < statement.length) {
242
+ if (statement[index] === "'" && statement[index + 1] === "'") {
243
+ index += 2;
244
+ continue;
245
+ }
246
+ if (statement[index] === "'") {
247
+ index += 1;
248
+ terminated = true;
249
+ break;
250
+ }
251
+ index += 1;
252
+ }
253
+ if (!terminated) throw new Error("Migration SQL contains an unterminated literal");
254
+ tokens.push({ kind: "string", text: statement.slice(start, index) });
255
+ continue;
256
+ }
257
+ if (char === "\"" || char === "`" || char === "[") {
258
+ throw new Error("Quoted migration identifiers are not allowed");
259
+ }
260
+ if (isSymbolStart(char)) {
261
+ const pair = statement.slice(index, index + 2);
262
+ const symbol = isTwoCharacterSymbol(pair) ? pair : char;
263
+ tokens.push({ kind: "symbol", text: symbol });
264
+ index += symbol.length;
265
+ continue;
266
+ }
267
+ throw new Error("Migration SQL contains an unsupported token");
268
+ }
269
+ assertBalancedParentheses(tokens);
270
+ return tokens;
271
+ }
272
+
273
+ class TokenCursor {
274
+ position = 0;
275
+
276
+ constructor(tokens) {
277
+ this.tokens = tokens;
278
+ }
279
+
280
+ done() {
281
+ return this.position >= this.tokens.length;
282
+ }
283
+
284
+ expectKeyword(keyword, message = "Statement type is not allowed") {
285
+ if (!this.matchKeyword(keyword)) throw new Error(message);
286
+ }
287
+
288
+ expectSymbol(symbol) {
289
+ if (!this.matchSymbol(symbol)) throw unsupportedStatement();
290
+ }
291
+
292
+ matchKeyword(keyword) {
293
+ if (!isKeyword(this.tokens[this.position], keyword)) return false;
294
+ this.position += 1;
295
+ return true;
296
+ }
297
+
298
+ matchSymbol(symbol) {
299
+ if (!this.peekSymbol(symbol)) return false;
300
+ this.position += 1;
301
+ return true;
302
+ }
303
+
304
+ peekSymbol(symbol) {
305
+ const token = this.tokens[this.position];
306
+ return token?.kind === "symbol" && token.text === symbol;
307
+ }
308
+
309
+ readIdentifier() {
310
+ const token = this.tokens[this.position];
311
+ if (token?.kind !== "word") throw unsupportedStatement();
312
+ this.position += 1;
313
+ return token.text;
314
+ }
315
+
316
+ skipBalancedGroup() {
317
+ if (!this.peekSymbol("(")) throw unsupportedStatement();
318
+ this.position = matchingParenIndex(this.tokens, this.position) + 1;
319
+ }
320
+ }
321
+
322
+ function normalizeRequiredPrefix(value) {
323
+ const prefix = String(value ?? "").trim().toLowerCase();
324
+ if (!prefix) throw new Error("App D1 migration preflight requires --table-prefix");
325
+ return prefix;
326
+ }
327
+
328
+ function assertOwnedMigrationIdentifier(identifier, tablePrefix) {
329
+ const normalized = identifier.toLowerCase();
330
+ if (normalized.startsWith("sqlite_")) throw new Error("reserved_migration_identifier");
331
+ if (!normalized.startsWith(tablePrefix)) {
332
+ throw new Error(`Migration identifier ${identifier} is outside App prefix ${tablePrefix}`);
333
+ }
334
+ }
335
+
336
+ function assertOwnedReferences(tokens, tablePrefix) {
337
+ for (let index = 0; index < tokens.length; index += 1) {
338
+ if (!isKeyword(tokens[index], "REFERENCES")) continue;
339
+ const referenced = tokens[index + 1];
340
+ if (referenced?.kind !== "word") throw unsupportedStatement();
341
+ assertOwnedMigrationIdentifier(referenced.text, tablePrefix);
342
+ if (tokens[index + 2]?.kind === "symbol" && tokens[index + 2]?.text === ".") {
343
+ throw new Error("Data migrations cannot query other tables or use administrative SQL");
344
+ }
345
+ }
346
+ }
347
+
348
+ function rejectQualifiedTarget(cursor) {
349
+ if (cursor.peekSymbol(".")) throw new Error("Data migrations cannot use qualified table names");
350
+ }
351
+
352
+ function rejectAdministrativeKeywords(tokens) {
353
+ if (tokens.some((token) => token.kind === "word" && ADMINISTRATIVE_KEYWORDS.has(token.upper))) {
354
+ throw unsupportedStatement();
355
+ }
356
+ }
357
+
358
+ function assertNoQueryKeywords(tokens) {
359
+ if (tokens.some((token) => token.kind === "word" && CROSS_TABLE_QUERY_KEYWORDS.has(token.upper))) {
360
+ throw new Error("Migrations cannot query other tables");
361
+ }
362
+ }
363
+
364
+ function assertNoDataQueryKeywords(tokens) {
365
+ if (tokens.some((token) => token.kind === "word" && (token.upper === "FROM" || CROSS_TABLE_QUERY_KEYWORDS.has(token.upper)))) {
366
+ throw new Error("Data migrations cannot query other tables or use administrative SQL");
367
+ }
368
+ }
369
+
370
+ function assertBalancedParentheses(tokens) {
371
+ let depth = 0;
372
+ for (const token of tokens) {
373
+ if (token.kind !== "symbol") continue;
374
+ if (token.text === "(") depth += 1;
375
+ if (token.text === ")") depth -= 1;
376
+ if (depth < 0) throw new Error("Migration SQL contains unbalanced parentheses");
377
+ }
378
+ if (depth !== 0) throw new Error("Migration SQL contains unbalanced parentheses");
379
+ }
380
+
381
+ function matchingParenIndex(tokens, openingIndex) {
382
+ if (tokens[openingIndex]?.kind !== "symbol" || tokens[openingIndex]?.text !== "(") throw unsupportedStatement();
383
+ let depth = 0;
384
+ for (let index = openingIndex; index < tokens.length; index += 1) {
385
+ const token = tokens[index];
386
+ if (token.kind !== "symbol") continue;
387
+ if (token.text === "(") depth += 1;
388
+ if (token.text === ")") depth -= 1;
389
+ if (depth === 0) return index;
390
+ }
391
+ throw new Error("Migration SQL contains unbalanced parentheses");
392
+ }
393
+
394
+ function findTopLevelKeyword(tokens, keyword, start) {
395
+ let depth = 0;
396
+ for (let index = start; index < tokens.length; index += 1) {
397
+ const token = tokens[index];
398
+ if (token.kind === "symbol" && token.text === "(") {
399
+ depth += 1;
400
+ continue;
401
+ }
402
+ if (token.kind === "symbol" && token.text === ")") {
403
+ depth -= 1;
404
+ continue;
405
+ }
406
+ if (depth === 0 && isKeyword(token, keyword)) return index;
407
+ }
408
+ return -1;
409
+ }
410
+
411
+ function readNumberEnd(value, start) {
412
+ let index = start;
413
+ if (value[index] === "0" && (value[index + 1] === "x" || value[index + 1] === "X") && isHexDigit(value[index + 2])) {
414
+ index += 3;
415
+ while (isHexDigit(value[index])) index += 1;
416
+ return index;
417
+ }
418
+ if (value[index] === ".") index += 1;
419
+ while (isDigit(value[index])) index += 1;
420
+ if (value[index] === ".") {
421
+ index += 1;
422
+ while (isDigit(value[index])) index += 1;
423
+ }
424
+ if (value[index] === "e" || value[index] === "E") {
425
+ let exponent = index + 1;
426
+ if (value[exponent] === "+" || value[exponent] === "-") exponent += 1;
427
+ if (isDigit(value[exponent])) {
428
+ index = exponent + 1;
429
+ while (isDigit(value[index])) index += 1;
430
+ }
431
+ }
432
+ return index;
433
+ }
434
+
435
+ function isIdentifierStart(char) {
436
+ if (!char) return false;
437
+ const code = char.charCodeAt(0);
438
+ return char === "_" || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
439
+ }
440
+
441
+ function isIdentifierPart(char) {
442
+ return isIdentifierStart(char) || isDigit(char);
443
+ }
444
+
445
+ function isDigit(char) {
446
+ if (!char) return false;
447
+ const code = char.charCodeAt(0);
448
+ return code >= 48 && code <= 57;
449
+ }
450
+
451
+ function isHexDigit(char) {
452
+ if (!char) return false;
453
+ const code = char.charCodeAt(0);
454
+ return isDigit(char) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102);
455
+ }
456
+
457
+ function isWhitespace(char) {
458
+ return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v";
459
+ }
460
+
461
+ function isSymbolStart(char) {
462
+ return Boolean(char && "(),.=+-*/%<>!|&~".includes(char));
463
+ }
464
+
465
+ function isTwoCharacterSymbol(value) {
466
+ return value === "<=" || value === ">=" || value === "!=" || value === "<>" || value === "=="
467
+ || value === "||" || value === "<<" || value === ">>";
468
+ }
469
+
470
+ function isKeyword(token, keyword) {
471
+ return token?.kind === "word" && token.upper === keyword;
472
+ }
473
+
474
+ function unsupportedStatement() {
475
+ return new Error("Statement type is not allowed");
476
+ }