@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
@@ -0,0 +1,62 @@
1
+ import type { EdgeBaseConfig, HookCtx } from '@edge-base/shared';
2
+ import { ensureAuthSchema } from './auth-d1.js';
3
+ import { resolveAuthDb, type AuthDb } from './auth-db-adapter.js';
4
+ import { sendToDatabaseLiveDO } from './database-live-emitter.js';
5
+ import { createPushProvider } from './push-provider.js';
6
+ import { getDevicesForUser } from './push-token.js';
7
+ import type { Env } from '../types.js';
8
+
9
+ type PushTokenStore = KVNamespace | { kv: KVNamespace; authDb?: AuthDb | null };
10
+
11
+ async function resolvePushTokenStore(env: Env): Promise<PushTokenStore | null> {
12
+ if (!env.KV) {
13
+ return null;
14
+ }
15
+
16
+ try {
17
+ const authDb = resolveAuthDb(env as unknown as Record<string, unknown>);
18
+ await ensureAuthSchema(authDb);
19
+ return { kv: env.KV, authDb };
20
+ } catch {
21
+ return env.KV;
22
+ }
23
+ }
24
+
25
+ export function buildTableHookRuntimeServices(
26
+ config: EdgeBaseConfig,
27
+ env: Env,
28
+ ): Pick<HookCtx, 'databaseLive' | 'push'> {
29
+ return {
30
+ databaseLive: {
31
+ async broadcast(channel: string, event: string, data: unknown): Promise<void> {
32
+ await sendToDatabaseLiveDO(
33
+ env,
34
+ { channel, event, payload: data ?? {} },
35
+ '/internal/broadcast',
36
+ );
37
+ },
38
+ },
39
+ push: {
40
+ async send(userId: string, payload: { title?: string; body: string }): Promise<void> {
41
+ try {
42
+ const tokenStore = await resolvePushTokenStore(env);
43
+ if (!tokenStore) return;
44
+
45
+ const provider = createPushProvider(config.push, env);
46
+ if (!provider) return;
47
+
48
+ const devices = await getDevicesForUser(tokenStore, userId);
49
+ if (devices.length === 0) return;
50
+
51
+ await Promise.allSettled(
52
+ devices.map((device) =>
53
+ provider.send({ token: device.token, platform: device.platform, payload }),
54
+ ),
55
+ );
56
+ } catch (error) {
57
+ console.warn('[EdgeBase] table hook push.send failed:', error);
58
+ }
59
+ },
60
+ },
61
+ };
62
+ }
@@ -23,7 +23,16 @@ import {
23
23
  import { hashPassword, verifyPassword } from '../lib/password.js';
24
24
  import { generateId } from '../lib/uuid.js';
25
25
  import { validateKey, buildConstraintCtx, extractBearerToken, resolveServiceKeyCandidate } from '../lib/service-key.js';
26
- import { parseConfig, getDbDoName, getD1BindingName, shouldRouteToD1 } from '../lib/do-router.js';
26
+ import {
27
+ formatDbTargetValidationIssue,
28
+ getD1BindingName,
29
+ getDbDoName,
30
+ isDynamicDbBlock,
31
+ normalizeDbInstanceId,
32
+ parseConfig,
33
+ resolveDbTarget,
34
+ shouldRouteToD1,
35
+ } from '../lib/do-router.js';
27
36
  import { handleD1Request, d1BatchImport } from '../lib/d1-handler.js';
28
37
  import { fetchDOWithRetry } from '../lib/do-retry.js';
29
38
  import { dumpNamespaceTables } from '../lib/namespace-dump.js';
@@ -734,19 +743,6 @@ function getTableDO(env: Env, tableName: string, config: ReturnType<typeof parse
734
743
  return { stub: env.DATABASE.get(env.DATABASE.idFromName(doName)), doName };
735
744
  }
736
745
 
737
- function isDynamicDbBlock(
738
- dbBlock: {
739
- instance?: boolean;
740
- access?: {
741
- canCreate?: unknown;
742
- access?: unknown;
743
- };
744
- } | undefined,
745
- ): boolean {
746
- if (!dbBlock) return false;
747
- return !!(dbBlock.instance || dbBlock.access?.canCreate || dbBlock.access?.access);
748
- }
749
-
750
746
  function getEffectiveDbProvider(namespace: string, config: ReturnType<typeof parseConfig>): 'do' | 'd1' | 'postgres' | 'neon' {
751
747
  const dbBlock = config.databases?.[namespace];
752
748
  if (!dbBlock) return 'do';
@@ -763,10 +759,7 @@ function getEffectiveDbProvider(namespace: string, config: ReturnType<typeof par
763
759
  }
764
760
 
765
761
  function getRequestedInstanceId(c: { req: { query: (name: string) => string | undefined } }): string | undefined {
766
- const raw = c.req.query('instanceId');
767
- if (!raw) return undefined;
768
- const trimmed = raw.trim();
769
- return trimmed.length > 0 ? trimmed : undefined;
762
+ return normalizeDbInstanceId(c.req.query('instanceId'));
770
763
  }
771
764
 
772
765
  function validateAdminTableInstanceId(
@@ -774,28 +767,27 @@ function validateAdminTableInstanceId(
774
767
  config: ReturnType<typeof parseConfig>,
775
768
  instanceId: string | undefined,
776
769
  ): Response | null {
777
- const dynamic = isDynamicDbBlock(config.databases?.[namespace]);
778
- if (!instanceId) {
779
- if (dynamic) {
780
- return new Response(
781
- JSON.stringify({
782
- code: 400,
783
- message: `instanceId is required for dynamic namespace '${namespace}'`,
784
- }),
785
- {
786
- status: 400,
787
- headers: { 'Content-Type': 'application/json' },
788
- },
789
- );
790
- }
770
+ const target = resolveDbTarget(config, namespace, instanceId);
771
+ if (target.ok) {
791
772
  return null;
792
773
  }
793
-
794
- if (instanceId.includes(':')) {
774
+ if (target.status !== 400) {
775
+ return new Response(
776
+ JSON.stringify({
777
+ code: target.status,
778
+ message: formatDbTargetValidationIssue(target.issue, namespace),
779
+ }),
780
+ {
781
+ status: target.status,
782
+ headers: { 'Content-Type': 'application/json' },
783
+ },
784
+ );
785
+ }
786
+ if (target.issue === 'instance_id_invalid') {
795
787
  return new Response(
796
788
  JSON.stringify({
797
789
  code: 400,
798
- message: 'instanceId must not contain \':\'',
790
+ message: formatDbTargetValidationIssue(target.issue, namespace),
799
791
  }),
800
792
  {
801
793
  status: 400,
@@ -803,8 +795,16 @@ function validateAdminTableInstanceId(
803
795
  },
804
796
  );
805
797
  }
806
-
807
- return null;
798
+ return new Response(
799
+ JSON.stringify({
800
+ code: 400,
801
+ message: formatDbTargetValidationIssue(target.issue, namespace),
802
+ }),
803
+ {
804
+ status: 400,
805
+ headers: { 'Content-Type': 'application/json' },
806
+ },
807
+ );
808
808
  }
809
809
 
810
810
  async function restoreAdminNamespaceTables(
@@ -974,7 +974,7 @@ api.openapi(adminGetTableRecords, async (c) => {
974
974
  const name = c.req.param('name')!;
975
975
  const config = parseConfig(c.env);
976
976
  const namespace = findNamespaceForTable(name, config);
977
- const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
977
+ const instanceId = getRequestedInstanceId(c);
978
978
  const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
979
979
  if (instanceError) return instanceError;
980
980
 
@@ -1027,7 +1027,7 @@ api.openapi(adminCreateTableRecord, async (c) => {
1027
1027
  const name = c.req.param('name')!;
1028
1028
  const config = parseConfig(c.env);
1029
1029
  const namespace = findNamespaceForTable(name, config);
1030
- const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
1030
+ const instanceId = getRequestedInstanceId(c);
1031
1031
  const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
1032
1032
  if (instanceError) return instanceError;
1033
1033
 
@@ -1081,7 +1081,7 @@ api.openapi(adminUpdateTableRecord, async (c) => {
1081
1081
  const id = c.req.param('id')!;
1082
1082
  const config = parseConfig(c.env);
1083
1083
  const namespace = findNamespaceForTable(name, config);
1084
- const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
1084
+ const instanceId = getRequestedInstanceId(c);
1085
1085
  const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
1086
1086
  if (instanceError) return instanceError;
1087
1087
 
@@ -1129,7 +1129,7 @@ api.openapi(adminDeleteTableRecord, async (c) => {
1129
1129
  const id = c.req.param('id')!;
1130
1130
  const config = parseConfig(c.env);
1131
1131
  const namespace = findNamespaceForTable(name, config);
1132
- const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
1132
+ const instanceId = getRequestedInstanceId(c);
1133
1133
  const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
1134
1134
  if (instanceError) return instanceError;
1135
1135
 
@@ -54,7 +54,7 @@ import {
54
54
  buildFunctionPushProxy,
55
55
  buildAdminAuthContext,
56
56
  buildAdminDbProxy,
57
- executeSqlWithDirectD1Access,
57
+ executeSqlProviderAware,
58
58
  getWorkerUrl,
59
59
  } from '../lib/functions.js';
60
60
  import * as authService from '../lib/auth-d1-service.js';
@@ -660,8 +660,13 @@ export async function executeAuthHook(
660
660
  db: adminDb,
661
661
  table: (name: string) => adminDb('shared').table(name),
662
662
  auth: authAdmin,
663
+ sqlProviderAware: (namespace: string, id: string | undefined, query: string, params?: unknown[]) =>
664
+ executeSqlProviderAware(
665
+ { env, config, databaseNamespace: env.DATABASE, workerUrl: options.workerUrl, serviceKey },
666
+ namespace, id, query, params,
667
+ ),
663
668
  sqlWithDirectD1Access: (namespace: string, id: string | undefined, query: string, params?: unknown[]) =>
664
- executeSqlWithDirectD1Access(
669
+ executeSqlProviderAware(
665
670
  { env, config, databaseNamespace: env.DATABASE, workerUrl: options.workerUrl, serviceKey },
666
671
  namespace, id, query, params,
667
672
  ),
@@ -9,7 +9,12 @@ import {
9
9
  DATABASE_LIVE_HUB_DO_NAME,
10
10
  isDbLiveChannel,
11
11
  } from '../lib/database-live-emitter.js';
12
- import { parseConfig } from '../lib/do-router.js';
12
+ import {
13
+ formatDbTargetValidationIssue,
14
+ isDynamicDbBlock,
15
+ parseConfig,
16
+ resolveDbTarget,
17
+ } from '../lib/do-router.js';
13
18
  import { validateKey, buildConstraintCtx } from '../lib/service-key.js';
14
19
  import { getTrustedClientIp } from '../lib/client-ip.js';
15
20
  import {
@@ -50,18 +55,107 @@ const dbConnectDiagnosticSchema = z.object({
50
55
  maxPending: z.number().optional(),
51
56
  });
52
57
 
53
- function resolveDatabaseLiveChannel(query: {
58
+ function resolveStructuredDatabaseLiveChannel(
59
+ config: ReturnType<typeof parseConfig>,
60
+ query: {
61
+ namespace?: string;
62
+ instanceId?: string;
63
+ table?: string;
64
+ docId?: string;
65
+ },
66
+ ): { ok: true; channel: string } | { ok: false; message: string } {
67
+ if (!query.namespace || !query.table) {
68
+ return { ok: false, message: 'Database subscription target required' };
69
+ }
70
+
71
+ const target = resolveDbTarget(config, query.namespace, query.instanceId);
72
+ if (!target.ok) {
73
+ return {
74
+ ok: false,
75
+ message: formatDbTargetValidationIssue(target.issue, query.namespace),
76
+ };
77
+ }
78
+ if (!target.value.dbBlock.tables?.[query.table]) {
79
+ return {
80
+ ok: false,
81
+ message: `Table '${query.table}' not found in database '${query.namespace}'`,
82
+ };
83
+ }
84
+
85
+ const channel = buildDbLiveChannel(
86
+ query.namespace,
87
+ query.table,
88
+ target.value.instanceId,
89
+ query.docId,
90
+ );
91
+ if (!isDbLiveChannel(channel)) {
92
+ return { ok: false, message: 'Invalid database subscription target' };
93
+ }
94
+
95
+ return { ok: true, channel };
96
+ }
97
+
98
+ function resolveDatabaseLiveChannel(
99
+ config: ReturnType<typeof parseConfig>,
100
+ query: {
54
101
  channel?: string;
55
102
  namespace?: string;
56
103
  instanceId?: string;
57
104
  table?: string;
58
105
  docId?: string;
59
- }): string | null {
106
+ }): { ok: true; channel: string } | { ok: false; message: string } {
60
107
  if (query.channel) {
61
- return isDbLiveChannel(query.channel) ? query.channel : null;
108
+ if (!isDbLiveChannel(query.channel)) {
109
+ return {
110
+ ok: false,
111
+ message: `Database live only supports DB channels: ${query.channel}`,
112
+ };
113
+ }
114
+
115
+ const parts = query.channel.split(':');
116
+ const namespace = parts[1];
117
+ if (!namespace) {
118
+ return { ok: false, message: 'Database subscription target required' };
119
+ }
120
+
121
+ const dbBlock = config.databases?.[namespace];
122
+ if (!dbBlock) {
123
+ return {
124
+ ok: false,
125
+ message: formatDbTargetValidationIssue('namespace_not_found', namespace),
126
+ };
127
+ }
128
+
129
+ const dynamic = isDynamicDbBlock(dbBlock);
130
+ if (dynamic && parts.length < 4) {
131
+ return {
132
+ ok: false,
133
+ message: formatDbTargetValidationIssue('instance_id_required', namespace),
134
+ };
135
+ }
136
+ if (!dynamic && parts.length > 4) {
137
+ return {
138
+ ok: false,
139
+ message: formatDbTargetValidationIssue('instance_id_not_allowed', namespace),
140
+ };
141
+ }
142
+
143
+ const structured = dynamic
144
+ ? {
145
+ namespace,
146
+ instanceId: parts[2],
147
+ table: parts[3],
148
+ docId: parts[4],
149
+ }
150
+ : {
151
+ namespace,
152
+ table: parts[2],
153
+ docId: parts[3],
154
+ };
155
+ return resolveStructuredDatabaseLiveChannel(config, structured);
62
156
  }
63
- if (!query.namespace || !query.table) return null;
64
- return buildDbLiveChannel(query.namespace, query.table, query.instanceId, query.docId);
157
+
158
+ return resolveStructuredDatabaseLiveChannel(config, query);
65
159
  }
66
160
 
67
161
  function getPendingKey(ip: string): string {
@@ -85,7 +179,8 @@ const checkDatabaseConnection = createRoute({
85
179
  });
86
180
 
87
181
  databaseLiveRoute.openapi(checkDatabaseConnection, async (c) => {
88
- const channel = resolveDatabaseLiveChannel({
182
+ const config = parseConfig(c.env);
183
+ const channelResult = resolveDatabaseLiveChannel(config, {
89
184
  channel: c.req.query('channel') ?? undefined,
90
185
  namespace: c.req.query('namespace') ?? undefined,
91
186
  instanceId: c.req.query('instanceId') ?? undefined,
@@ -93,14 +188,15 @@ databaseLiveRoute.openapi(checkDatabaseConnection, async (c) => {
93
188
  docId: c.req.query('docId') ?? undefined,
94
189
  });
95
190
 
96
- if (!channel) {
191
+ if (!channelResult.ok) {
97
192
  return c.json({
98
193
  ok: false,
99
194
  type: 'db_connect_invalid_request',
100
195
  category: 'request',
101
- message: 'Database subscription target required',
196
+ message: channelResult.message,
102
197
  }, 400);
103
198
  }
199
+ const channel = channelResult.channel;
104
200
 
105
201
  const ip = getTrustedClientIp(c.env, c.req) ?? 'unknown';
106
202
  const kvKey = getPendingKey(ip);
@@ -224,16 +320,18 @@ databaseLiveRoute.openapi(connectDatabaseSubscription, async (c) => {
224
320
  return c.json({ code: 400, message: 'Expected WebSocket upgrade' }, 400);
225
321
  }
226
322
 
227
- const channel = resolveDatabaseLiveChannel({
323
+ const config = parseConfig(c.env);
324
+ const channelResult = resolveDatabaseLiveChannel(config, {
228
325
  channel: c.req.query('channel') ?? undefined,
229
326
  namespace: c.req.query('namespace') ?? undefined,
230
327
  instanceId: c.req.query('instanceId') ?? undefined,
231
328
  table: c.req.query('table') ?? undefined,
232
329
  docId: c.req.query('docId') ?? undefined,
233
330
  });
234
- if (!channel) {
235
- return c.json({ code: 400, message: 'Database subscription target required' }, 400);
331
+ if (!channelResult.ok) {
332
+ return c.json({ code: 400, message: channelResult.message }, 400);
236
333
  }
334
+ const channel = channelResult.channel;
237
335
 
238
336
  const ip = getTrustedClientIp(c.env, c.req) ?? 'unknown';
239
337
  const kvKey = getPendingKey(ip);
package/src/routes/sql.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * SQL endpoint — POST /api/sql
3
3
  *
4
- * Allows server SDK (with Service Key) to execute raw SQL on any DatabaseDO.
4
+ * Allows server SDK (with Service Key) to execute provider-aware raw SQL.
5
5
  * NOT available to client SDK (no sql() method on ClientEdgeBase).
6
6
  *
7
7
  * §11: URL stays /api/sql, but request body now uses
@@ -13,7 +13,7 @@
13
13
  * - id, if provided, must not contain ':' (§2)
14
14
  * - Parameterized queries enforced (sql + params separate)
15
15
  *
16
- * Flow: Server SDK → POST /api/sql → Worker → DatabaseDO sqlExec() → JSON
16
+ * Flow: Server SDK → POST /api/sql → Worker → provider-aware executor → JSON
17
17
  *
18
18
  * Request body:
19
19
  * { namespace: string, id?: string, sql: string, params?: unknown[] }
@@ -23,19 +23,19 @@
23
23
  * { namespace: 'workspace', id: 'ws-456', sql: 'SELECT * FROM documents', params: [] }
24
24
  */
25
25
  import { OpenAPIHono, createRoute, type HonoEnv } from '../lib/hono.js';
26
- import { parseConfig, getD1BindingName, shouldRouteToD1 } from '../lib/do-router.js';
27
- import { executeD1Sql } from '../lib/d1-sql.js';
26
+ import {
27
+ formatDbTargetValidationIssue,
28
+ parseConfig,
29
+ resolveDbTarget,
30
+ } from '../lib/do-router.js';
28
31
  import { validateKey, buildConstraintCtx } from '../lib/service-key.js';
29
- import { zodDefaultHook, sqlBodySchema, jsonResponseSchema, errorResponseSchema } from '../lib/schemas.js';
30
32
  import {
31
- ensureLocalDevPostgresSchema,
32
- getLocalDevPostgresExecOptions,
33
- getProviderBindingName,
34
- withPostgresConnection,
35
- } from '../lib/postgres-executor.js';
36
- import { ensurePgSchema } from '../lib/postgres-schema-init.js';
37
- import { executeDoSql } from '../lib/do-sql.js';
38
-
33
+ zodDefaultHook,
34
+ sqlBodySchema,
35
+ jsonResponseSchema,
36
+ errorResponseSchema,
37
+ } from '../lib/schemas.js';
38
+ import { executeProviderAwareSql } from '../lib/provider-aware-sql.js';
39
39
 
40
40
  export const sqlRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
41
41
 
@@ -48,15 +48,31 @@ const executeSql = createRoute({
48
48
  method: 'post',
49
49
  path: '/',
50
50
  tags: ['admin'],
51
- summary: 'Execute SQL via DatabaseDO',
51
+ summary: 'Execute provider-aware raw SQL',
52
52
  request: {
53
53
  body: { content: { 'application/json': { schema: sqlBodySchema } }, required: true },
54
54
  },
55
55
  responses: {
56
- 200: { description: 'Query results', content: { 'application/json': { schema: jsonResponseSchema } } },
57
- 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
58
- 401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
59
- 403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
56
+ 200: {
57
+ description: 'Query results',
58
+ content: { 'application/json': { schema: jsonResponseSchema } },
59
+ },
60
+ 400: {
61
+ description: 'Bad request',
62
+ content: { 'application/json': { schema: errorResponseSchema } },
63
+ },
64
+ 401: {
65
+ description: 'Unauthorized',
66
+ content: { 'application/json': { schema: errorResponseSchema } },
67
+ },
68
+ 403: {
69
+ description: 'Forbidden',
70
+ content: { 'application/json': { schema: errorResponseSchema } },
71
+ },
72
+ 404: {
73
+ description: 'Namespace not found',
74
+ content: { 'application/json': { schema: errorResponseSchema } },
75
+ },
60
76
  },
61
77
  });
62
78
 
@@ -76,23 +92,27 @@ sqlRoute.openapi(executeSql, async (c) => {
76
92
  if (id !== undefined && id !== null && typeof id !== 'string') {
77
93
  return c.json({ code: 400, message: 'id must be a string' }, 400);
78
94
  }
79
- if (id && id.includes(':')) {
80
- return c.json({ code: 400, message: 'id must not contain \':\' (§2)' }, 400);
81
- }
82
95
  if (!sql || typeof sql !== 'string') {
83
96
  return c.json({ code: 400, message: 'sql is required' }, 400);
84
97
  }
85
98
 
86
99
  // Validate namespace is declared in databases config (§1)
87
100
  const config = parseConfig(c.env);
88
- const dbBlock = config.databases?.[namespace];
89
- if (!dbBlock) {
90
- return c.json({ code: 404, message: `Namespace '${namespace}' not found in config` }, 404);
91
- }
92
- const isDynamicNamespace = !!(dbBlock.instance || dbBlock.access?.canCreate || dbBlock.access?.access);
93
- if (isDynamicNamespace && !id) {
94
- return c.json({ code: 400, message: `id is required for dynamic namespace '${namespace}'` }, 400);
101
+ const target = resolveDbTarget(config, namespace, id);
102
+ if (!target.ok) {
103
+ return c.json(
104
+ {
105
+ code: target.status,
106
+ message: formatDbTargetValidationIssue(target.issue, namespace, {
107
+ namespaceLabel: 'Namespace',
108
+ instanceIdLabel: 'id',
109
+ includeSectionRef: target.issue === 'instance_id_invalid',
110
+ }),
111
+ },
112
+ target.status,
113
+ );
95
114
  }
115
+ const { instanceId } = target.value;
96
116
 
97
117
  // Service Key required AND validated
98
118
  const { result: skResult } = validateKey(
@@ -110,65 +130,25 @@ sqlRoute.openapi(executeSql, async (c) => {
110
130
  return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
111
131
  }
112
132
 
113
- if (!id && (dbBlock?.provider === 'neon' || dbBlock?.provider === 'postgres')) {
114
- const bindingName = getProviderBindingName(namespace);
115
- const envRecord = c.env as unknown as Record<string, unknown>;
116
- const hyperdrive = envRecord[bindingName] as { connectionString?: string } | undefined;
117
- const envKey = dbBlock.connectionString ?? `${bindingName}_URL`;
118
- const connStr = hyperdrive?.connectionString ?? (envRecord[envKey] as string | undefined);
119
- if (!connStr) {
120
- return c.json({ code: 500, message: `PostgreSQL connection '${envKey}' not found.` }, 500);
121
- }
122
-
123
- try {
124
- const localDevOptions = getLocalDevPostgresExecOptions(c.env as unknown as Record<string, unknown>, namespace);
125
- if (localDevOptions) {
126
- await ensureLocalDevPostgresSchema(localDevOptions);
127
- }
128
- const result = await withPostgresConnection(connStr, async (query) => {
129
- if (!localDevOptions) {
130
- await ensurePgSchema(connStr, namespace, dbBlock.tables ?? {}, query);
131
- }
132
- return query(sql, params ?? []);
133
- }, localDevOptions);
134
- const rows = result.rows ?? [];
135
- return c.json({ rows, items: rows, results: rows, columns: result.columns, rowCount: result.rowCount });
136
- } catch (err) {
137
- const message = err instanceof Error ? err.message : 'SQL execution failed';
138
- return c.json({ code: 500, message }, 500);
139
- }
140
- }
141
-
142
- if (!id && shouldRouteToD1(namespace, config)) {
143
- const bindingName = getD1BindingName(namespace);
144
- const d1 = (c.env as unknown as Record<string, unknown>)[bindingName] as D1Database | undefined;
145
- if (!d1) {
146
- return c.json({ code: 500, message: `D1 binding '${bindingName}' not found.` }, 500);
147
- }
148
-
149
- try {
150
- const result = await executeD1Sql(d1, sql, params ?? []);
151
- return c.json({
152
- rows: result.rows,
153
- items: result.rows,
154
- results: result.rows,
155
- rowCount: result.rowCount,
156
- });
157
- } catch (err) {
158
- const message = err instanceof Error ? err.message : 'SQL execution failed';
159
- return c.json({ code: 500, message }, 500);
160
- }
161
- }
162
-
163
133
  try {
164
- const rows = await executeDoSql({
165
- databaseNamespace: c.env.DATABASE,
134
+ const result = await executeProviderAwareSql(
135
+ {
136
+ env: c.env,
137
+ config,
138
+ databaseNamespace: c.env.DATABASE,
139
+ },
166
140
  namespace,
167
- id,
168
- query: sql,
169
- params: params ?? [],
141
+ instanceId,
142
+ sql,
143
+ params ?? [],
144
+ );
145
+ return c.json({
146
+ rows: result.rows,
147
+ items: result.rows,
148
+ results: result.rows,
149
+ columns: result.columns,
150
+ rowCount: result.rowCount,
170
151
  });
171
- return c.json({ rows, items: rows, results: rows });
172
152
  } catch (err) {
173
153
  const message = err instanceof Error ? err.message : 'SQL execution failed';
174
154
  return c.json({ code: 500, message }, 500);
@@ -44,7 +44,7 @@ import {
44
44
  buildFunctionPushProxy,
45
45
  buildAdminAuthContext,
46
46
  buildAdminDbProxy,
47
- executeSqlWithDirectD1Access,
47
+ executeSqlProviderAware,
48
48
  getWorkerUrl,
49
49
  } from '../lib/functions.js';
50
50
 
@@ -166,8 +166,13 @@ function buildStorageHookAdminContext(
166
166
  db: adminDb,
167
167
  table: (name: string) => adminDb('shared').table(name),
168
168
  auth: buildAdminAuthContext({ d1Database: env.AUTH_DB, serviceKey, workerUrl }),
169
+ sqlProviderAware: (namespace: string, id: string | undefined, query: string, params?: unknown[]) =>
170
+ executeSqlProviderAware(
171
+ { env, config, databaseNamespace: env.DATABASE, workerUrl, serviceKey },
172
+ namespace, id, query, params,
173
+ ),
169
174
  sqlWithDirectD1Access: (namespace: string, id: string | undefined, query: string, params?: unknown[]) =>
170
- executeSqlWithDirectD1Access(
175
+ executeSqlProviderAware(
171
176
  { env, config, databaseNamespace: env.DATABASE, workerUrl, serviceKey },
172
177
  namespace, id, query, params,
173
178
  ),