@pramen/server 0.0.35 → 0.0.36

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.
@@ -38,7 +38,7 @@
38
38
  // A rename can't be inferred from a diff (a removed + added column is ambiguous),
39
39
  // so it must be declared with `renamedFrom`; otherwise it is applied as drop+add.
40
40
 
41
- import { addColumnSql, createTableSql, defaultSqlValue, indexName, indexStatements, sqlType } from "./ddl";
41
+ import { addColumnSql, compositeKey, createTableSql, declaredForeignKeys, defaultSqlValue, indexName, indexStatements, sqlType } from "./ddl";
42
42
  import { digest } from "./digest";
43
43
  import { quoteIdent, type Driver } from "./driver";
44
44
  import { entitiesInPartition, partitionOf, validateSchema } from "../sdk/schema";
@@ -88,7 +88,16 @@ function isInternalTable(name: string): boolean {
88
88
 
89
89
  export function schemaHash(schema: SchemaDef): string {
90
90
  const canon: Record<string, unknown> = {};
91
- for (const [table, def] of Object.entries(schema)) canon[table] = def.fields;
91
+ for (const [table, def] of Object.entries(schema)) {
92
+ // Keep the bare-`fields` shape when there are no composite uniques AND no FKs so
93
+ // existing stores' hashes are byte-identical (no spurious migration); fold each in
94
+ // only when present (composite unique / a belongsTo with onDelete).
95
+ const fks = declaredForeignKeys(def);
96
+ const extra: Record<string, unknown> = {};
97
+ if (def.uniques.length) extra.uniques = def.uniques;
98
+ if (fks.size) extra.fks = Object.fromEntries([...fks].map(([c, s]) => [c, `${s.target}:${s.onDelete}`]));
99
+ canon[table] = Object.keys(extra).length ? { fields: def.fields, ...extra } : def.fields;
100
+ }
92
101
  return digest(canon);
93
102
  }
94
103
 
@@ -161,6 +170,30 @@ async function columnHasDuplicates(driver: Driver, table: string, col: string):
161
170
  return rows.length > 0;
162
171
  }
163
172
 
