@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.
- package/admin-build/_app/immutable/chunks/{DjOEv9M9.js → A_3UuvCe.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dqk2TGNU.js → B-_-hJ9o.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BFs_qStz.js → B5Nwfelm.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B0QyxC2M.js → BxoNtYHK.js} +3 -3
- package/admin-build/_app/immutable/chunks/{BsFiK_FJ.js → CZ0TVkCa.js} +1 -1
- package/admin-build/_app/immutable/chunks/{k0CIJkw4.js → CzSAxmuj.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D-x55wdW.js → DCKcAiQH.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CSGrwS7E.js → DCvwWZrm.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BTJcQFEp.js → DRqPU3wD.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CqUxCvs_.js → Dc1-6Po6.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D755Tqat.js → DiyBpamp.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BcIUK2sk.js → Dlty5069.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BY07qVPA.js → DpVAayDG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BCKr7yKd.js → Du5vWVa2.js} +1 -1
- package/admin-build/_app/immutable/chunks/{m9QZTyVV.js → byv2rTy8.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DnLqc9L1.js → nZvorU8i.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.BTsq3_xq.js → app.CfrmEXPD.js} +2 -2
- package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.BZ00WDYH.js → 0.Cn2BZ4da.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.RzSJ3yyr.js → 1.Dv4LX_Co.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.D-rsiquF.js → 10.DPVv3kat.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.l7-bgtFD.js → 11.CiCb6Ayu.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.Dkq0H7B5.js → 12.CIPyeekF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.DtK_4oRz.js → 13.Z15Lt36e.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.BKo7-AMx.js → 14.s0l5bAq3.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.CQAj_6lq.js → 15.UwSSNO76.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.XVIG-Ffr.js → 16.qiD8i883.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.g6raZLCM.js → 17.Dy3dcSvu.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.IQz6a3T6.js → 18.DeXyPYsO.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CAAZ8i8h.js → 19.CAbuyS6w.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.BPcX3KPj.js → 20.Bec0T7un.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.DuDYelMY.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.Br5AG_5Z.js → 22.CdVprrv2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.KjbrdXoE.js → 23.Y8RzVLoF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.C3n2-hgw.js → 24.CWhHYFBx.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.SFDSBzHd.js → 25.wCBplOVt.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.D95vui6E.js → 26.Cod_JRFK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.FgLgdjwB.js → 27.BO2HVMu9.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.B9sYYm1F.js → 28.DxG-FBVQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.DyqZ_wbN.js → 29.CjGqWGvE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.Bzo2yVIO.js → 3.By3_OmdZ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.c1CiNwiS.js → 30.M_H7Htpq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.CXty66Vh.js → 31.DEU18izM.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.BgQaXZ27.js → 4.DeYhKtzJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.BuJrHvxH.js → 5.9WLgxhrD.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.CkBBC94k.js → 6.BdT2i_dd.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.D2YBvNFM.js → 7.CHq0s4K6.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.D8qQWo_z.js → 8.DuvRw-XZ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.BLDLX5hV.js → 9.C2Ub82wn.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/package.json +3 -2
- package/src/__tests__/d1-live-broadcast-verification.test.ts +271 -0
- package/src/__tests__/database-live-do.test.ts +50 -0
- package/src/__tests__/database-live-emitter.test.ts +116 -1
- package/src/__tests__/error-format.test.ts +63 -0
- package/src/__tests__/functions-context.test.ts +592 -35
- package/src/__tests__/meta-export-coverage.test.ts +1 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +110 -0
- package/src/__tests__/provider-aware-sql.test.ts +157 -0
- package/src/__tests__/room-auth-state-loss.test.ts +124 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +0 -4
- package/src/__tests__/sql-route.test.ts +187 -76
- package/src/durable-objects/database-live-do.ts +46 -1
- package/src/durable-objects/room-runtime-base.ts +26 -2
- package/src/durable-objects/rooms-do.ts +1 -1
- package/src/lib/admin-db-target.ts +30 -74
- package/src/lib/d1-handler.ts +45 -14
- package/src/lib/database-live-emitter.ts +57 -16
- package/src/lib/functions.ts +332 -454
- package/src/lib/internal-transport.ts +316 -0
- package/src/lib/plugin-migrations.ts +39 -39
- package/src/lib/postgres-handler.ts +39 -11
- package/src/lib/provider-aware-sql.ts +827 -0
- package/src/routes/admin.ts +7 -1
- package/src/routes/auth.ts +11 -12
- package/src/routes/sql.ts +51 -76
- package/src/routes/storage.ts +11 -12
- package/src/types.ts +2 -0
- package/admin-build/_app/immutable/entry/start.zXCirpgY.js +0 -1
- 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/
|
|
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":"
|
|
1
|
+
{"version":"1774317332252"}
|
package/admin-build/index.html
CHANGED
|
@@ -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.
|
|
9
|
-
<link href="/admin/_app/immutable/chunks/
|
|
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/
|
|
13
|
-
<link href="/admin/_app/immutable/entry/app.
|
|
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
|
-
|
|
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.
|
|
38
|
-
import("/admin/_app/immutable/entry/app.
|
|
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.
|
|
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/
|
|
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 {
|
|
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
|
});
|