@edge-base/server 0.3.1 → 0.3.3

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.
Files changed (72) hide show
  1. package/admin-build/_app/immutable/chunks/{C-zXt-cq.js → ArkGiTMv.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{DF40xjpj.js → B6_FvkIG.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{Cek51lkE.js → BM46GgYk.js} +3 -3
  4. package/admin-build/_app/immutable/chunks/{BSIzoJ5I.js → Bxlz62x0.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/ByFwWOVj.js +1 -0
  6. package/admin-build/_app/immutable/chunks/{aPulNooa.js → CKJGIsEj.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{D0mNYcxN.js → CUEBmgYR.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{C1Mycuvd.js → Cav53qqs.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{DOOhHO-v.js → CtOkHpP1.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{BkrC_6Ss.js → D3arvney.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{Dvlxm2Iq.js → DC_HyNNV.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{dZsKGnWZ.js → Da6ZRIF1.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BeYswbvA.js → DfwnKUzy.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/DuAuob_N.js +1 -0
  15. package/admin-build/_app/immutable/chunks/{BunyH6GE.js → ZQw8NgPJ.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{Xm1dHMTE.js → fgIr2CuT.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.j1WHBlZX.js → app.BtTg2P3N.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.D21f0BIj.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.Oo7ZpbR-.js → 0.CqnCTNhX.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.zgMnKHns.js → 1.BLn3o9jT.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.u5mkRiUe.js → 10.Bp6HrvpD.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.9ynAgaxV.js → 11.79DhgZw-.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.M_EqzJ6s.js → 12.A7_yb57H.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.D_NQ9q60.js → 13.DgMDZXO8.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.-vENIJ2w.js → 14.DycfMAIE.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.DoMCZhGv.js → 15.Bk_-RJ3j.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.6BmnzScq.js → 16.B7Vjh-Xp.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.q6QQ9N_J.js → 17.DKfB3-O7.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.DUhJ6dKj.js → 18.Bwq1YHfM.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CvkvPlGP.js → 19.3S-h9EgK.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.cACSbRIl.js → 20.Cm2djLN8.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.C3hqim4E.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.BsQ6xeKr.js → 22.DltIIio7.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.CNW3mLz5.js → 23.qVqH8BFx.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CReqtEOy.js → 24.BBgqZFZ0.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.BuxVhqlb.js → 25.2xJDygjz.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.C-LzPf62.js → 26.B7pxYnqL.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.Dl5RL_tB.js → 27.P3eodDFO.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.CWfSQgjH.js → 28.CPEn1uNw.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.wUSfh2eQ.js → 29.D7ZGYHTI.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.D_laBXeK.js → 3.UaLSRTpv.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.DcMhZyQ0.js → 30.D-iftjK2.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.CSwhNsNi.js → 31.CHSNieUr.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Cq28vPI2.js → 4.BgPFR0hR.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BTT9B3oI.js → 5.BRn6WaF_.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.iCmmzXZp.js → 6.Cdem1OHu.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.qY5v7qqJ.js → 7.FpFS7ceb.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.C6p4RvgK.js → 8.aCQtyAed.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.f548N7zh.js → 9.9BZCZcm_.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +193 -0
  53. package/package.json +3 -3
  54. package/src/__tests__/error-format.test.ts +21 -0
  55. package/src/__tests__/postgres-api-consistency.test.ts +240 -0
  56. package/src/__tests__/postgres-batch-compat.test.ts +33 -27
  57. package/src/__tests__/postgres-batch.test.ts +394 -0
  58. package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
  59. package/src/__tests__/postgres-transact.test.ts +148 -0
  60. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  61. package/src/durable-objects/database-do.ts +455 -15
  62. package/src/durable-objects/room-runtime-base.ts +8 -1
  63. package/src/lib/d1-handler.ts +416 -50
  64. package/src/lib/errors.ts +8 -4
  65. package/src/lib/internal-transport.ts +10 -0
  66. package/src/lib/postgres-handler.ts +562 -84
  67. package/src/routes/auth.ts +58 -46
  68. package/src/routes/tables.ts +140 -0
  69. package/admin-build/_app/immutable/chunks/D3hdqsfp.js +0 -1
  70. package/admin-build/_app/immutable/chunks/DdjFeYwG.js +0 -1
  71. package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +0 -1
  72. package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +0 -1
@@ -74,6 +74,21 @@ interface DOEnv {
74
74
  SERVICE_KEY?: string;
75
75
  }
76
76
 
77
+ // ─── Rule-rejection wording ───
78
+
79
+ /**
80
+ * Canonical rule-rejection message, byte-for-byte identical to the D1 handler's
81
+ * d1RuleRejectedMessage (and the PostgreSQL handler, aligned in Phase 2) so all
82
+ * three storage providers emit the same string. Do NOT import from d1-handler.ts
83
+ * to avoid a Worker/DO ↔ handler dependency; the format is replicated here.
84
+ */
85
+ function doRuleRejectedMessage(tableName: string, action: string, id?: string): string {
86
+ if (id) {
87
+ return `Access denied. The '${action}' access rule for table '${tableName}' rejected record '${id}'.`;
88
+ }
89
+ return `Access denied. The '${action}' access rule for table '${tableName}' rejected this request.`;
90
+ }
91
+
77
92
  // ─── DatabaseDO Class ───
78
93
 
79
94
  export class DatabaseDO extends DurableObject<DOEnv> {
@@ -447,7 +462,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
447
462
  if (!canRead) {
448
463
  throw new EdgeBaseError(
449
464
  403,
450
- `Access denied: 'read' rule blocked row "${row.id}" in table "${name}".`,
465
+ doRuleRejectedMessage(name, 'read', String(row.id)),
451
466
  );
452
467
  }
453
468
  }
@@ -460,10 +475,13 @@ export class DatabaseDO extends DurableObject<DOEnv> {
460
475
  // Build response
461
476
  const response: Record<string, unknown> = { items: enrichedRows };
462
477
 
463
- // Offset pagination: include total, page, perPage
478
+ // Offset pagination: include total, page, perPage.
479
+ // ?includeTotal=0/false skips the COUNT query — total comes back null.
464
480
  if (countSql && countParams) {
465
- const countResult = [...this.sql(countSql, ...countParams)];
466
- const total = (countResult[0]?.total as number) ?? 0;
481
+ const includeTotal = !['0', 'false'].includes((c.req.query('includeTotal') ?? '').toLowerCase());
482
+ const total = includeTotal
483
+ ? ((([...this.sql(countSql, ...countParams)])[0]?.total as number) ?? 0)
484
+ : null;
467
485
  const perPage = options.pagination?.perPage ?? options.pagination?.limit ?? 100;
468
486
  response.total = total;
469
487
  response.page = options.pagination?.page ?? 1;
@@ -563,7 +581,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
563
581
  if (!canRead) {
564
582
  throw new EdgeBaseError(
565
583
  403,
566
- `Access denied: 'read' rule blocked row "${row.id}" in table "${name}".`,
584
+ doRuleRejectedMessage(name, 'read', String(row.id)),
567
585
  );
568
586
  }
569
587
  }
@@ -606,7 +624,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
606
624
  if (!canRead)
607
625
  throw new EdgeBaseError(
608
626
  403,
609
- `Access denied: 'read' rule blocked record "${id}" in table "${name}".`,
627
+ doRuleRejectedMessage(name, 'read', id),
610
628
  );
611
629
  }
612
630
 
@@ -876,7 +894,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
876
894
  if (!canUpdate)
877
895
  throw new EdgeBaseError(
878
896
  403,
879
- `Access denied: 'update' rule blocked record "${id}" in table "${name}".`,
897
+ doRuleRejectedMessage(name, 'update', id),
880
898
  );
881
899
  }
882
900
 
@@ -1037,7 +1055,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
1037
1055
  if (!canDelete)
1038
1056
  throw new EdgeBaseError(
1039
1057
  403,
1040
- `Access denied: 'delete' rule blocked record "${id}" in table "${name}".`,
1058
+ doRuleRejectedMessage(name, 'delete', id),
1041
1059
  );
1042
1060
  }
1043
1061
 
@@ -1150,7 +1168,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
1150
1168
  if (!canInsert)
1151
1169
  throw new EdgeBaseError(
1152
1170
  403,
1153
- `Access denied: 'insert' rule blocked batch insert on table "${name}".`,
1171
+ doRuleRejectedMessage(name, 'insert'),
1154
1172
  );
1155
1173
  }
1156
1174
  // update/delete rules are evaluated per-row inside the transaction below
@@ -1282,13 +1300,13 @@ export class DatabaseDO extends DurableObject<DOEnv> {
1282
1300
  if (!canUpdate)
1283
1301
  throw new EdgeBaseError(
1284
1302
  403,
1285
- `Access denied: 'update' rule blocked record "${id}" in table "${name}".`,
1303
+ doRuleRejectedMessage(name, 'update', id),
1286
1304
  );
1287
1305
  } catch (e) {
1288
1306
  if (e instanceof EdgeBaseError) throw e;
1289
1307
  throw new EdgeBaseError(
1290
1308
  403,
1291
- `Access denied: 'update' rule blocked record "${id}" in table "${name}".`,
1309
+ doRuleRejectedMessage(name, 'update', id),
1292
1310
  );
1293
1311
  }
1294
1312
  }
@@ -1368,13 +1386,13 @@ export class DatabaseDO extends DurableObject<DOEnv> {
1368
1386
  if (!canDelete)
1369
1387
  throw new EdgeBaseError(
1370
1388
  403,
1371
- `Access denied: 'delete' rule blocked record "${id}" in table "${name}".`,
1389
+ doRuleRejectedMessage(name, 'delete', id),
1372
1390
  );
1373
1391
  } catch (e) {
1374
1392
  if (e instanceof EdgeBaseError) throw e;
1375
1393
  throw new EdgeBaseError(
1376
1394
  403,
1377
- `Access denied: 'delete' rule blocked record "${id}" in table "${name}".`,
1395
+ doRuleRejectedMessage(name, 'delete', id),
1378
1396
  );
1379
1397
  }
1380
1398
  }
@@ -1508,7 +1526,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
1508
1526
  if (!preCheck)
1509
1527
  throw new EdgeBaseError(
1510
1528
  403,
1511
- `Access denied: '${body.action}' rule blocked batch-by-filter on table "${name}".`,
1529
+ doRuleRejectedMessage(name, body.action),
1512
1530
  );
1513
1531
  } else if (this.config.release) {
1514
1532
  // Release mode: no rule defined → deny
@@ -1560,7 +1578,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
1560
1578
  if (rows.length === 0) {
1561
1579
  throw new EdgeBaseError(
1562
1580
  403,
1563
- `Access denied: '${body.action}' rule blocked all matched rows in table "${name}".`,
1581
+ doRuleRejectedMessage(name, body.action),
1564
1582
  );
1565
1583
  }
1566
1584
  }
@@ -1649,6 +1667,428 @@ export class DatabaseDO extends DurableObject<DOEnv> {
1649
1667
  return c.json({ processed, succeeded });
1650
1668
  });
1651
1669
 
1670
+ // TRANSACT: POST /transact — multi-table atomic writes (SQLite DO provider).
1671
+ // Ordered insert/update/delete ops across tables in ONE transactionSync,
1672
+ // plus `expect` ops that assert row state and abort with 409 when unmet —
1673
+ // closing check-then-write races for app servers without exposing raw
1674
+ // BEGIN/COMMIT. Rule semantics mirror /tables/:name/batch: table-level
1675
+ // insert rules before the transaction, per-row update/delete (and expect
1676
+ // read) rules inside it, Service Key bypasses.
1677
+ app.post('/transact', async (c) => {
1678
+ interface TransactOp {
1679
+ table?: unknown;
1680
+ op?: unknown;
1681
+ id?: unknown;
1682
+ data?: unknown;
1683
+ where?: unknown;
1684
+ exists?: unknown;
1685
+ }
1686
+ const body = await c.req.json<{ operations?: TransactOp[] }>();
1687
+ const operations = body.operations;
1688
+ if (!Array.isArray(operations) || operations.length === 0) {
1689
+ throw validationError('transact requires a non-empty operations array.');
1690
+ }
1691
+ const MAX_TRANSACT_OPS = 500;
1692
+ if (operations.length > MAX_TRANSACT_OPS) {
1693
+ throw validationError(
1694
+ `Transact limit exceeded: ${operations.length} operations (max ${MAX_TRANSACT_OPS}).`,
1695
+ );
1696
+ }
1697
+
1698
+ const auth = this.parseAuthContext(c.req.raw);
1699
+ const isSK = this.isServiceKeyRequest(c.req.raw);
1700
+
1701
+ // Validate shapes + resolve per-table context up front.
1702
+ interface TxTableCtx {
1703
+ tableConfig: TableConfig;
1704
+ effective: ReturnType<typeof buildEffectiveSchema>;
1705
+ rules: TableRules | undefined;
1706
+ }
1707
+ const tableCtx = new Map<string, TxTableCtx>();
1708
+ for (const op of operations) {
1709
+ if (typeof op?.table !== 'string' || !op.table) {
1710
+ throw validationError('Each transact operation must name a table.');
1711
+ }
1712
+ if (op.op !== 'insert' && op.op !== 'update' && op.op !== 'delete' && op.op !== 'expect') {
1713
+ throw validationError(
1714
+ `Unknown transact op '${String(op.op)}'. Expected insert | update | delete | expect.`,
1715
+ );
1716
+ }
1717
+ if (op.op === 'insert' && (!op.data || typeof op.data !== 'object')) {
1718
+ throw validationError('transact insert requires a data object.');
1719
+ }
1720
+ if (op.op === 'update') {
1721
+ if (typeof op.id !== 'string' || !op.id) {
1722
+ throw validationError('transact update requires an id.');
1723
+ }
1724
+ if (!op.data || typeof op.data !== 'object') {
1725
+ throw validationError('transact update requires a data object.');
1726
+ }
1727
+ }
1728
+ if (op.op === 'delete' && (typeof op.id !== 'string' || !op.id)) {
1729
+ throw validationError('transact delete requires an id.');
1730
+ }
1731
+ if (op.op === 'expect') {
1732
+ const hasId = typeof op.id === 'string' && op.id.length > 0;
1733
+ const hasWhere = Array.isArray(op.where) && op.where.length > 0;
1734
+ if (!hasId && !hasWhere) {
1735
+ throw validationError('transact expect requires an id and/or where conditions.');
1736
+ }
1737
+ if (typeof op.exists !== 'boolean') {
1738
+ throw validationError('transact expect requires a boolean exists.');
1739
+ }
1740
+ }
1741
+ if (!tableCtx.has(op.table)) {
1742
+ this.ensureTableExists(op.table);
1743
+ const tableConfig = this.getTableConfig(op.table);
1744
+ if (!tableConfig) {
1745
+ throw validationError(
1746
+ `Table '${op.table}' is not defined in the schema configuration.`,
1747
+ );
1748
+ }
1749
+ tableCtx.set(op.table, {
1750
+ tableConfig,
1751
+ effective: buildEffectiveSchema(tableConfig.schema),
1752
+ rules: getTableAccess(tableConfig ?? undefined) as TableRules | undefined,
1753
+ });
1754
+ }
1755
+ }
1756
+
1757
+ // Table-level insert rules — evaluated before the transaction (like /batch).
1758
+ if (!isSK) {
1759
+ const checked = new Set<string>();
1760
+ for (const op of operations) {
1761
+ if (op.op !== 'insert' || checked.has(op.table as string)) continue;
1762
+ checked.add(op.table as string);
1763
+ const rules = tableCtx.get(op.table as string)?.rules;
1764
+ if (rules?.insert) {
1765
+ const canInsert = await this.evalRowRule(rules.insert, auth, {});
1766
+ if (!canInsert) {
1767
+ throw new EdgeBaseError(
1768
+ 403,
1769
+ doRuleRejectedMessage(String(op.table), 'insert'),
1770
+ );
1771
+ }
1772
+ }
1773
+ }
1774
+ }
1775
+
1776
+ const results: Record<string, unknown>[] = [];
1777
+ const liveChanges: Array<{
1778
+ table: string;
1779
+ type: 'added' | 'modified' | 'removed';
1780
+ docId: string;
1781
+ data: Record<string, unknown> | null;
1782
+ }> = [];
1783
+ const triggerOps: Array<{
1784
+ table: string;
1785
+ action: 'insert' | 'update' | 'delete';
1786
+ payload: { before?: Record<string, unknown>; after?: Record<string, unknown> };
1787
+ }> = [];
1788
+
1789
+ this.ctx.storage.transactionSync(() => {
1790
+ const now = new Date().toISOString();
1791
+ for (const op of operations) {
1792
+ const name = op.table as string;
1793
+ const { tableConfig, effective, rules } = tableCtx.get(name) as TxTableCtx;
1794
+
1795
+ if (op.op === 'expect') {
1796
+ const clauses: string[] = [];
1797
+ const params: unknown[] = [];
1798
+ if (typeof op.id === 'string' && op.id) {
1799
+ clauses.push('"id" = ?');
1800
+ params.push(op.id);
1801
+ }
1802
+ for (const cond of (op.where as unknown[]) ?? []) {
1803
+ if (!Array.isArray(cond) || cond.length !== 3) {
1804
+ throw validationError('transact expect where entries must be [field, "==", value].');
1805
+ }
1806
+ const [field, cmp, value] = cond as [unknown, unknown, unknown];
1807
+ if (typeof field !== 'string' || !field) {
1808
+ throw validationError('transact expect where field must be a string.');
1809
+ }
1810
+ if (cmp !== '==') {
1811
+ throw validationError(
1812
+ `transact expect only supports '==' conditions (got '${String(cmp)}').`,
1813
+ );
1814
+ }
1815
+ if (value === null) {
1816
+ clauses.push(`"${field.replace(/"/g, '""')}" IS NULL`);
1817
+ continue;
1818
+ }
1819
+ if (typeof value === 'object') {
1820
+ throw validationError('transact expect cannot compare object values.');
1821
+ }
1822
+ clauses.push(`"${field.replace(/"/g, '""')}" = ?`);
1823
+ params.push(
1824
+ effective[field]?.type === 'boolean'
1825
+ ? (value === true || value === 'true' || value === 1 || value === '1' ? 1 : 0)
1826
+ : value,
1827
+ );
1828
+ }
1829
+ const rows = [
1830
+ ...this.sql(
1831
+ `SELECT * FROM "${name}" WHERE ${clauses.join(' AND ')} LIMIT 1`,
1832
+ ...params,
1833
+ ),
1834
+ ];
1835
+ // Read rule guards expect probes so they cannot leak row existence.
1836
+ if (!isSK && rows.length > 0 && rules?.read && typeof rules.read === 'function') {
1837
+ let canRead = false;
1838
+ try {
1839
+ canRead = (
1840
+ rules.read as (a: AuthContext | null, row: Record<string, unknown>) => boolean
1841
+ )(auth, rows[0] as Record<string, unknown>);
1842
+ } catch {
1843
+ canRead = false;
1844
+ }
1845
+ if (!canRead) {
1846
+ throw new EdgeBaseError(
1847
+ 403,
1848
+ doRuleRejectedMessage(name, 'read'),
1849
+ );
1850
+ }
1851
+ }
1852
+ if (op.exists === true && rows.length === 0) {
1853
+ throw new EdgeBaseError(
1854
+ 409,
1855
+ `Transaction expectation failed: expected a matching row in "${name}".`,
1856
+ );
1857
+ }
1858
+ if (op.exists === false && rows.length > 0) {
1859
+ throw new EdgeBaseError(
1860
+ 409,
1861
+ `Transaction expectation failed: expected no matching row in "${name}".`,
1862
+ );
1863
+ }
1864
+ results.push({ expected: true });
1865
+ continue;
1866
+ }
1867
+
1868
+ if (op.op === 'insert') {
1869
+ const item = op.data as Record<string, unknown>;
1870
+ const validation = validateInsert(item, tableConfig.schema);
1871
+ if (!validation.valid) {
1872
+ throw validationError(
1873
+ `Transact insert on "${name}" failed validation. See data for field-level errors.`,
1874
+ Object.fromEntries(
1875
+ Object.entries(validation.errors).map(([k, v]) => [
1876
+ k,
1877
+ { code: 'invalid', message: v },
1878
+ ]),
1879
+ ),
1880
+ );
1881
+ }
1882
+ const id = (item.id as string) || generateId();
1883
+ const record: Record<string, unknown> = { ...item, id };
1884
+ if ('createdAt' in effective) record.createdAt = now;
1885
+ if ('updatedAt' in effective) record.updatedAt = now;
1886
+ for (const [fname, field] of Object.entries(effective)) {
1887
+ if (record[fname] === undefined && field.default !== undefined) {
1888
+ record[fname] = field.default;
1889
+ }
1890
+ }
1891
+ let columns: string[];
1892
+ if (!tableConfig.schema) {
1893
+ columns = Object.keys(record);
1894
+ this.ensureSchemalessColumns(
1895
+ name,
1896
+ columns.filter((k) => !(k in effective)),
1897
+ );
1898
+ } else {
1899
+ columns = Object.keys(record).filter((k) => k in effective);
1900
+ }
1901
+ const values = columns.map((k) => {
1902
+ const v = record[k];
1903
+ if (
1904
+ effective[k]?.type === 'json' &&
1905
+ v !== null &&
1906
+ v !== undefined &&
1907
+ typeof v === 'object'
1908
+ ) {
1909
+ return JSON.stringify(v);
1910
+ }
1911
+ if (effective[k]?.type === 'boolean' && v !== null && v !== undefined) {
1912
+ return v === true || v === 'true' || v === 1 || v === '1' ? 1 : 0;
1913
+ }
1914
+ return v;
1915
+ });
1916
+ const placeholders = columns.map(() => '?').join(', ');
1917
+ const colStr = columns.map((col) => `"${col}"`).join(', ');
1918
+ this.sql(`INSERT INTO "${name}" (${colStr}) VALUES (${placeholders})`, ...values);
1919
+ results.push({ inserted: record });
1920
+ liveChanges.push({ table: name, type: 'added', docId: id, data: record });
1921
+ triggerOps.push({ table: name, action: 'insert', payload: { after: record } });
1922
+ continue;
1923
+ }
1924
+
1925
+ if (op.op === 'update') {
1926
+ const id = op.id as string;
1927
+ const data = op.data as Record<string, unknown>;
1928
+ const validation = validateUpdate(data, tableConfig.schema);
1929
+ if (!validation.valid) {
1930
+ throw validationError(
1931
+ `Transact update on "${name}" failed validation. See data for field-level errors.`,
1932
+ Object.fromEntries(
1933
+ Object.entries(validation.errors).map(([k, v]) => [
1934
+ k,
1935
+ { code: 'invalid', message: v },
1936
+ ]),
1937
+ ),
1938
+ );
1939
+ }
1940
+ const beforeRows = [...this.sql(`SELECT * FROM "${name}" WHERE "id" = ?`, id)];
1941
+ const beforeRow = beforeRows[0] as Record<string, unknown> | undefined;
1942
+ if (!isSK && beforeRow && rules?.update && typeof rules.update === 'function') {
1943
+ let canUpdate = false;
1944
+ try {
1945
+ canUpdate = (
1946
+ rules.update as (a: AuthContext | null, row: Record<string, unknown>) => boolean
1947
+ )(auth, beforeRow);
1948
+ } catch {
1949
+ canUpdate = false;
1950
+ }
1951
+ if (!canUpdate) {
1952
+ throw new EdgeBaseError(
1953
+ 403,
1954
+ doRuleRejectedMessage(name, 'update', id),
1955
+ );
1956
+ }
1957
+ }
1958
+ const updateData = { ...data };
1959
+ delete updateData.id;
1960
+ delete updateData.createdAt;
1961
+ if ('updatedAt' in effective && effective.updatedAt?.onUpdate === 'now') {
1962
+ updateData.updatedAt = now;
1963
+ }
1964
+ if (!tableConfig.schema) {
1965
+ this.ensureSchemalessColumns(
1966
+ name,
1967
+ Object.keys(updateData).filter((k) => !(k in effective)),
1968
+ );
1969
+ }
1970
+ for (const [key, value] of Object.entries(updateData)) {
1971
+ if (
1972
+ effective[key]?.type === 'json' &&
1973
+ value !== null &&
1974
+ value !== undefined &&
1975
+ typeof value === 'object' &&
1976
+ !('$op' in value)
1977
+ ) {
1978
+ updateData[key] = JSON.stringify(value);
1979
+ } else if (
1980
+ effective[key]?.type === 'boolean' &&
1981
+ value !== null &&
1982
+ value !== undefined &&
1983
+ (typeof value !== 'object' || !('$op' in value))
1984
+ ) {
1985
+ updateData[key] =
1986
+ value === true || value === 'true' || value === 1 || value === '1' ? 1 : 0;
1987
+ }
1988
+ }
1989
+ const { setClauses, params } = parseUpdateBody(updateData);
1990
+ if (setClauses.length > 0) {
1991
+ params.push(id);
1992
+ this.sql(`UPDATE "${name}" SET ${setClauses.join(', ')} WHERE "id" = ?`, ...params);
1993
+ }
1994
+ const afterRows = [...this.sql(`SELECT * FROM "${name}" WHERE "id" = ?`, id)];
1995
+ const afterRow =
1996
+ afterRows.length > 0
1997
+ ? (afterRows[0] as Record<string, unknown>)
1998
+ : { id, ...data };
1999
+ results.push({ updated: afterRow });
2000
+ if (beforeRow) {
2001
+ liveChanges.push({ table: name, type: 'modified', docId: id, data: afterRow });
2002
+ triggerOps.push({
2003
+ table: name,
2004
+ action: 'update',
2005
+ payload: { before: beforeRow, after: afterRow },
2006
+ });
2007
+ }
2008
+ continue;
2009
+ }
2010
+
2011
+ // delete
2012
+ const id = op.id as string;
2013
+ const existing = [...this.sql(`SELECT * FROM "${name}" WHERE "id" = ?`, id)];
2014
+ if (existing.length > 0) {
2015
+ if (!isSK && rules?.delete && typeof rules.delete === 'function') {
2016
+ let canDelete = false;
2017
+ try {
2018
+ canDelete = (
2019
+ rules.delete as (a: AuthContext | null, row: Record<string, unknown>) => boolean
2020
+ )(auth, existing[0] as Record<string, unknown>);
2021
+ } catch {
2022
+ canDelete = false;
2023
+ }
2024
+ if (!canDelete) {
2025
+ throw new EdgeBaseError(
2026
+ 403,
2027
+ doRuleRejectedMessage(name, 'delete', id),
2028
+ );
2029
+ }
2030
+ }
2031
+ liveChanges.push({ table: name, type: 'removed', docId: id, data: null });
2032
+ triggerOps.push({
2033
+ table: name,
2034
+ action: 'delete',
2035
+ payload: { before: existing[0] as Record<string, unknown> },
2036
+ });
2037
+ }
2038
+ this.sql(`DELETE FROM "${name}" WHERE "id" = ?`, id);
2039
+ results.push({ deleted: true, id });
2040
+ }
2041
+ });
2042
+
2043
+ // Post-commit: database-live events grouped per table (same thresholds as /batch).
2044
+ const changesByTable = new Map<string, typeof liveChanges>();
2045
+ for (const change of liveChanges) {
2046
+ const list = changesByTable.get(change.table) ?? [];
2047
+ list.push(change);
2048
+ changesByTable.set(change.table, list);
2049
+ }
2050
+ const transactBatchThreshold = resolveDbLiveBatchThreshold(this.config);
2051
+ for (const [tableName, changes] of changesByTable) {
2052
+ if (changes.length >= transactBatchThreshold) {
2053
+ this.ctx.waitUntil(
2054
+ this.emitDbLiveBatchEvent(
2055
+ tableName,
2056
+ changes.map(({ type, docId, data }) => ({ type, docId, data })),
2057
+ ),
2058
+ );
2059
+ } else {
2060
+ for (const change of changes) {
2061
+ this.ctx.waitUntil(
2062
+ this.emitDbLiveEvent(tableName, change.type, change.docId, change.data),
2063
+ );
2064
+ }
2065
+ }
2066
+ }
2067
+
2068
+ // Post-commit: DB triggers per operation (same shape as /batch).
2069
+ const doOriginTransact = this.doName ? parseDbDoName(this.doName) : { namespace: 'shared' };
2070
+ const transactTriggerContext = {
2071
+ databaseNamespace: this.env.DATABASE,
2072
+ authNamespace: this.env.AUTH,
2073
+ kvNamespace: this.env.KV,
2074
+ config: this.config,
2075
+ serviceKey: this.getServiceKey(),
2076
+ };
2077
+ for (const trig of triggerOps) {
2078
+ this.ctx.waitUntil(
2079
+ executeDbTriggers(
2080
+ trig.table,
2081
+ trig.action,
2082
+ trig.payload,
2083
+ transactTriggerContext,
2084
+ doOriginTransact,
2085
+ ),
2086
+ );
2087
+ }
2088
+
2089
+ return c.json({ results });
2090
+ });
2091
+
1652
2092
  // INTERNAL: POST /internal/sql — raw SQL execution for server SDK
1653
2093
  // Only accessible via Worker-level /api/sql route which validates Service Key.
1654
2094
  // Parameterized queries enforced: query + params are separate.
@@ -626,7 +626,14 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
626
626
  }
627
627
 
628
628
  if (earliest < Infinity) {
629
- this.ctx.storage.setAlarm(earliest);
629
+ // Never schedule at or before now. workerd re-fires a past-due alarm
630
+ // immediately without yielding the DO input gate, which spins into a
631
+ // tight loop ("Alarm exceeded its allowed execution time" →
632
+ // broken.dropped) and wedges the object so queued fetches hang forever.
633
+ // A due timer (fireAt <= now, e.g. restored from storage after
634
+ // hibernation) is still handled on the very next tick, just with a gap
635
+ // that lets pending requests interleave.
636
+ this.ctx.storage.setAlarm(Math.max(earliest, Date.now() + 1));
630
637
  }
631
638
  }
632
639