@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.
- package/admin-build/_app/immutable/chunks/{C-zXt-cq.js → ArkGiTMv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DF40xjpj.js → B6_FvkIG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Cek51lkE.js → BM46GgYk.js} +3 -3
- package/admin-build/_app/immutable/chunks/{BSIzoJ5I.js → Bxlz62x0.js} +1 -1
- package/admin-build/_app/immutable/chunks/ByFwWOVj.js +1 -0
- package/admin-build/_app/immutable/chunks/{aPulNooa.js → CKJGIsEj.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D0mNYcxN.js → CUEBmgYR.js} +1 -1
- package/admin-build/_app/immutable/chunks/{C1Mycuvd.js → Cav53qqs.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DOOhHO-v.js → CtOkHpP1.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BkrC_6Ss.js → D3arvney.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dvlxm2Iq.js → DC_HyNNV.js} +1 -1
- package/admin-build/_app/immutable/chunks/{dZsKGnWZ.js → Da6ZRIF1.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BeYswbvA.js → DfwnKUzy.js} +1 -1
- package/admin-build/_app/immutable/chunks/DuAuob_N.js +1 -0
- package/admin-build/_app/immutable/chunks/{BunyH6GE.js → ZQw8NgPJ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Xm1dHMTE.js → fgIr2CuT.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.j1WHBlZX.js → app.BtTg2P3N.js} +2 -2
- package/admin-build/_app/immutable/entry/start.D21f0BIj.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.Oo7ZpbR-.js → 0.CqnCTNhX.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.zgMnKHns.js → 1.BLn3o9jT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.u5mkRiUe.js → 10.Bp6HrvpD.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.9ynAgaxV.js → 11.79DhgZw-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.M_EqzJ6s.js → 12.A7_yb57H.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.D_NQ9q60.js → 13.DgMDZXO8.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.-vENIJ2w.js → 14.DycfMAIE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.DoMCZhGv.js → 15.Bk_-RJ3j.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.6BmnzScq.js → 16.B7Vjh-Xp.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.q6QQ9N_J.js → 17.DKfB3-O7.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.DUhJ6dKj.js → 18.Bwq1YHfM.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CvkvPlGP.js → 19.3S-h9EgK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.cACSbRIl.js → 20.Cm2djLN8.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.C3hqim4E.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.BsQ6xeKr.js → 22.DltIIio7.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.CNW3mLz5.js → 23.qVqH8BFx.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.CReqtEOy.js → 24.BBgqZFZ0.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.BuxVhqlb.js → 25.2xJDygjz.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.C-LzPf62.js → 26.B7pxYnqL.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.Dl5RL_tB.js → 27.P3eodDFO.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.CWfSQgjH.js → 28.CPEn1uNw.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.wUSfh2eQ.js → 29.D7ZGYHTI.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.D_laBXeK.js → 3.UaLSRTpv.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.DcMhZyQ0.js → 30.D-iftjK2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.CSwhNsNi.js → 31.CHSNieUr.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.Cq28vPI2.js → 4.BgPFR0hR.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.BTT9B3oI.js → 5.BRn6WaF_.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.iCmmzXZp.js → 6.Cdem1OHu.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.qY5v7qqJ.js → 7.FpFS7ceb.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.C6p4RvgK.js → 8.aCQtyAed.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.f548N7zh.js → 9.9BZCZcm_.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +193 -0
- package/package.json +3 -3
- package/src/__tests__/error-format.test.ts +21 -0
- package/src/__tests__/postgres-api-consistency.test.ts +240 -0
- package/src/__tests__/postgres-batch-compat.test.ts +33 -27
- package/src/__tests__/postgres-batch.test.ts +394 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
- package/src/__tests__/postgres-transact.test.ts +148 -0
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/database-do.ts +455 -15
- package/src/durable-objects/room-runtime-base.ts +8 -1
- package/src/lib/d1-handler.ts +416 -50
- package/src/lib/errors.ts +8 -4
- package/src/lib/internal-transport.ts +10 -0
- package/src/lib/postgres-handler.ts +562 -84
- package/src/routes/auth.ts +58 -46
- package/src/routes/tables.ts +140 -0
- package/admin-build/_app/immutable/chunks/D3hdqsfp.js +0 -1
- package/admin-build/_app/immutable/chunks/DdjFeYwG.js +0 -1
- package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +0 -1
- 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
|
-
|
|
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
|
|
466
|
-
const total =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|