173
+ /** Managed composite-unique indexes live on `table`: `compositeKey(cols)` → index name.
174
+ * Only `pramen_uidx_`-prefixed multi-column unique indexes are pramen-managed, so the
175
+ * reconciler never touches a hand-created index. */
176
+ async function liveCompositeUniques(driver: Driver, table: string): Promise<Map<string, string>> {
177
+ const idx = (await driver.exec(`PRAGMA index_list(${quoteIdent(table)})`, [])) as { name: string; unique: number }[];
178
+ const out = new Map<string, string>();
179
+ for (const i of idx) {
180
+ if (i.unique !== 1 || !i.name.startsWith("pramen_uidx_")) continue;
181
+ const cols = (await driver.exec(`PRAGMA index_info(${quoteIdent(i.name)})`, [])) as { name: string | null }[];
182
+ const names = cols.map((c) => c.name).filter((n): n is string => n != null);
183
+ if (names.length >= 2) out.set(names.join(","), i.name);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /** Does the column tuple hold a duplicate all-non-NULL combination? (A new composite
189
+ * UNIQUE over such data can't build its index.) Mirrors {@link columnHasDuplicates}. */
190
+ async function compositeHasDuplicates(driver: Driver, table: string, cols: readonly string[]): Promise<boolean> {
191
+ const notNull = cols.map((c) => `${quoteIdent(c)} IS NOT NULL`).join(" AND ");
192
+ const group = cols.map((c) => quoteIdent(c)).join(", ");
193
+ const rows = await driver.exec(`SELECT 1 FROM ${quoteIdent(table)} WHERE ${notNull} GROUP BY ${group} HAVING COUNT(*) > 1 LIMIT 1`, []);
194
+ return rows.length > 0;
195
+ }
196
+
164
197
  /** Normalize a DEFAULT's SQL text for comparison: trim, and strip balanced outer
165
198
  * parens (SQLite reports an expr default with or without the wrapping parens the DDL
166
199
  * emitted — `(datetime('now'))` vs `datetime('now')` — depending on the engine, so the
@@ -197,14 +230,53 @@ async function writeMeta(driver: Driver, key: string, value: string): Promise<vo
197
230
  await driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES (?, ?)`, [key, value]);
198
231
  }
199
232
 
233
+ /** Live tables (excluding internals and `table` itself) holding a foreign key that
234
+ * REFERENCES `table`, with everything needed to drop + faithfully restore them around a
235
+ * rebuild of `table`: their column list, their exact CREATE TABLE DDL, and their index
236
+ * DDL (both verbatim from sqlite_master). Needed because `DROP TABLE parent` performs an
237
+ * implicit `DELETE FROM parent`, and `defer_foreign_keys` defers only violation CHECKS —
238
+ * ON DELETE actions still fire (CASCADE/SET NULL corrupt the holders' rows; RESTRICT
239
+ * aborts immediately). SQLite's official escape (`PRAGMA foreign_keys=OFF`) is
240
+ * unavailable on DO SQLite and D1, so the holders are quarantined instead. */
241
+ async function liveReferencingHolders(
242
+ driver: Driver,
243
+ table: string,
244
+ ): Promise<{ name: string; cols: string[]; createSql: string; indexSql: string[] }[]> {
245
+ const tables = (await driver.exec(`SELECT name, sql FROM sqlite_master WHERE type = 'table'`, [])) as { name: string; sql: string | null }[];
246
+ const out: { name: string; cols: string[]; createSql: string; indexSql: string[] }[] = [];
247
+ for (const t of tables) {
248
+ if (t.name === table || isInternalTable(t.name) || !t.sql) continue;
249
+ const fks = (await driver.exec(`PRAGMA foreign_key_list(${quoteIdent(t.name)})`, [])) as { table: string }[];
250
+ if (!fks.some((fk) => fk.table === table)) continue;
251
+ const cols = [...(await tableColumns(driver, t.name)).keys()];
252
+ const idx = (await driver.exec(`SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL`, [t.name])) as { sql: string }[];
253
+ out.push({ name: t.name, cols, createSql: t.sql, indexSql: idx.map((r) => r.sql) });
254
+ }
255
+ return out;
256
+ }
257
+
200
258
  /** Rebuild a table to exactly the declared schema: create a temp table, copy each
201
259
  * desired column from its source (renamed or same-named live column, CAST on a
202
- * type change; brand-new columns left NULL), drop the old table, rename the temp. */
203
- async function rebuildTable(driver: Driver, table: string, def: { fields: EntityFields }, live: Map<string, string>): Promise<void> {
260
+ * type change; brand-new columns left NULL), drop the old table, rename the temp.
261
+ *
262
+ * FK safety: dropping the old table implicit-DELETEs its rows, which fires the ON DELETE
263
+ * actions of any live FK that references it — even under `defer_foreign_keys` (deferral
264
+ * postpones checks, not actions). So every live referencing holder is QUARANTINED first
265
+ * (bare FK-less copy of its rows, table dropped) and restored from its verbatim DDL after
266
+ * the swap — all inside the same atomic step, so the holders' rows can never be cascaded
267
+ * away, nulled, or trip a RESTRICT mid-rebuild. A SELF-referential FK gets the same
268
+ * treatment applied to the rebuilt table itself: the plain tmp+rename swap would give tmp
269
+ * a live FK into the old table right when it's dropped, so the swap goes through a bare
270
+ * (FK-less) copy instead — quarantine out, recreate final, copy back. */
271
+ async function rebuildTable(
272
+ driver: Driver,
273
+ table: string,
274
+ def: { fields: EntityFields; relations?: import("../sdk/schema").RelationDefs },
275
+ live: Map<string, string>,
276
+ pkOf?: (entity: string) => string,
277
+ skipFks?: ReadonlySet<string>,
278
+ ): Promise<void> {
204
279
  const tmp = `__pramen_rebuild_${table}`;
205
- await driver.exec(`DROP TABLE IF EXISTS ${quoteIdent(tmp)}`, []);
206
- await driver.exec(createTableSql(tmp, def), []);
207
-
208
280
  const destCols: string[] = [];
209
281
  const srcExprs: string[] = [];
210
282
  for (const [name, field] of Object.entries(def.fields)) {
@@ -222,11 +294,92 @@ async function rebuildTable(driver: Driver, table: string, def: { fields: Entity
222
294
  destCols.push(quoteIdent(name));
223
295
  srcExprs.push(expr);
224
296
  }
225
- if (destCols.length > 0) {
226
- await driver.exec(`INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, []);
297
+
298
+ const holders = await liveReferencingHolders(driver, table);
299
+ // Self-referential FK — declared in the new shape (and surviving skipFks) or live on
300
+ // the old table — forces the bare-copy swap for the table itself.
301
+ const declaredSelf = [...declaredForeignKeys(def)].some(([col, s]) => s.target === table && !skipFks?.has(col));
302
+ const liveSelf = [...(await liveForeignKeys(driver, table)).values()].some((s) => s.target === table);
303
+ const selfRef = declaredSelf || liveSelf;
304
+
305
+ // The drop+rename would trip an immediate FK check (dropping a referenced table, or a
306
+ // rebuilt table whose FKs momentarily see stale rows), so run the whole sequence
307
+ // ATOMICALLY: the D1 driver's batch() defers FK checks to the batch commit, and on the
308
+ // DO the ambient boot transaction (+ defer set at migrate start) already covers it.
309
+ const stmts: { sql: string; params: unknown[] }[] = [];
310
+ // Quarantine tables are bare column lists — untyped, no constraints, no FKs. Values
311
+ // round-trip verbatim (they were already coerced by the original table's affinity).
312
+ const bareCopy = (name: string, quotedCols: string[]): string => `CREATE TABLE ${quoteIdent(name)} (${quotedCols.join(", ")})`;
313
+ // 1. Quarantine every live holder referencing this table (FK-less row copy, then drop),
314
+ // so the swap's implicit DELETE has no FK actions left to fire into them.
315
+ for (const h of holders) {
316
+ const q = `__pramen_q_${h.name}`;
317
+ const cols = h.cols.map((c) => quoteIdent(c)).join(", ");
318
+ stmts.push({ sql: `DROP TABLE IF EXISTS ${quoteIdent(q)}`, params: [] });
319
+ stmts.push({ sql: bareCopy(q, h.cols.map((c) => quoteIdent(c))), params: [] });
320
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(q)} (${cols}) SELECT ${cols} FROM ${quoteIdent(h.name)}`, params: [] });
321
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(h.name)}`, params: [] });
227
322
  }
228
- await driver.exec(`DROP TABLE ${quoteIdent(table)}`, []);
229
- await driver.exec(`ALTER TABLE ${quoteIdent(tmp)} RENAME TO ${quoteIdent(table)}`, []);
323
+ // 2. Swap the table itself.
324
+ stmts.push({ sql: `DROP TABLE IF EXISTS ${quoteIdent(tmp)}`, params: [] });
325
+ if (selfRef) {
326
+ // Bare-copy swap: out to an FK-less temp, drop, recreate final, copy back. The final
327
+ // INSERT lists only the copied columns, so a new expr-default column still backfills.
328
+ stmts.push({ sql: bareCopy(tmp, destCols), params: [] });
329
+ if (destCols.length > 0) {
330
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, params: [] });
331
+ }
332
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(table)}`, params: [] });
333
+ stmts.push({ sql: createTableSql(table, def, pkOf, skipFks), params: [] });
334
+ if (destCols.length > 0) {
335
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(table)} (${destCols.join(", ")}) SELECT ${destCols.join(", ")} FROM ${quoteIdent(tmp)}`, params: [] });
336
+ }
337
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(tmp)}`, params: [] });
338
+ } else {
339
+ stmts.push({ sql: createTableSql(tmp, def, pkOf, skipFks), params: [] });
340
+ if (destCols.length > 0) {
341
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, params: [] });
342
+ }
343
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(table)}`, params: [] });
344
+ stmts.push({ sql: `ALTER TABLE ${quoteIdent(tmp)} RENAME TO ${quoteIdent(table)}`, params: [] });
345
+ }
346
+ // 3. Restore each holder verbatim (its DDL references this table by name, so its FK
347
+ // binds to the rebuilt table). Row order/content unchanged; deferred checks validate
348
+ // the restored FKs once at commit.
349
+ for (const h of holders) {
350
+ const q = `__pramen_q_${h.name}`;
351
+ const cols = h.cols.map((c) => quoteIdent(c)).join(", ");
352
+ stmts.push({ sql: h.createSql, params: [] });
353
+ stmts.push({ sql: `INSERT INTO ${quoteIdent(h.name)} (${cols}) SELECT ${cols} FROM ${quoteIdent(q)}`, params: [] });
354
+ stmts.push({ sql: `DROP TABLE ${quoteIdent(q)}`, params: [] });
355
+ for (const idx of h.indexSql) stmts.push({ sql: idx, params: [] });
356
+ }
357
+ if (driver.batch) await driver.batch(stmts);
358
+ else for (const s of stmts) await driver.exec(s.sql, s.params); // DO: already inside the boot txn
359
+ }
360
+
361
+ /** Primary-key column of an entity (the `primaryKey()` field), defaulting to `id`. */
362
+ function pkColumnOf(def: { fields: EntityFields } | undefined): string {
363
+ if (def) for (const [n, f] of Object.entries(def.fields)) if ((f as FieldDef).primaryKey) return n;
364
+ return "id";
365
+ }
366
+
367
+ /** Live FK columns of a table → {target, onDelete} via PRAGMA foreign_key_list. */
368
+ async function liveForeignKeys(driver: Driver, table: string): Promise<Map<string, { target: string; onDelete: string }>> {
369
+ const rows = (await driver.exec(`PRAGMA foreign_key_list(${quoteIdent(table)})`, [])) as { table: string; from: string; on_delete: string }[];
370
+ const out = new Map<string, { target: string; onDelete: string }>();
371
+ for (const r of rows) out.set(r.from, { target: r.table, onDelete: (r.on_delete || "NO ACTION").toUpperCase() });
372
+ return out;
373
+ }
374
+
375
+ /** Does the FK column hold a non-NULL value with no matching target row? (Adding the FK
376
+ * over such data would fail the deferred check at commit.) */
377
+ async function fkColumnHasOrphans(driver: Driver, table: string, col: string, target: string, targetPk: string): Promise<boolean> {
378
+ const rows = await driver.exec(
379
+ `SELECT 1 FROM ${quoteIdent(table)} c WHERE c.${quoteIdent(col)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${quoteIdent(target)} p WHERE p.${quoteIdent(targetPk)} = c.${quoteIdent(col)}) LIMIT 1`,
380
+ [],
381
+ );
382
+ return rows.length > 0;
230
383
  }
231
384
 
232
385
  export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOptions = {}): Promise<MigrationReport> {
@@ -234,7 +387,14 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
234
387
  // checked before any DDL so a bad schema fails fast on boot / the D1 path, not mid-migration.
235
388
  validateSchema(schema);
236
389
  await driver.exec(`CREATE TABLE IF NOT EXISTS _pramen_meta (key TEXT PRIMARY KEY, value TEXT)`, []);
390
+ // Defer FK checks to the end of the migration transaction so drop/rebuild steps don't
391
+ // trip an immediate FK violation. On the DO the whole migrate runs in one transaction, so
392
+ // this one PRAGMA covers everything; on D1 (no ambient transaction) rebuilds instead go
393
+ // through driver.batch(), which sets its own defer — so this is a harmless no-op there.
394
+ await driver.exec(`PRAGMA defer_foreign_keys = ON`, []);
237
395
  const allowDestructive = opts.allowDestructive ?? false;
396
+ // Resolve a referenced entity's PK column (for FOREIGN KEY ... REFERENCES emission).
397
+ const pkOf = (entity: string): string => pkColumnOf(schema[entity]);
238
398
 
239
399
  // When a partition is named, narrow the schema to just that partition's entities —
240
400
  // every later pass (create/alter/rebuild/drop/index/hash) iterates this subset, so
@@ -268,11 +428,14 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
268
428
  // Per-table: columns whose new `unique()` can't be indexed (duplicate values) — the
269
429
  // index pass must skip them so it doesn't throw. They're already reported in `skipped`.
270
430
  const uniqueIndexSkip = new Map<string, Set<string>>();
431
+ // Per-table: composite-unique tuples (keyed by compositeKey) whose new index can't be
432
+ // built (duplicate tuples present) — skipped by the index pass, reported in `skipped`.
433
+ const compositeUniqueSkip = new Map<string, Set<string>>();
271
434
 
272
435
  for (const [table, def] of entries) {
273
436
  const existing = await tableColumns(driver, table);
274
437
  if (existing.size === 0) {
275
- await driver.exec(createTableSql(table, def), []);
438
+ await driver.exec(createTableSql(table, def, pkOf), []);
276
439
  created.push(table);
277
440
  continue;
278
441
  }
@@ -367,6 +530,27 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
367
530
  }
368
531
  }
369
532
 
533
+ // Foreign keys (belongsTo with onDelete). SQLite can't ALTER a table to add/change an
534
+ // FK, so any FK delta is enacted by a rebuild (safe — no data loss). An FK being ADDED
535
+ // over data with orphaned references is skipped (reported) and left out of the rebuild,
536
+ // mirroring the unique-over-duplicates behavior, so the migration doesn't fail.
537
+ const declaredFks = declaredForeignKeys(def);
538
+ const liveFks = await liveForeignKeys(driver, table);
539
+ const fkSkip = new Set<string>();
540
+ for (const [col, spec] of declaredFks) {
541
+ if (!liveFks.has(col) && (await fkColumnHasOrphans(driver, table, col, spec.target, pkOf(spec.target)))) {
542
+ fkSkip.add(col);
543
+ skipped.push(`add FK ${table}.${col} → ${spec.target} (orphaned references present)`);
544
+ }
545
+ }
546
+ const applyFks = new Map([...declaredFks].filter(([col]) => !fkSkip.has(col)));
547
+ const fkChanged =
548
+ applyFks.size !== liveFks.size ||
549
+ [...applyFks].some(([col, s]) => {
550
+ const l = liveFks.get(col);
551
+ return !l || l.target !== s.target || l.onDelete !== s.onDelete;
552
+ });
553
+
370
554
  const destructive = needsDrop || needsTypeChange || renamedSources.size > 0 || modifierRebuildDestructive;
371
555
  if (destructive && !allowDestructive) {
372
556
  // The destructive part is gated off — skip the whole rebuild (any pending safe
@@ -378,10 +562,10 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
378
562
  ...destructiveReasons,
379
563
  ];
380
564
  skipped.push(`rebuild ${table} (${reasons.join(", ")})`);
381
- } else if (destructive || needsAdditiveRebuild || modifierRebuildSafe) {
382
- // A safe rebuild (expr-default column, default/notNull modifier change) needs no
383
- // permission — it loses no data.
384
- await rebuildTable(driver, table, def, live);
565
+ } else if (destructive || needsAdditiveRebuild || modifierRebuildSafe || fkChanged) {
566
+ // A safe rebuild (expr-default column, default/notNull modifier change, FK add/change)
567
+ // needs no permission — it loses no data.
568
+ await rebuildTable(driver, table, def, live, pkOf, fkSkip);
385
569
  rebuilt.push(table);
386
570
  }
387
571
 
@@ -392,6 +576,24 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
392
576
  for (const col of dropUniqueCols) {
393
577
  await driver.exec(`DROP INDEX IF EXISTS ${quoteIdent(indexName(table, col))}`, []);
394
578
  }
579
+
580
+ // Composite UNIQUE reconciliation (managed pramen_uidx_ indexes). Drop any live one
581
+ // the schema no longer declares; skip creating a new one whose tuples already have
582
+ // duplicates (the final index pass would otherwise throw). Creation itself is the
583
+ // idempotent index pass below.
584
+ const liveComposite = await liveCompositeUniques(driver, table);
585
+ const declaredComposite = new Set((def.uniques ?? []).map((c) => compositeKey(c)));
586
+ for (const [key, idxName] of liveComposite) {
587
+ if (!declaredComposite.has(key)) await driver.exec(`DROP INDEX IF EXISTS ${quoteIdent(idxName)}`, []);
588
+ }
589
+ for (const cols of def.uniques ?? []) {
590
+ const key = compositeKey(cols);
591
+ if (liveComposite.has(key)) continue;
592
+ if (await compositeHasDuplicates(driver, table, cols)) {
593
+ (compositeUniqueSkip.get(table) ?? compositeUniqueSkip.set(table, new Set()).get(table)!).add(key);
594
+ skipped.push(`add composite UNIQUE ${table}(${cols.join(", ")}) (duplicate tuples present)`);
595
+ }
596
+ }
395
597
  }
396
598
 
397
599
  // A partition MOVE — an entity that was applied in THIS partition before but the
@@ -419,7 +621,7 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
419
621
  // declaration is dropped above. A column whose new `unique()` has duplicate values is
420
622
  // skipped (reported above) so this doesn't throw.
421
623
  for (const [table, def] of entries) {
422
- for (const stmt of indexStatements(table, def, uniqueIndexSkip.get(table))) await driver.exec(stmt, []);
624
+ for (const stmt of indexStatements(table, def, uniqueIndexSkip.get(table), compositeUniqueSkip.get(table))) await driver.exec(stmt, []);
423
625
  }
424
626
 
425
627
  // Drop tables the schema no longer declares (internal bookkeeping tables skipped).
@@ -16,14 +16,22 @@ function bind(v: unknown): unknown {
16
16
 
17
17
  export type CmpOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "LIKE";
18
18
 
19
+ /** Substring match mode for the structured string operators (auto-escaping, so the
20
+ * needle's `%`/`_` are literal). Case-insensitive, matching SQLite's default LIKE. */
21
+ export type StrMode = "contains" | "prefix" | "suffix";
22
+
19
23
  export type SqlExpr =
20
24
  | { t: "true" }
21
25
  | { t: "false" }
22
26
  | { t: "cmp"; op: CmpOp; col: string; value: unknown }
23
27
  | { t: "in"; col: string; values: unknown[]; negate: boolean }
24
28
  | { t: "null"; col: string; negate: boolean }
29
+ // Structured substring match — the needle is escaped and wrapped, so `%`/`_` in the
30
+ // input match literally (unlike raw `like`, where the caller controls wildcards).
31
+ | { t: "strmatch"; col: string; needle: string; mode: StrMode }
25
32
  | { t: "and"; parts: SqlExpr[] }
26
33
  | { t: "or"; parts: SqlExpr[] }
34
+ | { t: "not"; expr: SqlExpr }
27
35
  // Relation traversal: `outerCol IN (SELECT selectCol FROM from WHERE where)`.
28
36
  // Built by the ACL layer (which knows schema + the target's read scope); the
29
37
  // inner predicate compiles inline so placeholders share the outer param sequence.
@@ -34,8 +42,10 @@ export const FALSE: SqlExpr = { t: "false" };
34
42
  export const cmp = (op: CmpOp, col: string, value: unknown): SqlExpr => ({ t: "cmp", op, col, value });
35
43
  export const isNull = (col: string, negate = false): SqlExpr => ({ t: "null", col, negate });
36
44
  export const inList = (col: string, values: unknown[], negate = false): SqlExpr => ({ t: "in", col, values, negate });
45
+ export const strMatch = (col: string, needle: string, mode: StrMode): SqlExpr => ({ t: "strmatch", col, needle, mode });
37
46
  export const and = (...parts: SqlExpr[]): SqlExpr => ({ t: "and", parts });
38
47
  export const or = (...parts: SqlExpr[]): SqlExpr => ({ t: "or", parts });
48
+ export const not = (expr: SqlExpr): SqlExpr => ({ t: "not", expr });
39
49
 
40
50
  /** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
41
51
  export const eq = (col: string, value: unknown): SqlExpr => (value === null ? isNull(col) : cmp("=", col, value));
@@ -49,6 +59,8 @@ export function compileWhere(input: Record<string, unknown>): SqlExpr {
49
59
  parts.push(and(...(v as Record<string, unknown>[]).map(compileWhere)));
50
60
  } else if (k === "OR") {
51
61
  parts.push(or(...(v as Record<string, unknown>[]).map(compileWhere)));
62
+ } else if (k === "NOT") {
63
+ parts.push(not(compileWhere(v as Record<string, unknown>)));
52
64
  } else {
53
65
  parts.push(columnPredicate(k, v));
54
66
  }
@@ -68,6 +80,9 @@ function columnPredicate(col: string, v: unknown): SqlExpr {
68
80
  case "lt": ops.push(cmp("<", col, val)); break;
69
81
  case "lte": ops.push(cmp("<=", col, val)); break;
70
82
  case "like": ops.push(cmp("LIKE", col, val)); break;
83
+ case "contains": ops.push(strMatch(col, String(val), "contains")); break;
84
+ case "startsWith": ops.push(strMatch(col, String(val), "prefix")); break;
85
+ case "endsWith": ops.push(strMatch(col, String(val), "suffix")); break;
71
86
  case "in": ops.push(inList(col, val as unknown[])); break;
72
87
  case "notIn": ops.push(inList(col, val as unknown[], true)); break;
73
88
  case "isNull": ops.push(isNull(col, !val)); break; // isNull:true => IS NULL
@@ -101,6 +116,15 @@ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] =
101
116
  return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
102
117
  case "null":
103
118
  return { sql: `${dialect.id(expr.col)} IS ${expr.negate ? "NOT " : ""}NULL`, params };
119
+ case "strmatch": {
120
+ // Escape the LIKE metacharacters in the needle (\, %, _) so they match literally,
121
+ // then wrap with wildcards per mode. ESCAPE '\' declares the escape char (SQLite +
122
+ // Postgres both support it). LIKE is ASCII-case-insensitive by default.
123
+ const esc = expr.needle.replace(/[\\%_]/g, "\\$&");
124
+ const pattern = expr.mode === "contains" ? `%${esc}%` : expr.mode === "prefix" ? `${esc}%` : `%${esc}`;
125
+ params.push(dialect.encode(pattern));
126
+ return { sql: `${dialect.id(expr.col)} LIKE ${dialect.placeholder(params.length)} ESCAPE '\\'`, params };
127
+ }
104
128
  case "in": {
105
129
  if (expr.values.length === 0) return { sql: expr.negate ? "1" : "0", params }; // empty: notIn=>all, in=>none
106
130
  const ph = expr.values.map((v) => (params.push(dialect.encode(v)), dialect.placeholder(params.length))).join(", ");
@@ -113,6 +137,8 @@ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] =
113
137
  const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
114
138
  return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
115
139
  }
140
+ case "not":
141
+ return { sql: `NOT (${compileExpr(expr.expr, dialect, params).sql})`, params };
116
142
  case "sub": {
117
143
  // Inner predicate shares `params`, so placeholder numbering stays correct
118
144
  // across dialects (? and $n alike).
@@ -169,6 +195,13 @@ export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
169
195
  const isNullVal = v === null || v === undefined;
170
196
  return expr.negate ? !isNullVal : isNullVal;
171
197
  }
198
+ case "strmatch": {
199
+ const left = row[expr.col];
200
+ if (typeof left !== "string") return false;
201
+ const s = left.toLowerCase();
202
+ const n = expr.needle.toLowerCase(); // CI, mirroring SQLite's default LIKE
203
+ return expr.mode === "contains" ? s.includes(n) : expr.mode === "prefix" ? s.startsWith(n) : s.endsWith(n);
204
+ }
172
205
  case "in": {
173
206
  const left = bind(row[expr.col]);
174
207
  if (left === null || left === undefined) return false; // can't demonstrate membership
@@ -180,6 +213,8 @@ export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
180
213
  return expr.parts.every((p) => evalExpr(p, row));
181
214
  case "or":
182
215
  return expr.parts.some((p) => evalExpr(p, row));
216
+ case "not":
217
+ return !evalExpr(expr.expr, row);
183
218
  case "sub":
184
219
  // Relation traversal needs a SQL round-trip; it isn't supported in the
185
220
  // in-memory cell-ACL `when` evaluator (those predicates must be single-table).
package/src/sdk/infer.ts CHANGED
@@ -41,7 +41,9 @@ export type InferRow<F extends EntityFields> = { [K in keyof F]: Cell<F[K]> };
41
41
  * which can drop columns per row — `InferRow` over-claims presence by design. */
42
42
  export type ProjectedRow<F extends EntityFields> = { [K in keyof F]?: Cell<F[K]> };
43
43
 
44
- /** Operators available on a column predicate. `like` is string-only. */
44
+ /** Operators available on a column predicate. String ops (`like`/`contains`/
45
+ * `startsWith`/`endsWith`) are string-only; `contains`/`startsWith`/`endsWith` escape
46
+ * the needle's wildcards (unlike `like`, where the caller writes `%`/`_`). */
45
47
  export interface WhereOps<V> {
46
48
  eq?: V | null;
47
49
  ne?: V | null;
@@ -52,16 +54,20 @@ export interface WhereOps<V> {
52
54
  in?: V[];
53
55
  notIn?: V[];
54
56
  like?: V extends string ? string : never;
57
+ contains?: V extends string ? string : never;
58
+ startsWith?: V extends string ? string : never;
59
+ endsWith?: V extends string ? string : never;
55
60
  isNull?: boolean;
56
61
  }
57
62
 
58
63
  /** Predicate input: per-column equality shorthand or an operator object, plus
59
- * nestable AND/OR groups. */
64
+ * nestable AND/OR/NOT groups. */
60
65
  export type WhereInput<F extends EntityFields> = {
61
66
  [K in keyof F]?: FieldTsType<F[K]> | null | WhereOps<FieldTsType<F[K]>>;
62
67
  } & {
63
68
  AND?: WhereInput<F>[];
64
69
  OR?: WhereInput<F>[];
70
+ NOT?: WhereInput<F>;
65
71
  };
66
72
 
67
73
  // --- partition boundary: runtime-only by decision (Issue 08) ---
@@ -141,11 +147,11 @@ export type FieldsOf<E> = E extends EntityDef<infer F, RelationDefs> ? F : never
141
147
  /** Extract a schema entry's relations. */
142
148
  export type RelationsOf<E> = E extends EntityDef<EntityFields, infer R> ? R : Record<string, never>;
143
149
 
144
- type RelValue<S extends SchemaDef, Rel> = Rel extends { kind: "belongsTo"; target: infer Tg }
150
+ type RelValue<S extends SchemaDef, Rel> = Rel extends { kind: "belongsTo" | "oneHasOne" | "oneHasOneInverse"; target: infer Tg }
145
151
  ? Tg extends keyof S
146
152
  ? InferRow<FieldsOf<S[Tg]>> | null
147
153
  : never
148
- : Rel extends { kind: "hasMany"; target: infer Tg }
154
+ : Rel extends { kind: "hasMany" | "manyToMany"; target: infer Tg }
149
155
  ? Tg extends keyof S
150
156
  ? InferRow<FieldsOf<S[Tg]>>[]
151
157
  : never
package/src/sdk/schema.ts CHANGED
@@ -71,11 +71,19 @@ export type EntityFields = Record<string, FieldDef>;
71
71
 
72
72
  // --- relations ---
73
73
 
74
+ /** FK ON DELETE behavior for an owning relation's real foreign key. `restrict` (the
75
+ * default) blocks deleting a referenced row; `cascade` deletes the referencing rows;
76
+ * `setNull` nulls the FK column (which must be nullable). Enforced by the SQLite engine
77
+ * at runtime on both DO and D1. */
78
+ export type OnDelete = "cascade" | "setNull" | "restrict";
79
+
74
80
  export interface BelongsToDef<T extends string = string> {
75
81
  readonly kind: "belongsTo";
76
82
  readonly target: T;
77
- /** Local column holding the target's primary key. */
83
+ /** Local column holding the target's primary key (a real FK: REFERENCES target(pk)). */
78
84
  readonly column: string;
85
+ /** ON DELETE action for the FK; omitted ⇒ `restrict` (SQLite default). */
86
+ readonly onDelete?: OnDelete;
79
87
  }
80
88
  export interface HasManyDef<T extends string = string> {
81
89
  readonly kind: "hasMany";
@@ -83,12 +91,45 @@ export interface HasManyDef<T extends string = string> {
83
91
  /** Column on the target referring back to this entity's primary key. */
84
92
  readonly column: string;
85
93
  }
86
- export type RelationDef = BelongsToDef | HasManyDef;
94
+ /** Many-to-many via an explicit junction entity. Logical (no FK constraints), like the
95
+ * other relation kinds: `through` is a normal entity you define and write to directly;
96
+ * `sourceColumn`/`targetColumn` are its columns holding this entity's and the target's
97
+ * primary keys. Source, junction, and target must share a partition (single-DO traversal). */
98
+ export interface ManyToManyDef<T extends string = string> {
99
+ readonly kind: "manyToMany";
100
+ readonly target: T;
101
+ readonly through: string;
102
+ readonly sourceColumn: string;
103
+ readonly targetColumn: string;
104
+ }
105
+ /** One-to-one (owning side): THIS entity holds `column` = the target's primary key, and
106
+ * the pairing is 1:1 — mark `column` `unique()` for the DB-enforced guarantee. Reads as a
107
+ * single target (like belongsTo); FK-capable via `onDelete`. */
108
+ export interface OneHasOneDef<T extends string = string> {
109
+ readonly kind: "oneHasOne";
110
+ readonly target: T;
111
+ readonly column: string;
112
+ readonly onDelete?: OnDelete;
113
+ }
114
+ /** One-to-one (inverse side): the TARGET holds `column` referencing THIS entity's primary
115
+ * key. Reads as a single target (the reverse of a oneHasOne), or null. */
116
+ export interface OneHasOneInverseDef<T extends string = string> {
117
+ readonly kind: "oneHasOneInverse";
118
+ readonly target: T;
119
+ readonly column: string;
120
+ }
121
+ export type RelationDef = BelongsToDef | HasManyDef | ManyToManyDef | OneHasOneDef | OneHasOneInverseDef;
87
122
  export type RelationDefs = Record<string, RelationDef>;
88
123
 
89
124
  const relationBuilders = {
90
- belongsTo: <T extends string>(target: T, column: string) => ({ kind: "belongsTo", target, column }) as const,
125
+ belongsTo: <T extends string>(target: T, column: string, opts?: { onDelete?: OnDelete }) =>
126
+ ({ kind: "belongsTo", target, column, onDelete: opts?.onDelete }) as const,
91
127
  hasMany: <T extends string>(target: T, column: string) => ({ kind: "hasMany", target, column }) as const,
128
+ oneHasOne: <T extends string>(target: T, column: string, opts?: { onDelete?: OnDelete }) =>
129
+ ({ kind: "oneHasOne", target, column, onDelete: opts?.onDelete }) as const,
130
+ oneHasOneInverse: <T extends string>(target: T, column: string) => ({ kind: "oneHasOneInverse", target, column }) as const,
131
+ manyToMany: <T extends string>(target: T, opts: { through: string; sourceColumn: string; targetColumn: string }) =>
132
+ ({ kind: "manyToMany", target, through: opts.through, sourceColumn: opts.sourceColumn, targetColumn: opts.targetColumn }) as const,
92
133
  };
93
134
  export type RelationBuilders = typeof relationBuilders;
94
135
 
@@ -137,18 +178,23 @@ export interface EntityDef<F extends EntityFields = EntityFields, R extends Rela
137
178
  readonly partition: string;
138
179
  /** Declarative write-triggers (see TriggerDef). Always an array (possibly empty). */
139
180
  readonly triggers: readonly TriggerDef[];
181
+ /** Composite (multi-column) UNIQUE constraints, each a tuple of column names, enforced
182
+ * via a managed unique index. Single-column uniqueness stays on the field (`unique()`).
183
+ * Always an array (possibly empty). */
184
+ readonly uniques: readonly (readonly string[])[];
140
185
  }
141
186
 
142
187
  export function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(
143
188
  build: (t: FieldBuilders) => F,
144
189
  relations?: (r: RelationBuilders) => R,
145
- opts?: { partition?: string; triggers?: readonly TriggerDef[] },
190
+ opts?: { partition?: string; triggers?: readonly TriggerDef[]; unique?: readonly (readonly string[])[] },
146
191
  ): EntityDef<F, R> {
147
192
  return {
148
193
  fields: build(builders),
149
194
  relations: (relations ? relations(relationBuilders) : {}) as R,
150
195
  partition: opts?.partition ?? DEFAULT_PARTITION,
151
196
  triggers: opts?.triggers ?? [],
197
+ uniques: opts?.unique ?? [],
152
198
  };
153
199
  }
154
200
 
@@ -309,6 +355,23 @@ export function validateSchema(schema: SchemaDef): void {
309
355
  `put both entities in the same partition or drop the relation.`,
310
356
  );
311
357
  }
358
+ if (rel.kind === "manyToMany") {
359
+ const through = schema[rel.through];
360
+ if (!through) {
361
+ throw new Error(`relation '${entity}.${relName}' names an unknown junction entity '${rel.through}'.`);
362
+ }
363
+ for (const [label, col] of [["sourceColumn", rel.sourceColumn], ["targetColumn", rel.targetColumn]] as const) {
364
+ if (!(col in through.fields)) {
365
+ throw new Error(`relation '${entity}.${relName}' ${label} '${col}' is not a column of junction '${rel.through}'.`);
366
+ }
367
+ }
368
+ if (partitionOf(schema, rel.through) !== pE) {
369
+ throw new Error(
370
+ `relation '${entity}.${relName}' junction '${rel.through}' is in a different partition than '${entity}' — ` +
371
+ `the source, junction, and target must share a partition (traversal is single-DO).`,
372
+ );
373
+ }
374
+ }
312
375
  }
313
376
  for (const t of def.triggers) {
314
377
  if (!t.task) throw new Error(`trigger on '${entity}' is missing a 'task'.`);
@@ -322,5 +385,15 @@ export function validateSchema(schema: SchemaDef): void {
322
385
  }
323
386
  }
324
387
  }
388
+ for (const cols of def.uniques) {
389
+ if (cols.length < 2) {
390
+ throw new Error(`composite unique on '${entity}' needs at least two columns (use unique() for one).`);
391
+ }
392
+ for (const c of cols) {
393
+ if (!(c in def.fields)) {
394
+ throw new Error(`composite unique on '${entity}' references unknown column '${c}'.`);
395
+ }
396
+ }
397
+ }
325
398
  }
326
399
  }