@edge-base/server 0.2.1 → 0.2.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 (81) hide show
  1. package/admin-build/_app/immutable/chunks/{DjOEv9M9.js → A_3UuvCe.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{Dqk2TGNU.js → B-_-hJ9o.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{BFs_qStz.js → B5Nwfelm.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{B0QyxC2M.js → BxoNtYHK.js} +3 -3
  5. package/admin-build/_app/immutable/chunks/{BsFiK_FJ.js → CZ0TVkCa.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{k0CIJkw4.js → CzSAxmuj.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{D-x55wdW.js → DCKcAiQH.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{CSGrwS7E.js → DCvwWZrm.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{BTJcQFEp.js → DRqPU3wD.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{CqUxCvs_.js → Dc1-6Po6.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{D755Tqat.js → DiyBpamp.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{BcIUK2sk.js → Dlty5069.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BY07qVPA.js → DpVAayDG.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{BCKr7yKd.js → Du5vWVa2.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{m9QZTyVV.js → byv2rTy8.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{DnLqc9L1.js → nZvorU8i.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BTsq3_xq.js → app.CfrmEXPD.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.BZ00WDYH.js → 0.Cn2BZ4da.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.RzSJ3yyr.js → 1.Dv4LX_Co.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.D-rsiquF.js → 10.DPVv3kat.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.l7-bgtFD.js → 11.CiCb6Ayu.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.Dkq0H7B5.js → 12.CIPyeekF.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DtK_4oRz.js → 13.Z15Lt36e.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.BKo7-AMx.js → 14.s0l5bAq3.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.CQAj_6lq.js → 15.UwSSNO76.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.XVIG-Ffr.js → 16.qiD8i883.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.g6raZLCM.js → 17.Dy3dcSvu.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.IQz6a3T6.js → 18.DeXyPYsO.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CAAZ8i8h.js → 19.CAbuyS6w.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.BPcX3KPj.js → 20.Bec0T7un.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.DuDYelMY.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.Br5AG_5Z.js → 22.CdVprrv2.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.KjbrdXoE.js → 23.Y8RzVLoF.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.C3n2-hgw.js → 24.CWhHYFBx.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.SFDSBzHd.js → 25.wCBplOVt.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.D95vui6E.js → 26.Cod_JRFK.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.FgLgdjwB.js → 27.BO2HVMu9.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.B9sYYm1F.js → 28.DxG-FBVQ.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.DyqZ_wbN.js → 29.CjGqWGvE.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.Bzo2yVIO.js → 3.By3_OmdZ.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.c1CiNwiS.js → 30.M_H7Htpq.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.CXty66Vh.js → 31.DEU18izM.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.BgQaXZ27.js → 4.DeYhKtzJ.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BuJrHvxH.js → 5.9WLgxhrD.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.CkBBC94k.js → 6.BdT2i_dd.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.D2YBvNFM.js → 7.CHq0s4K6.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.D8qQWo_z.js → 8.DuvRw-XZ.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.BLDLX5hV.js → 9.C2Ub82wn.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 -2
  53. package/src/__tests__/d1-live-broadcast-verification.test.ts +271 -0
  54. package/src/__tests__/database-live-do.test.ts +50 -0
  55. package/src/__tests__/database-live-emitter.test.ts +116 -1
  56. package/src/__tests__/error-format.test.ts +63 -0
  57. package/src/__tests__/functions-context.test.ts +592 -35
  58. package/src/__tests__/meta-export-coverage.test.ts +1 -0
  59. package/src/__tests__/postgres-field-ops-compat.test.ts +110 -0
  60. package/src/__tests__/provider-aware-sql.test.ts +157 -0
  61. package/src/__tests__/room-auth-state-loss.test.ts +124 -0
  62. package/src/__tests__/runtime-surface-accounting.test.ts +0 -4
  63. package/src/__tests__/sql-route.test.ts +187 -76
  64. package/src/durable-objects/database-live-do.ts +46 -1
  65. package/src/durable-objects/room-runtime-base.ts +26 -2
  66. package/src/durable-objects/rooms-do.ts +1 -1
  67. package/src/lib/admin-db-target.ts +30 -74
  68. package/src/lib/d1-handler.ts +45 -14
  69. package/src/lib/database-live-emitter.ts +57 -16
  70. package/src/lib/functions.ts +332 -454
  71. package/src/lib/internal-transport.ts +316 -0
  72. package/src/lib/plugin-migrations.ts +39 -39
  73. package/src/lib/postgres-handler.ts +39 -11
  74. package/src/lib/provider-aware-sql.ts +827 -0
  75. package/src/routes/admin.ts +7 -1
  76. package/src/routes/auth.ts +11 -12
  77. package/src/routes/sql.ts +51 -76
  78. package/src/routes/storage.ts +11 -12
  79. package/src/types.ts +2 -0
  80. package/admin-build/_app/immutable/entry/start.zXCirpgY.js +0 -1
  81. package/admin-build/_app/immutable/nodes/21.DoPabrY_.js +0 -1
@@ -1 +1 @@
1
- import"../chunks/CWj6FrbW.js";import{o as U}from"../chunks/Bn2NtlTj.js";import{p as j,a as z,f as G,c as b,g as t,s as m,r as g,u as n,b as y,d as D,t as J}from"../chunks/BdTBlfLy.js";import{d as K,s as N,a as O}from"../chunks/DtZk82gG.js";import{a as Q,t as V,e as W,i as X}from"../chunks/CqUxCvs_.js";import{a as F,f as I}from"../chunks/DEELgv7K.js";import{s as Y}from"../chunks/CoI6jjbg.js";import{P as Z}from"../chunks/B8s_s9QY.js";import{a as tt}from"../chunks/Q2nPFxS6.js";import{M as _,T as et}from"../chunks/k0CIJkw4.js";import{D as at,T as rt}from"../chunks/DjOEv9M9.js";var nt=I("<button> </button>"),st=I('<div class="range-selector"></div>'),ot=I('<div class="analytics-grid"><!> <!> <!> <!></div> <div class="analytics-chart"><!></div> <div class="analytics-bottom"><!> <!></div>',1);function bt(w,M){j(M,!0);let s=D(!0),o=D(null),p=D("24h");const T=[{value:"1h",label:"1H"},{value:"6h",label:"6H"},{value:"24h",label:"24H"},{value:"7d",label:"7D"},{value:"30d",label:"30D"}],k=n(()=>()=>{switch(t(p)){case"1h":return"minute";case"6h":case"24h":return"hour";default:return"day"}});async function $(){try{const e=await Q.fetch(`data/analytics?range=${t(p)}&category=function&metric=overview&groupBy=${t(k)()}`);y(o,e,!0)}catch(e){V(e instanceof Error?e.message:"Failed to load functions analytics")}finally{y(s,!1)}}function A(e){y(p,e,!0),y(s,!0),$()}U(()=>{$();const e=setInterval($,3e4);return()=>clearInterval(e)});const H=n(()=>()=>{var c;if(!((c=t(o))!=null&&c.summary))return 0;const{totalRequests:e,totalErrors:a}=t(o).summary;return e>0?a/e*100:0}),B=n(()=>()=>{var e;return(((e=t(o))==null?void 0:e.timeSeries)||[]).map(a=>({timestamp:a.timestamp,value:a.requests??a.value??0}))}),C=n(()=>()=>{var e;return(((e=t(o))==null?void 0:e.breakdown)||[]).map(a=>({label:a.label||"other",value:a.count}))}),E=n(()=>()=>{var e;return(((e=t(o))==null?void 0:e.topItems)||[]).map(a=>({label:a.label,count:a.count,secondary:`${Math.round(a.avgLatency)}ms · ${a.errorRate.toFixed(1)}% 5xx`}))});Z(w,{title:"Functions Analytics",description:"Serverless function execution and performance metrics",get docsHref(){return tt},actions:a=>{var c=st();W(c,21,()=>T,X,(f,d)=>{var l=nt();let v;var h=b(l,!0);g(l),J(()=>{v=Y(l,1,"range-btn",null,v,{"range-btn--active":t(p)===t(d).value}),N(h,t(d).label)}),O("click",l,()=>A(t(d).value)),F(f,l)}),g(c),F(a,c)},children:(a,c)=>{var f=ot(),d=G(f),l=b(d);{let r=n(()=>{var i,u;return((u=(i=t(o))==null?void 0:i.summary)==null?void 0:u.totalRequests)??0});_(l,{label:"Invocations",get value(){return t(r)},get loading(){return t(s)}})}var v=m(l,2);{let r=n(()=>{var i,u;return((u=(i=t(o))==null?void 0:i.summary)==null?void 0:u.uniqueUsers)??0});_(v,{label:"Unique Users",get value(){return t(r)},get loading(){return t(s)}})}var h=m(v,2);{let r=n(()=>t(H)().toFixed(1));_(h,{label:"5xx Rate",get value(){return t(r)},unit:"%",get loading(){return t(s)}})}var L=m(h,2);{let r=n(()=>{var i,u;return Math.round(((u=(i=t(o))==null?void 0:i.summary)==null?void 0:u.avgLatency)??0)});_(L,{label:"Avg Duration",get value(){return t(r)},unit:"ms",get loading(){return t(s)}})}g(d);var x=m(d,2),P=b(x);{let r=n(()=>t(B)());et(P,{get data(){return t(r)},type:"bar",height:240,label:"Function invocations over time",get loading(){return t(s)},color:"#dc2626"})}g(x);var R=m(x,2),q=b(R);{let r=n(()=>t(C)());at(q,{get items(){return t(r)},title:"Functions by invocation count",get loading(){return t(s)}})}var S=m(q,2);{let r=n(()=>t(E)());rt(S,{get items(){return t(r)},title:"Top Functions",get loading(){return t(s)}})}g(R),F(a,f)}}),z()}K(["click"]);export{bt as component};
1
+ import"../chunks/CWj6FrbW.js";import{o as U}from"../chunks/Bn2NtlTj.js";import{p as j,a as z,f as G,c as b,g as t,s as m,r as g,u as n,b as y,d as D,t as J}from"../chunks/BdTBlfLy.js";import{d as K,s as N,a as O}from"../chunks/DtZk82gG.js";import{a as Q,t as V,e as W,i as X}from"../chunks/Dc1-6Po6.js";import{a as F,f as I}from"../chunks/DEELgv7K.js";import{s as Y}from"../chunks/CoI6jjbg.js";import{P as Z}from"../chunks/B8s_s9QY.js";import{a as tt}from"../chunks/Q2nPFxS6.js";import{M as _,T as et}from"../chunks/CzSAxmuj.js";import{D as at,T as rt}from"../chunks/A_3UuvCe.js";var nt=I("<button> </button>"),st=I('<div class="range-selector"></div>'),ot=I('<div class="analytics-grid"><!> <!> <!> <!></div> <div class="analytics-chart"><!></div> <div class="analytics-bottom"><!> <!></div>',1);function bt(w,M){j(M,!0);let s=D(!0),o=D(null),p=D("24h");const T=[{value:"1h",label:"1H"},{value:"6h",label:"6H"},{value:"24h",label:"24H"},{value:"7d",label:"7D"},{value:"30d",label:"30D"}],k=n(()=>()=>{switch(t(p)){case"1h":return"minute";case"6h":case"24h":return"hour";default:return"day"}});async function $(){try{const e=await Q.fetch(`data/analytics?range=${t(p)}&category=function&metric=overview&groupBy=${t(k)()}`);y(o,e,!0)}catch(e){V(e instanceof Error?e.message:"Failed to load functions analytics")}finally{y(s,!1)}}function A(e){y(p,e,!0),y(s,!0),$()}U(()=>{$();const e=setInterval($,3e4);return()=>clearInterval(e)});const H=n(()=>()=>{var c;if(!((c=t(o))!=null&&c.summary))return 0;const{totalRequests:e,totalErrors:a}=t(o).summary;return e>0?a/e*100:0}),B=n(()=>()=>{var e;return(((e=t(o))==null?void 0:e.timeSeries)||[]).map(a=>({timestamp:a.timestamp,value:a.requests??a.value??0}))}),C=n(()=>()=>{var e;return(((e=t(o))==null?void 0:e.breakdown)||[]).map(a=>({label:a.label||"other",value:a.count}))}),E=n(()=>()=>{var e;return(((e=t(o))==null?void 0:e.topItems)||[]).map(a=>({label:a.label,count:a.count,secondary:`${Math.round(a.avgLatency)}ms · ${a.errorRate.toFixed(1)}% 5xx`}))});Z(w,{title:"Functions Analytics",description:"Serverless function execution and performance metrics",get docsHref(){return tt},actions:a=>{var c=st();W(c,21,()=>T,X,(f,d)=>{var l=nt();let v;var h=b(l,!0);g(l),J(()=>{v=Y(l,1,"range-btn",null,v,{"range-btn--active":t(p)===t(d).value}),N(h,t(d).label)}),O("click",l,()=>A(t(d).value)),F(f,l)}),g(c),F(a,c)},children:(a,c)=>{var f=ot(),d=G(f),l=b(d);{let r=n(()=>{var i,u;return((u=(i=t(o))==null?void 0:i.summary)==null?void 0:u.totalRequests)??0});_(l,{label:"Invocations",get value(){return t(r)},get loading(){return t(s)}})}var v=m(l,2);{let r=n(()=>{var i,u;return((u=(i=t(o))==null?void 0:i.summary)==null?void 0:u.uniqueUsers)??0});_(v,{label:"Unique Users",get value(){return t(r)},get loading(){return t(s)}})}var h=m(v,2);{let r=n(()=>t(H)().toFixed(1));_(h,{label:"5xx Rate",get value(){return t(r)},unit:"%",get loading(){return t(s)}})}var L=m(h,2);{let r=n(()=>{var i,u;return Math.round(((u=(i=t(o))==null?void 0:i.summary)==null?void 0:u.avgLatency)??0)});_(L,{label:"Avg Duration",get value(){return t(r)},unit:"ms",get loading(){return t(s)}})}g(d);var x=m(d,2),P=b(x);{let r=n(()=>t(B)());et(P,{get data(){return t(r)},type:"bar",height:240,label:"Function invocations over time",get loading(){return t(s)},color:"#dc2626"})}g(x);var R=m(x,2),q=b(R);{let r=n(()=>t(C)());at(q,{get items(){return t(r)},title:"Functions by invocation count",get loading(){return t(s)}})}var S=m(q,2);{let r=n(()=>t(E)());rt(S,{get items(){return t(r)},title:"Top Functions",get loading(){return t(s)}})}g(R),F(a,f)}}),z()}K(["click"]);export{bt as component};
@@ -1 +1 @@
1
- {"version":"1774248496864"}
1
+ {"version":"1774317332252"}
@@ -5,12 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>EdgeBase Admin</title>
7
7
  <link rel="icon" href="/admin/favicon.svg" type="image/svg+xml" />
8
- <link href="/admin/_app/immutable/entry/start.zXCirpgY.js" rel="modulepreload">
9
- <link href="/admin/_app/immutable/chunks/m9QZTyVV.js" rel="modulepreload">
8
+ <link href="/admin/_app/immutable/entry/start.l1WvHznQ.js" rel="modulepreload">
9
+ <link href="/admin/_app/immutable/chunks/byv2rTy8.js" rel="modulepreload">
10
10
  <link href="/admin/_app/immutable/chunks/BdTBlfLy.js" rel="modulepreload">
11
11
  <link href="/admin/_app/immutable/chunks/Bn2NtlTj.js" rel="modulepreload">
12
- <link href="/admin/_app/immutable/chunks/D755Tqat.js" rel="modulepreload">
13
- <link href="/admin/_app/immutable/entry/app.BTsq3_xq.js" rel="modulepreload">
12
+ <link href="/admin/_app/immutable/chunks/DiyBpamp.js" rel="modulepreload">
13
+ <link href="/admin/_app/immutable/entry/app.CfrmEXPD.js" rel="modulepreload">
14
14
  <link href="/admin/_app/immutable/chunks/B2bEC_Hm.js" rel="modulepreload">
15
15
  <link href="/admin/_app/immutable/chunks/Bb0e0sAP.js" rel="modulepreload">
16
16
  <link href="/admin/_app/immutable/chunks/DtZk82gG.js" rel="modulepreload">
@@ -26,7 +26,7 @@
26
26
  <div style="display: contents">
27
27
  <script>
28
28
  {
29
- __sveltekit_1vwf6db = {
29
+ __sveltekit_1thdx8y = {
30
30
  base: "/admin",
31
31
  assets: "/admin"
32
32
  };
@@ -34,8 +34,8 @@
34
34
  const element = document.currentScript.parentElement;
35
35
 
36
36
  Promise.all([
37
- import("/admin/_app/immutable/entry/start.zXCirpgY.js"),
38
- import("/admin/_app/immutable/entry/app.BTsq3_xq.js")
37
+ import("/admin/_app/immutable/entry/start.l1WvHznQ.js"),
38
+ import("/admin/_app/immutable/entry/app.CfrmEXPD.js")
39
39
  ]).then(([kit, app]) => {
40
40
  kit.start(app, element);
41
41
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edge-base/server",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "EdgeBase runtime assets consumed by the EdgeBase CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,8 @@
34
34
  "jose": "^6.0.0",
35
35
  "pg": "^8.16.3",
36
36
  "zod": "^4.3.6",
37
- "@edge-base/shared": "0.2.1"
37
+ "@edge-base/core": "0.2.3",
38
+ "@edge-base/shared": "0.2.3"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@cloudflare/vitest-pool-workers": "^0.8.71",
@@ -0,0 +1,271 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { defineConfig } from '@edge-base/shared';
3
+ import { buildInternalHandlerContext } from '../lib/internal-request.js';
4
+ import type { Env } from '../types.js';
5
+
6
+ function createInsertMockD1(row: Record<string, unknown>): D1Database {
7
+ return {
8
+ prepare(sql: string) {
9
+ const state = { bindings: [] as unknown[] };
10
+ const stmt = {
11
+ bind: (...values: unknown[]) => {
12
+ state.bindings = values;
13
+ return stmt;
14
+ },
15
+ all: async () => {
16
+ if (sql.startsWith('INSERT INTO')) {
17
+ return { results: [], meta: { changes: 1 } };
18
+ }
19
+ if (sql.startsWith('SELECT * FROM')) {
20
+ return { results: [row], meta: { changes: 0 } };
21
+ }
22
+ throw new Error(`Unexpected SQL: ${sql}`);
23
+ },
24
+ };
25
+ return stmt;
26
+ },
27
+ } as unknown as D1Database;
28
+ }
29
+
30
+ function createBatchInsertMockD1(rowsById: Record<string, Record<string, unknown>>): D1Database {
31
+ return {
32
+ prepare(sql: string) {
33
+ const state = { bindings: [] as unknown[] };
34
+ const stmt = {
35
+ _sql: sql,
36
+ bind: (...values: unknown[]) => {
37
+ state.bindings = values;
38
+ return stmt;
39
+ },
40
+ all: async () => {
41
+ if (!sql.startsWith('SELECT * FROM')) {
42
+ throw new Error(`Unexpected SQL: ${sql}`);
43
+ }
44
+ const id = String(state.bindings[0]);
45
+ return {
46
+ results: rowsById[id] ? [rowsById[id]] : [],
47
+ meta: { changes: 0 },
48
+ };
49
+ },
50
+ };
51
+ return stmt;
52
+ },
53
+ batch: async () => [],
54
+ } as unknown as D1Database;
55
+ }
56
+
57
+ function createEnv(db: D1Database): Env {
58
+ return {
59
+ EDGEBASE_CONFIG: defineConfig({
60
+ release: true,
61
+ databases: {
62
+ shared: {
63
+ tables: {
64
+ posts: {
65
+ schema: {
66
+ title: { type: 'string', required: true },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ }),
73
+ DB_D1_SHARED: db,
74
+ DATABASE: {} as DurableObjectNamespace,
75
+ AUTH: {} as DurableObjectNamespace,
76
+ KV: {} as KVNamespace,
77
+ } as unknown as Env;
78
+ }
79
+
80
+ describe('d1 live broadcast verification', () => {
81
+ afterEach(() => {
82
+ vi.resetModules();
83
+ vi.clearAllMocks();
84
+ vi.unstubAllGlobals();
85
+ });
86
+
87
+ it('returns single-record insert responses without waiting for emitDbLiveEvent', async () => {
88
+ let resolveEmit!: () => void;
89
+ const emitGate = new Promise<void>((resolve) => {
90
+ resolveEmit = resolve;
91
+ });
92
+ const emitDbLiveEvent = vi.fn(() => emitGate);
93
+ const emitDbLiveBatchEvent = vi.fn().mockResolvedValue(undefined);
94
+ const sendToDatabaseLiveDO = vi.fn().mockResolvedValue(undefined);
95
+ const executeDbTriggers = vi.fn().mockResolvedValue(undefined);
96
+
97
+ vi.doMock('../lib/d1-schema-init.js', () => ({
98
+ ensureD1Schema: vi.fn().mockResolvedValue(undefined),
99
+ }));
100
+ vi.doMock('../lib/database-live-emitter.js', () => ({
101
+ emitDbLiveEvent,
102
+ emitDbLiveBatchEvent,
103
+ sendToDatabaseLiveDO,
104
+ }));
105
+ vi.doMock('../lib/functions.js', () => ({
106
+ executeDbTriggers,
107
+ }));
108
+
109
+ const { handleD1Request } = await import('../lib/d1-handler.js');
110
+
111
+ const env = createEnv(createInsertMockD1({ id: 'post-1', title: 'hello world' }));
112
+ const waitUntil = vi.fn();
113
+ const ctx = buildInternalHandlerContext({
114
+ env,
115
+ executionCtx: { waitUntil } as unknown as ExecutionContext,
116
+ request: new Request('http://internal/api/db/shared/tables/posts', {
117
+ method: 'POST',
118
+ headers: {
119
+ 'Content-Type': 'application/json',
120
+ 'X-Is-Service-Key': 'true',
121
+ },
122
+ }),
123
+ body: {
124
+ id: 'post-1',
125
+ title: 'hello world',
126
+ },
127
+ });
128
+
129
+ let settled = false;
130
+ const responsePromise = handleD1Request(ctx, 'shared', 'posts', '/tables/posts').then((response) => {
131
+ settled = true;
132
+ return response;
133
+ });
134
+
135
+ await vi.waitFor(() => {
136
+ expect(emitDbLiveEvent).toHaveBeenCalledTimes(1);
137
+ });
138
+ await vi.waitFor(() => {
139
+ expect(settled).toBe(true);
140
+ });
141
+
142
+ const response = await responsePromise;
143
+ const json = await response.json() as Record<string, unknown>;
144
+
145
+ expect(response.status).toBe(201);
146
+ expect(json).toMatchObject({ id: 'post-1', title: 'hello world' });
147
+ expect(waitUntil).toHaveBeenCalled();
148
+
149
+ resolveEmit();
150
+ });
151
+
152
+ it('sends small batches through a single background Promise.all instead of awaiting fan-out', async () => {
153
+ let resolveFanOut!: () => void;
154
+ const fanOutGate = new Promise<void>((resolve) => {
155
+ resolveFanOut = resolve;
156
+ });
157
+ const emitDbLiveEvent = vi.fn(() => fanOutGate);
158
+ const emitDbLiveBatchEvent = vi.fn().mockResolvedValue(undefined);
159
+ const sendToDatabaseLiveDO = vi.fn().mockResolvedValue(undefined);
160
+
161
+ vi.doMock('../lib/d1-schema-init.js', () => ({
162
+ ensureD1Schema: vi.fn().mockResolvedValue(undefined),
163
+ }));
164
+ vi.doMock('../lib/database-live-emitter.js', () => ({
165
+ emitDbLiveEvent,
166
+ emitDbLiveBatchEvent,
167
+ sendToDatabaseLiveDO,
168
+ }));
169
+ vi.doMock('../lib/functions.js', () => ({
170
+ executeDbTriggers: vi.fn().mockResolvedValue(undefined),
171
+ }));
172
+
173
+ const { handleD1Request } = await import('../lib/d1-handler.js');
174
+
175
+ const rowsById = {
176
+ 'post-1': { id: 'post-1', title: 'post 1' },
177
+ 'post-2': { id: 'post-2', title: 'post 2' },
178
+ };
179
+ const env = createEnv(createBatchInsertMockD1(rowsById));
180
+ const waitUntil = vi.fn();
181
+ const ctx = buildInternalHandlerContext({
182
+ env,
183
+ executionCtx: { waitUntil } as unknown as ExecutionContext,
184
+ request: new Request('http://internal/api/db/shared/tables/posts/batch', {
185
+ method: 'POST',
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ 'X-Is-Service-Key': 'true',
189
+ },
190
+ }),
191
+ body: {
192
+ inserts: Object.values(rowsById),
193
+ },
194
+ });
195
+
196
+ let settled = false;
197
+ const responsePromise = handleD1Request(ctx, 'shared', 'posts', '/tables/posts/batch').then((response) => {
198
+ settled = true;
199
+ return response;
200
+ });
201
+
202
+ await vi.waitFor(() => {
203
+ expect(emitDbLiveEvent).toHaveBeenCalledTimes(2);
204
+ });
205
+ await vi.waitFor(() => {
206
+ expect(settled).toBe(true);
207
+ });
208
+
209
+ const response = await responsePromise;
210
+ const json = await response.json() as { inserted: Array<Record<string, unknown>> };
211
+
212
+ expect(response.status).toBe(200);
213
+ expect(json.inserted).toHaveLength(2);
214
+ expect(emitDbLiveBatchEvent).not.toHaveBeenCalled();
215
+ expect(waitUntil).toHaveBeenCalledTimes(1);
216
+
217
+ resolveFanOut();
218
+ });
219
+
220
+ it('still sends 10+ change batches through waitUntil instead of awaiting the batch emitter', async () => {
221
+ const emitDbLiveEvent = vi.fn().mockResolvedValue(undefined);
222
+ const emitDbLiveBatchEvent = vi.fn(() => new Promise<void>(() => {}));
223
+ const sendToDatabaseLiveDO = vi.fn().mockResolvedValue(undefined);
224
+
225
+ vi.doMock('../lib/d1-schema-init.js', () => ({
226
+ ensureD1Schema: vi.fn().mockResolvedValue(undefined),
227
+ }));
228
+ vi.doMock('../lib/database-live-emitter.js', () => ({
229
+ emitDbLiveEvent,
230
+ emitDbLiveBatchEvent,
231
+ sendToDatabaseLiveDO,
232
+ }));
233
+ vi.doMock('../lib/functions.js', () => ({
234
+ executeDbTriggers: vi.fn().mockResolvedValue(undefined),
235
+ }));
236
+
237
+ const { handleD1Request } = await import('../lib/d1-handler.js');
238
+
239
+ const rowsById = Object.fromEntries(
240
+ Array.from({ length: 10 }, (_value, index) => {
241
+ const id = `post-${index + 1}`;
242
+ return [id, { id, title: `post ${index + 1}` }];
243
+ }),
244
+ );
245
+ const env = createEnv(createBatchInsertMockD1(rowsById));
246
+ const waitUntil = vi.fn();
247
+ const ctx = buildInternalHandlerContext({
248
+ env,
249
+ executionCtx: { waitUntil } as unknown as ExecutionContext,
250
+ request: new Request('http://internal/api/db/shared/tables/posts/batch', {
251
+ method: 'POST',
252
+ headers: {
253
+ 'Content-Type': 'application/json',
254
+ 'X-Is-Service-Key': 'true',
255
+ },
256
+ }),
257
+ body: {
258
+ inserts: Object.values(rowsById),
259
+ },
260
+ });
261
+
262
+ const response = await handleD1Request(ctx, 'shared', 'posts', '/tables/posts/batch');
263
+ const json = await response.json() as { inserted: Array<Record<string, unknown>> };
264
+
265
+ expect(response.status).toBe(200);
266
+ expect(json.inserted).toHaveLength(10);
267
+ expect(emitDbLiveBatchEvent).toHaveBeenCalledTimes(1);
268
+ expect(emitDbLiveEvent).not.toHaveBeenCalled();
269
+ expect(waitUntil).toHaveBeenCalledTimes(1);
270
+ });
271
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('cloudflare:workers', () => ({
4
+ DurableObject: class DurableObject {
5
+ ctx: unknown;
6
+ env: unknown;
7
+
8
+ constructor(ctx: unknown, env: unknown) {
9
+ this.ctx = ctx;
10
+ this.env = env;
11
+ }
12
+ },
13
+ }));
14
+
15
+ describe('DatabaseLiveDO delivery idempotency', () => {
16
+ it('ignores duplicate internal change events with the same deliveryId', async () => {
17
+ const { DatabaseLiveDO } = await import('../durable-objects/database-live-do.js');
18
+
19
+ const ctx = {
20
+ acceptWebSocket: vi.fn(),
21
+ getWebSockets: vi.fn(() => []),
22
+ getTags: vi.fn(() => []),
23
+ } as any;
24
+ const live = new DatabaseLiveDO(ctx, {} as any) as any;
25
+ live.broadcastWithFilters = vi.fn().mockResolvedValue(undefined);
26
+
27
+ const payload = {
28
+ deliveryId: 'delivery-1',
29
+ type: 'modified',
30
+ channel: 'dblive:shared:posts',
31
+ table: 'posts',
32
+ docId: 'post-1',
33
+ data: { id: 'post-1', title: 'hello' },
34
+ timestamp: '2026-03-24T00:00:00.000Z',
35
+ };
36
+
37
+ const first = await live.handleInternalEvent(new Request('http://internal/internal/event', {
38
+ method: 'POST',
39
+ body: JSON.stringify(payload),
40
+ }));
41
+ const second = await live.handleInternalEvent(new Request('http://internal/internal/event', {
42
+ method: 'POST',
43
+ body: JSON.stringify(payload),
44
+ }));
45
+
46
+ await expect(first.json()).resolves.toEqual({ ok: true });
47
+ await expect(second.json()).resolves.toEqual({ ok: true, duplicate: true });
48
+ expect(live.broadcastWithFilters).toHaveBeenCalledTimes(1);
49
+ });
50
+ });
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { buildDbLiveChannel, emitDbLiveBatchEvent, emitDbLiveEvent } from '../lib/database-live-emitter.js';
2
+ import {
3
+ buildDbLiveChannel,
4
+ emitDbLiveBatchEvent,
5
+ emitDbLiveEvent,
6
+ isDbLiveChannel,
7
+ sendToDatabaseLiveDO,
8
+ } from '../lib/database-live-emitter.js';
3
9
 
4
10
  describe('buildDbLiveChannel', () => {
5
11
  it('builds shared table channels with namespace', () => {
@@ -13,6 +19,24 @@ describe('buildDbLiveChannel', () => {
13
19
  });
14
20
  });
15
21
 
22
+ describe('isDbLiveChannel', () => {
23
+ it('accepts valid db-live table and document channels', () => {
24
+ expect(isDbLiveChannel('dblive:shared:posts')).toBe(true);
25
+ expect(isDbLiveChannel('dblive:shared:posts:post-1')).toBe(true);
26
+ expect(isDbLiveChannel('dblive:workspace:ws-1:posts')).toBe(true);
27
+ expect(isDbLiveChannel('dblive:workspace:ws-1:posts:post-1')).toBe(true);
28
+ });
29
+
30
+ it('rejects non-db-live, presence, broadcast, malformed, and empty-segment channels', () => {
31
+ expect(isDbLiveChannel('presence:shared:posts')).toBe(false);
32
+ expect(isDbLiveChannel('dblive:presence:posts')).toBe(false);
33
+ expect(isDbLiveChannel('dblive:broadcast:posts')).toBe(false);
34
+ expect(isDbLiveChannel('dblive:shared')).toBe(false);
35
+ expect(isDbLiveChannel('dblive:one:two:three:four:five')).toBe(false);
36
+ expect(isDbLiveChannel('dblive:shared::posts')).toBe(false);
37
+ });
38
+ });
39
+
16
40
  describe('database-live emitter', () => {
17
41
  it('emits shared-table events to namespace-aware table and doc channels', async () => {
18
42
  const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
@@ -53,4 +77,95 @@ describe('database-live emitter', () => {
53
77
  const payload = JSON.parse(fetch.mock.calls[0]![1].body as string);
54
78
  expect(payload.channel).toBe('dblive:workspace:ws-9:documents');
55
79
  });
80
+
81
+ it('rejects when the database-live DO responds with a server error', async () => {
82
+ const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 500 }));
83
+ const env = {
84
+ DATABASE_LIVE: {
85
+ idFromName: vi.fn((name: string) => name),
86
+ get: vi.fn().mockReturnValue({ fetch }),
87
+ },
88
+ } as any;
89
+
90
+ await expect(
91
+ emitDbLiveEvent(env, 'shared', 'posts', 'modified', 'post-1', { id: 'post-1' }),
92
+ ).rejects.toThrow(/DatabaseLiveDO .* failed with 500/);
93
+
94
+ expect(fetch).toHaveBeenCalledTimes(4);
95
+ });
96
+
97
+ it('skips the document fan-out for bulk events', async () => {
98
+ const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
99
+ const env = {
100
+ DATABASE_LIVE: {
101
+ idFromName: vi.fn((name: string) => name),
102
+ get: vi.fn().mockReturnValue({ fetch }),
103
+ },
104
+ } as any;
105
+
106
+ await emitDbLiveEvent(env, 'shared', 'posts', 'removed', '_bulk', { action: 'delete', count: 3 });
107
+
108
+ expect(fetch).toHaveBeenCalledTimes(1);
109
+ const payload = JSON.parse(fetch.mock.calls[0]![1].body as string);
110
+ expect(payload.channel).toBe('dblive:shared:posts');
111
+ });
112
+
113
+ it('returns early when DATABASE_LIVE is not configured', async () => {
114
+ await expect(
115
+ sendToDatabaseLiveDO({} as any, { channel: 'dblive:shared:posts', event: 'refresh' }),
116
+ ).resolves.toBeUndefined();
117
+ });
118
+
119
+ it('reuses the same deliveryId across retries for a single handoff', async () => {
120
+ const fetch = vi.fn()
121
+ .mockResolvedValueOnce(new Response(null, { status: 500 }))
122
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
123
+ const env = {
124
+ DATABASE_LIVE: {
125
+ idFromName: vi.fn((name: string) => name),
126
+ get: vi.fn().mockReturnValue({ fetch }),
127
+ },
128
+ } as any;
129
+
130
+ await sendToDatabaseLiveDO(env, { channel: 'dblive:shared:posts', event: 'refresh' }, '/internal/broadcast');
131
+
132
+ expect(fetch).toHaveBeenCalledTimes(2);
133
+ const firstBody = JSON.parse(fetch.mock.calls[0]![1].body as string);
134
+ const secondBody = JSON.parse(fetch.mock.calls[1]![1].body as string);
135
+ expect(firstBody.deliveryId).toBeTruthy();
136
+ expect(firstBody.deliveryId).toBe(secondBody.deliveryId);
137
+ });
138
+
139
+ it('keeps an existing deliveryId instead of generating a new one', async () => {
140
+ const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
141
+ const env = {
142
+ DATABASE_LIVE: {
143
+ idFromName: vi.fn((name: string) => name),
144
+ get: vi.fn().mockReturnValue({ fetch }),
145
+ },
146
+ } as any;
147
+
148
+ await sendToDatabaseLiveDO(env, {
149
+ channel: 'dblive:shared:posts',
150
+ deliveryId: 'delivery-fixed',
151
+ event: 'refresh',
152
+ });
153
+
154
+ const body = JSON.parse(fetch.mock.calls[0]![1].body as string);
155
+ expect(body.deliveryId).toBe('delivery-fixed');
156
+ });
157
+
158
+ it('throws a generic error when retries fail with a non-Error value', async () => {
159
+ const fetch = vi.fn().mockRejectedValue('boom');
160
+ const env = {
161
+ DATABASE_LIVE: {
162
+ idFromName: vi.fn((name: string) => name),
163
+ get: vi.fn().mockReturnValue({ fetch }),
164
+ },
165
+ } as any;
166
+
167
+ await expect(
168
+ sendToDatabaseLiveDO(env, { channel: 'dblive:shared:posts', event: 'refresh' }),
169
+ ).rejects.toThrow('DatabaseLiveDO delivery failed.');
170
+ });
56
171
  });
@@ -154,6 +154,13 @@ describe('hookRejectedError', () => {
154
154
  expect(hookRejectedError(original)).toBe(original);
155
155
  });
156
156
 
157
+ it('uses the fallback message and hook prefix for unauthorized non-Error rejections', () => {
158
+ const err = hookRejectedError('plain failure', 'Authentication required', 'beforeSave');
159
+ expect(err.code).toBe(401);
160
+ expect(err.message).toBe("Hook 'beforeSave' rejected: Authentication required");
161
+ expect(err.slug).toBe('hook-rejected');
162
+ });
163
+
157
164
  it('maps ownership denial messages to 403', () => {
158
165
  const err = hookRejectedError(new Error('Only owners can update this record.'));
159
166
  expect(err.code).toBe(403);
@@ -171,6 +178,18 @@ describe('hookRejectedError', () => {
171
178
  expect(err.code).toBe(409);
172
179
  });
173
180
 
181
+ it('maps not-found style messages to 404', () => {
182
+ const err = hookRejectedError(new Error('Unknown project id.'));
183
+ expect(err.code).toBe(404);
184
+ expect(err.message).toBe('Unknown project id.');
185
+ });
186
+
187
+ it('maps rate-limit style messages to 429', () => {
188
+ const err = hookRejectedError(new Error('Too many uploads, throttled.'));
189
+ expect(err.code).toBe(429);
190
+ expect(err.slug).toBe('hook-rejected');
191
+ });
192
+
174
193
  it('falls back to validation errors for unknown hook failures', () => {
175
194
  const err = hookRejectedError(new Error('Custom hook failure.'));
176
195
  expect(err.code).toBe(400);
@@ -179,6 +198,11 @@ describe('hookRejectedError', () => {
179
198
  });
180
199
 
181
200
  describe('normalizeDatabaseError', () => {
201
+ it('passes EdgeBaseError instances through untouched', () => {
202
+ const original = validationError('Already normalized');
203
+ expect(normalizeDatabaseError(original)).toBe(original);
204
+ });
205
+
182
206
  it('maps foreign key failures to validation errors', () => {
183
207
  const err = normalizeDatabaseError(new Error('D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT'));
184
208
  expect(err).toBeInstanceOf(EdgeBaseError);
@@ -187,6 +211,13 @@ describe('normalizeDatabaseError', () => {
187
211
  expect(err?.slug).toBe('foreign-key-failed');
188
212
  });
189
213
 
214
+ it('maps foreign key failures without a detected column to the generic message', () => {
215
+ const err = normalizeDatabaseError('FOREIGN KEY constraint failed');
216
+ expect(err).toBeInstanceOf(EdgeBaseError);
217
+ expect(err?.code).toBe(400);
218
+ expect(err?.message).toContain('Check that all foreign key references');
219
+ });
220
+
190
221
  it('maps foreign key failures from cross-realm error-like objects', () => {
191
222
  const err = normalizeDatabaseError({
192
223
  message: 'D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT',
@@ -204,6 +235,38 @@ describe('normalizeDatabaseError', () => {
204
235
  expect(err?.slug).toBe('record-already-exists');
205
236
  });
206
237
 
238
+ it('maps unique constraint failures without a parsed column to a generic conflict message', () => {
239
+ const err = normalizeDatabaseError('UNIQUE constraint failed');
240
+ expect(err).toBeInstanceOf(EdgeBaseError);
241
+ expect(err?.code).toBe(409);
242
+ expect(err?.message).toBe('Record already exists. A unique constraint was violated.');
243
+ });
244
+
245
+ it('maps not-null constraint failures to validation errors', () => {
246
+ const err = normalizeDatabaseError(new Error('NOT NULL constraint failed: users.email'));
247
+ expect(err).toBeInstanceOf(EdgeBaseError);
248
+ expect(err?.code).toBe(400);
249
+ expect(err?.message).toContain("'email'");
250
+ expect(err?.slug).toBe('constraint-failed');
251
+ });
252
+
253
+ it('maps cause-only check constraint failures to a generic validation error', () => {
254
+ const err = normalizeDatabaseError({
255
+ message: 'outer wrapper',
256
+ cause: {
257
+ message: 'check constraint failed',
258
+ },
259
+ });
260
+ expect(err).toBeInstanceOf(EdgeBaseError);
261
+ expect(err?.code).toBe(400);
262
+ expect(err?.message).toBe('Request violates a database constraint. Ensure all required fields are provided.');
263
+ expect(err?.slug).toBe('constraint-failed');
264
+ });
265
+
266
+ it('returns null for blank string inputs', () => {
267
+ expect(normalizeDatabaseError(' ')).toBeNull();
268
+ });
269
+
207
270
  it('returns null for unrelated runtime errors', () => {
208
271
  expect(normalizeDatabaseError(new Error('socket hang up'))).toBeNull();
209
272
  });