@edge-base/server 0.2.2 → 0.2.4

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 (97) hide show
  1. package/admin-build/_app/immutable/chunks/{C85dMlzL.js → 5RQRbp5q.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{B8DT4fss.js → BME_U9TJ.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{BaCHY17I.js → BYI6CUvd.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{BWyDPAjM.js → BgDzp0i0.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{c5iKSdWY.js → BjWZuf8W.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{g3ZZdY-r.js → C6lpZLE2.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{C-DsDCNG.js → D5GswVnI.js} +3 -3
  8. package/admin-build/_app/immutable/chunks/DBsVqhuh.js +1 -0
  9. package/admin-build/_app/immutable/chunks/{BEYYl662.js → DYaCRWMA.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/D__dwMuW.js +1 -0
  11. package/admin-build/_app/immutable/chunks/{4vlsb8ej.js → Dj-E9-FO.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{kiJ6KthZ.js → Dj0QUuOf.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BKXmgPq4.js → XQM1k9PM.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{CTngeX8H.js → fYEKMQ-Z.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CPdXvRUb.js → g_-Kpxu3.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{DzXaj-Ja.js → wCNueVYy.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BZxfavhF.js → app.C8ylfBe6.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.CtsqDyfj.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.DlsaydXO.js → 0.CJJ6HZbp.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.D2NWN5eG.js → 1.B4sI5cB4.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.EMDaN3nw.js → 10.D6hvCer6.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.BasqQ_o9.js → 11.Dx7b8aQ5.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.DO31Ljs7.js → 12.Bqmy5KIF.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DhyAy-GZ.js → 13.CC6KpXgS.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.CLecGWc4.js → 14.yCo1Ix8E.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.B9kp3W4e.js → 15.co0UfPlh.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.Pu_8T3RI.js → 16.D0xkPUBW.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.DX4z43t6.js → 17.CebNqPeh.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.BKsSaxrr.js → 18.JUoLOZxh.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.DXNF1htN.js → 19.ND8kmQJe.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.VRVb0wee.js → 20.DYb-q3W8.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.cz3IN9Cc.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.DqZf4CtH.js → 22.UOzm8WYV.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.DtyxMiQG.js → 23.BLgq21om.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CloWNmTd.js → 24.DN9usmUs.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.CnZWMq7_.js → 25.BddRfAyE.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.DrV7XOmf.js → 26.Dl6XHIeT.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.DV8L32OF.js → 27.D0iNwALG.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.Stil2D4u.js → 28.9dKQmdGi.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.Zsm1e5Dc.js → 29.wXzfJUXp.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.CKoj2vNz.js → 3.z8ut3jS-.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.Ni0k5bER.js → 30.BtZETNsL.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.mnqj9EbV.js → 31.CYonj2Jh.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.B_-z9AzT.js → 4.COtDPQ9b.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.yiZ72j4k.js → 5.CTRCeIhp.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.BqykybBG.js → 6.ChHi3QkR.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.BDAHlhsF.js → 7.CCMtr6Ac.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.D8Xvy0lH.js → 8.DpWJ-X_-.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.Dddmd7_F.js → 9.DOkvfmir.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/package.json +3 -3
  53. package/src/__tests__/admin-data-routes.test.ts +29 -0
  54. package/src/__tests__/d1-live-broadcast-verification.test.ts +271 -0
  55. package/src/__tests__/database-do-route-validation.test.ts +105 -0
  56. package/src/__tests__/database-live-do.test.ts +50 -0
  57. package/src/__tests__/database-live-emitter.test.ts +116 -1
  58. package/src/__tests__/database-live-route.test.ts +82 -0
  59. package/src/__tests__/do-router.test.ts +116 -0
  60. package/src/__tests__/error-format.test.ts +63 -0
  61. package/src/__tests__/functions-context.test.ts +674 -33
  62. package/src/__tests__/functions-d1-proxy.test.ts +54 -0
  63. package/src/__tests__/plugin-migration-routing.test.ts +32 -0
  64. package/src/__tests__/postgres-field-ops-compat.test.ts +110 -0
  65. package/src/__tests__/provider-aware-sql.test.ts +163 -0
  66. package/src/__tests__/room-auth-state-loss.test.ts +124 -0
  67. package/src/__tests__/runtime-surface-accounting.test.ts +0 -4
  68. package/src/__tests__/scheduled.test.ts +55 -0
  69. package/src/__tests__/service-key-db-proxy.test.ts +122 -1
  70. package/src/__tests__/sql-route.test.ts +252 -75
  71. package/src/__tests__/table-hook-runtime.test.ts +137 -0
  72. package/src/durable-objects/database-do.ts +36 -45
  73. package/src/durable-objects/database-live-do.ts +46 -1
  74. package/src/durable-objects/room-runtime-base.ts +26 -2
  75. package/src/durable-objects/rooms-do.ts +1 -1
  76. package/src/index.ts +12 -6
  77. package/src/lib/admin-db-target.ts +30 -74
  78. package/src/lib/d1-handler.ts +55 -35
  79. package/src/lib/database-live-emitter.ts +57 -16
  80. package/src/lib/do-router.ts +135 -3
  81. package/src/lib/functions.ts +215 -143
  82. package/src/lib/internal-transport.ts +28 -12
  83. package/src/lib/plugin-migration-routing.ts +28 -0
  84. package/src/lib/plugin-migrations.ts +38 -38
  85. package/src/lib/postgres-handler.ts +51 -31
  86. package/src/lib/provider-aware-sql.ts +831 -0
  87. package/src/lib/table-hook-runtime.ts +62 -0
  88. package/src/routes/admin.ts +41 -41
  89. package/src/routes/auth.ts +7 -2
  90. package/src/routes/database-live.ts +110 -12
  91. package/src/routes/sql.ts +64 -84
  92. package/src/routes/storage.ts +7 -2
  93. package/src/routes/tables.ts +42 -29
  94. package/admin-build/_app/immutable/chunks/5PDcRlfX.js +0 -1
  95. package/admin-build/_app/immutable/chunks/qiZXAKh-.js +0 -1
  96. package/admin-build/_app/immutable/entry/start.Mr9mmopc.js +0 -1
  97. package/admin-build/_app/immutable/nodes/21.Ck3_0D2f.js +0 -1
@@ -36,10 +36,11 @@ import { summarizeValidationErrors, validateInsert, validateUpdate } from './val
36
36
  import { buildEffectiveSchema } from './schema.js';
37
37
  import { generateId } from './uuid.js';
38
38
  import { parseUpdateBody } from './op-parser.js';
39
- import { emitDbLiveEvent, emitDbLiveBatchEvent, sendToDatabaseLiveDO } from './database-live-emitter.js';
39
+ import { emitDbLiveEvent, emitDbLiveBatchEvent } from './database-live-emitter.js';
40
40
  import { isTrustedInternalContext } from './internal-request.js';
41
41
  import { executeDbTriggers } from './functions.js';
42
42
  import { forbiddenError, hookRejectedError, normalizeDatabaseError } from './errors.js';
43
+ import { buildTableHookRuntimeServices } from './table-hook-runtime.js';
43
44
 
44
45
  // ─── Types ───
45
46
 
@@ -227,10 +228,11 @@ async function evalInsertRule(
227
228
 
228
229
  function buildHookCtx(
229
230
  db: D1Database,
230
- tables: Record<string, TableConfig>,
231
231
  env: Env,
232
232
  executionCtx?: ExecutionContext,
233
233
  ): HookCtx {
234
+ const runtimeServices = buildTableHookRuntimeServices(parseConfig(env), env);
235
+
234
236
  return {
235
237
  db: {
236
238
  async get(table: string, id: string): Promise<Record<string, unknown> | null> {
@@ -266,20 +268,7 @@ function buildHookCtx(
266
268
  return result.rows.length > 0;
267
269
  },
268
270
  },
269
- databaseLive: {
270
- async broadcast(channel: string, event: string, data: unknown): Promise<void> {
271
- await sendToDatabaseLiveDO(
272
- env,
273
- { channel, event, payload: data ?? {} },
274
- '/internal/broadcast',
275
- );
276
- },
277
- },
278
- push: {
279
- async send(_userId: string, _payload: { title?: string; body: string }): Promise<void> {
280
- // Push notifications — same mechanism as DO (via Worker env)
281
- },
282
- },
271
+ ...runtimeServices,
283
272
  waitUntil(promise: Promise<unknown>): void {
284
273
  if (executionCtx) {
285
274
  executionCtx.waitUntil(promise);
@@ -288,6 +277,18 @@ function buildHookCtx(
288
277
  };
289
278
  }
290
279
 
280
+ function scheduleDbLive(
281
+ executionCtx: ExecutionContext,
282
+ promise: Promise<void>,
283
+ context: string,
284
+ ): void {
285
+ executionCtx.waitUntil(
286
+ promise.catch((error) => {
287
+ console.warn(`[db-live] ${context} failed`, error);
288
+ }),
289
+ );
290
+ }
291
+
291
292
  // ─── Utility ───
292
293
 
293
294
  function esc(name: string): string {
@@ -436,7 +437,7 @@ async function handleList(
436
437
 
437
438
  // Apply onEnrich hook
438
439
  if (tableHooks?.onEnrich) {
439
- const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
440
+ const hookCtx = buildHookCtx(resolved.db, c.env, c.executionCtx);
440
441
  for (let i = 0; i < items.length; i++) {
441
442
  try {
442
443
  const enriched = await tableHooks.onEnrich(auth, items[i], hookCtx);
@@ -600,7 +601,7 @@ async function handleGet(
600
601
 
601
602
  // Apply onEnrich hook
602
603
  if (tableHooks?.onEnrich) {
603
- const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
604
+ const hookCtx = buildHookCtx(resolved.db, c.env, c.executionCtx);
604
605
  try {
605
606
  const enriched = await tableHooks.onEnrich(auth, row, hookCtx);
606
607
  if (enriched && typeof enriched === 'object') return c.json({ ...row, ...enriched });
@@ -666,7 +667,7 @@ async function handleInsert(
666
667
  }
667
668
 
668
669
  // Run beforeInsert hook
669
- const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
670
+ const hookCtx = buildHookCtx(resolved.db, c.env, c.executionCtx);
670
671
  if (tableHooks?.beforeInsert) {
671
672
  try {
672
673
  const transformed = await tableHooks.beforeInsert(auth, body, hookCtx);
@@ -748,10 +749,12 @@ async function handleInsert(
748
749
  hookCtx.waitUntil(Promise.resolve(hook(inserted, hookCtx)).catch(() => {}));
749
750
  }
750
751
 
751
- // Emit database-live event (fire-and-forget)
752
+ // Emit database-live event in the background so writes stay fast.
752
753
  const eventType = isUpsert && isUpdate ? 'modified' : 'added';
753
- c.executionCtx.waitUntil(
754
+ scheduleDbLive(
755
+ c.executionCtx,
754
756
  emitDbLiveEvent(c.env, resolved.namespace, tableName, eventType, String(inserted.id ?? ''), inserted),
757
+ `emit ${eventType} ${resolved.namespace}.${tableName}`,
755
758
  );
756
759
  c.executionCtx.waitUntil(
757
760
  executeDbTriggers(
@@ -831,7 +834,7 @@ async function handleUpdate(
831
834
  }
832
835
 
833
836
  // Run beforeUpdate hook
834
- const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
837
+ const hookCtx = buildHookCtx(resolved.db, c.env, c.executionCtx);
835
838
  if (tableHooks?.beforeUpdate) {
836
839
  try {
837
840
  const transformed = await tableHooks.beforeUpdate(auth, existingRow, body, hookCtx);
@@ -891,9 +894,10 @@ async function handleUpdate(
891
894
  );
892
895
  }
893
896
 
894
- // Emit database-live event (fire-and-forget)
895
- c.executionCtx.waitUntil(
897
+ scheduleDbLive(
898
+ c.executionCtx,
896
899
  emitDbLiveEvent(c.env, resolved.namespace, tableName, 'modified', id, updated),
900
+ `emit modified ${resolved.namespace}.${tableName}:${id}`,
897
901
  );
898
902
  c.executionCtx.waitUntil(
899
903
  executeDbTriggers(
@@ -947,7 +951,7 @@ async function handleDelete(
947
951
  }
948
952
 
949
953
  // Run beforeDelete hook
950
- const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
954
+ const hookCtx = buildHookCtx(resolved.db, c.env, c.executionCtx);
951
955
  if (tableHooks?.beforeDelete) {
952
956
  try {
953
957
  await tableHooks.beforeDelete(auth, existingRow, hookCtx);
@@ -969,9 +973,10 @@ async function handleDelete(
969
973
  );
970
974
  }
971
975
 
972
- // Emit database-live event (fire-and-forget)
973
- c.executionCtx.waitUntil(
976
+ scheduleDbLive(
977
+ c.executionCtx,
974
978
  emitDbLiveEvent(c.env, resolved.namespace, tableName, 'removed', id, stripInternalFields(existingRow)),
979
+ `emit removed ${resolved.namespace}.${tableName}:${id}`,
975
980
  );
976
981
  c.executionCtx.waitUntil(
977
982
  executeDbTriggers(
@@ -1203,15 +1208,21 @@ async function handleBatch(
1203
1208
  // Emit database-live events
1204
1209
  if (allChanges.length > 0) {
1205
1210
  if (allChanges.length >= 10) {
1206
- c.executionCtx.waitUntil(
1211
+ scheduleDbLive(
1212
+ c.executionCtx,
1207
1213
  emitDbLiveBatchEvent(c.env, resolved.namespace, tableName, allChanges),
1214
+ `emit batch ${resolved.namespace}.${tableName} (${allChanges.length} changes)`,
1208
1215
  );
1209
1216
  } else {
1210
- for (const ch of allChanges) {
1211
- c.executionCtx.waitUntil(
1212
- emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
1213
- );
1214
- }
1217
+ scheduleDbLive(
1218
+ c.executionCtx,
1219
+ Promise.all(
1220
+ allChanges.map((ch) =>
1221
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
1222
+ ),
1223
+ ).then(() => undefined),
1224
+ `emit fan-out ${resolved.namespace}.${tableName} (${allChanges.length} changes)`,
1225
+ );
1215
1226
  }
1216
1227
  }
1217
1228
 
@@ -1306,8 +1317,17 @@ async function handleBatchByFilter(
1306
1317
  // Emit database-live events
1307
1318
  if (succeeded > 0) {
1308
1319
  const eventType = body.action === 'delete' ? 'removed' : 'modified';
1309
- c.executionCtx.waitUntil(
1310
- emitDbLiveEvent(c.env, resolved.namespace, tableName, eventType as 'modified' | 'removed', '_bulk', { action: body.action, count: succeeded }),
1320
+ scheduleDbLive(
1321
+ c.executionCtx,
1322
+ emitDbLiveEvent(
1323
+ c.env,
1324
+ resolved.namespace,
1325
+ tableName,
1326
+ eventType as 'modified' | 'removed',
1327
+ '_bulk',
1328
+ { action: body.action, count: succeeded },
1329
+ ),
1330
+ `emit bulk ${resolved.namespace}.${tableName} (${body.action})`,
1311
1331
  );
1312
1332
  }
1313
1333
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared database live event emission helpers.
3
3
  *
4
- * Used by d1-handler.ts and postgres-handler.ts for fire-and-forget
4
+ * Used by d1-handler.ts and postgres-handler.ts for background
5
5
  * event delivery to DatabaseLiveDO after successful CUD operations.
6
6
  *
7
7
  * database-do.ts keeps its own internal version (uses DO env).
@@ -10,6 +10,16 @@ import type { Env } from '../types.js';
10
10
 
11
11
  export const DATABASE_LIVE_HUB_DO_NAME = 'database-live:hub';
12
12
 
13
+ function withDeliveryId(event: Record<string, unknown>): Record<string, unknown> {
14
+ if (typeof event.deliveryId === 'string' && event.deliveryId.length > 0) {
15
+ return event;
16
+ }
17
+ return {
18
+ ...event,
19
+ deliveryId: crypto.randomUUID(),
20
+ };
21
+ }
22
+
13
23
  export function buildDbLiveChannel(
14
24
  namespace: string,
15
25
  table: string,
@@ -34,7 +44,7 @@ export function isDbLiveChannel(channel: string): boolean {
34
44
 
35
45
  /**
36
46
  * Emit a single CUD event to DatabaseLiveDO.
37
- * Mirrors database-do.ts emitDbLiveEvent() — fire-and-forget.
47
+ * Mirrors database-do.ts emitDbLiveEvent().
38
48
  */
39
49
  export function emitDbLiveEvent(
40
50
  env: Env,
@@ -87,25 +97,56 @@ export function emitDbLiveBatchEvent(
87
97
  return sendToDatabaseLiveDO(env, event, '/internal/batch-event');
88
98
  }
89
99
 
100
+ async function postToDatabaseLiveDO(
101
+ env: Env,
102
+ event: Record<string, unknown>,
103
+ path = '/internal/event',
104
+ ): Promise<void> {
105
+ const liveNamespace = env.DATABASE_LIVE;
106
+ if (!liveNamespace) {
107
+ return;
108
+ }
109
+
110
+ const doId = liveNamespace.idFromName(DATABASE_LIVE_HUB_DO_NAME);
111
+ const stub = liveNamespace.get(doId);
112
+ const response = await stub.fetch(`http://internal${path}`, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify(event),
116
+ });
117
+
118
+ if (!response.ok) {
119
+ const detail = await response.text().catch(() => '');
120
+ const suffix = detail ? `: ${detail.slice(0, 200)}` : '';
121
+ throw new Error(`DatabaseLiveDO ${path} failed with ${response.status}${suffix}`);
122
+ }
123
+ }
124
+
90
125
  /**
91
- * Fire-and-forget: send event to DatabaseLiveDO via stub.fetch().
92
- * Never blocks CRUD response errors are silently ignored.
126
+ * Send an event to DatabaseLiveDO via stub.fetch().
127
+ * Callers should schedule this with waitUntil() when they want
128
+ * fire-and-forget request semantics without hiding delivery failures.
93
129
  */
94
- export function sendToDatabaseLiveDO(
130
+ export async function sendToDatabaseLiveDO(
95
131
  env: Env,
96
132
  event: Record<string, unknown>,
97
133
  path = '/internal/event',
98
134
  ): Promise<void> {
99
- try {
100
- const doId = env.DATABASE_LIVE.idFromName(DATABASE_LIVE_HUB_DO_NAME);
101
- const stub = env.DATABASE_LIVE.get(doId);
102
- return stub.fetch(`http://internal${path}`, {
103
- method: 'POST',
104
- headers: { 'Content-Type': 'application/json' },
105
- body: JSON.stringify(event),
106
- }).then(() => undefined).catch(() => undefined);
107
- } catch {
108
- // Ignore — database live delivery should not block database operations
109
- return Promise.resolve();
135
+ const eventWithDeliveryId = withDeliveryId(event);
136
+ let lastError: unknown;
137
+
138
+ for (let attempt = 0; attempt < 2; attempt++) {
139
+ try {
140
+ await postToDatabaseLiveDO(env, eventWithDeliveryId, path);
141
+ return;
142
+ } catch (error) {
143
+ lastError = error;
144
+ }
145
+ }
146
+
147
+ if (lastError instanceof Error) {
148
+ throw lastError;
110
149
  }
150
+
151
+ throw new Error('DatabaseLiveDO delivery failed.');
111
152
  }
@@ -8,11 +8,36 @@
8
8
  *
9
9
  * Constraint: `id` must NOT contain `:` character.
10
10
  */
11
- import { materializeConfig, type EdgeBaseConfig } from '@edge-base/shared';
11
+ import { materializeConfig, type EdgeBaseConfig, type DbBlock } from '@edge-base/shared';
12
12
  import { counter } from '../middleware/rate-limit.js';
13
13
 
14
14
  const RUNTIME_CONFIG_GLOBAL_KEY = '__EDGEBASE_RUNTIME_CONFIG__';
15
15
 
16
+ export type DbTargetValidationIssue =
17
+ | 'namespace_not_found'
18
+ | 'instance_id_empty'
19
+ | 'instance_id_invalid'
20
+ | 'instance_id_required'
21
+ | 'instance_id_not_allowed';
22
+
23
+ export type DbTargetResolutionResult =
24
+ | {
25
+ ok: true;
26
+ value: {
27
+ namespace: string;
28
+ instanceId?: string;
29
+ dbBlock: DbBlock;
30
+ dynamic: boolean;
31
+ };
32
+ }
33
+ | {
34
+ ok: false;
35
+ issue: DbTargetValidationIssue;
36
+ status: 400 | 404;
37
+ namespace: string;
38
+ instanceId?: string;
39
+ };
40
+
16
41
  // ─── DO Instance ID Generation (§2) ───
17
42
 
18
43
  /**
@@ -53,6 +78,105 @@ export function parseDbDoName(doName: string): { namespace: string; id?: string
53
78
  };
54
79
  }
55
80
 
81
+ export function normalizeDbInstanceId(instanceId: string | null | undefined): string | undefined {
82
+ return typeof instanceId === 'string' ? instanceId : undefined;
83
+ }
84
+
85
+ export function formatDbTargetValidationIssue(
86
+ issue: DbTargetValidationIssue,
87
+ namespace: string,
88
+ options: {
89
+ namespaceLabel?: string;
90
+ instanceIdLabel?: string;
91
+ includeSectionRef?: boolean;
92
+ } = {},
93
+ ): string {
94
+ const namespaceLabel = options.namespaceLabel ?? 'Database';
95
+ const instanceIdLabel = options.instanceIdLabel ?? 'instanceId';
96
+ switch (issue) {
97
+ case 'namespace_not_found':
98
+ return `${namespaceLabel} '${namespace}' not found in config`;
99
+ case 'instance_id_empty':
100
+ return `${instanceIdLabel} must not be empty`;
101
+ case 'instance_id_invalid':
102
+ return options.includeSectionRef
103
+ ? `${instanceIdLabel} must not contain ':' (§2)`
104
+ : `${instanceIdLabel} must not contain ':'`;
105
+ case 'instance_id_required':
106
+ return `${instanceIdLabel} is required for dynamic namespace '${namespace}'`;
107
+ case 'instance_id_not_allowed':
108
+ return `${instanceIdLabel} is not allowed for single-instance namespace '${namespace}'`;
109
+ }
110
+ }
111
+
112
+ export function resolveDbTarget(
113
+ config: EdgeBaseConfig,
114
+ namespace: string,
115
+ instanceId?: string | null,
116
+ ): DbTargetResolutionResult {
117
+ const normalizedInstanceId = normalizeDbInstanceId(instanceId);
118
+ const dbBlock = config.databases?.[namespace];
119
+ if (!dbBlock) {
120
+ return {
121
+ ok: false,
122
+ issue: 'namespace_not_found',
123
+ status: 404,
124
+ namespace,
125
+ instanceId: normalizedInstanceId,
126
+ };
127
+ }
128
+
129
+ if (normalizedInstanceId !== undefined && normalizedInstanceId.trim().length === 0) {
130
+ return {
131
+ ok: false,
132
+ issue: 'instance_id_empty',
133
+ status: 400,
134
+ namespace,
135
+ instanceId: normalizedInstanceId,
136
+ };
137
+ }
138
+
139
+ if (normalizedInstanceId?.includes(':')) {
140
+ return {
141
+ ok: false,
142
+ issue: 'instance_id_invalid',
143
+ status: 400,
144
+ namespace,
145
+ instanceId: normalizedInstanceId,
146
+ };
147
+ }
148
+
149
+ const dynamic = isDynamicDbBlock(dbBlock);
150
+ if (dynamic && normalizedInstanceId === undefined) {
151
+ return {
152
+ ok: false,
153
+ issue: 'instance_id_required',
154
+ status: 400,
155
+ namespace,
156
+ instanceId: normalizedInstanceId,
157
+ };
158
+ }
159
+ if (!dynamic && normalizedInstanceId !== undefined) {
160
+ return {
161
+ ok: false,
162
+ issue: 'instance_id_not_allowed',
163
+ status: 400,
164
+ namespace,
165
+ instanceId: normalizedInstanceId,
166
+ };
167
+ }
168
+
169
+ return {
170
+ ok: true,
171
+ value: {
172
+ namespace,
173
+ instanceId: normalizedInstanceId,
174
+ dbBlock,
175
+ dynamic,
176
+ },
177
+ };
178
+ }
179
+
56
180
  // ─── DO Stub Call Helper ───
57
181
 
58
182
  /**
@@ -258,13 +382,21 @@ export function shouldRouteToD1(namespace: string, config: EdgeBaseConfig): bool
258
382
  if (dbBlock.provider === 'neon' || dbBlock.provider === 'postgres' || dbBlock.provider === 'do') return false;
259
383
 
260
384
  // Auto-detect: multi-tenant namespaces stay in DO
261
- if (dbBlock.instance) return false;
262
- if (dbBlock.access?.canCreate || dbBlock.access?.access) return false;
385
+ if (isDynamicDbBlock(dbBlock)) return false;
263
386
 
264
387
  // Default: single-instance → D1
265
388
  return true;
266
389
  }
267
390
 
391
+ /**
392
+ * Dynamic DB blocks represent per-instance / multi-tenant storage and therefore
393
+ * require create/access authorization semantics that single-instance DB blocks do not.
394
+ */
395
+ export function isDynamicDbBlock(dbBlock?: DbBlock): boolean {
396
+ if (!dbBlock) return false;
397
+ return Boolean(dbBlock.instance || dbBlock.access?.canCreate || dbBlock.access?.access);
398
+ }
399
+
268
400
  /**
269
401
  * Get the D1 binding name for a single-instance namespace.
270
402
  * Convention: DB_D1_{NAMESPACE_UPPER}