@lunora/platform 1.0.0-alpha.1 → 1.0.0-alpha.10
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/dist/conformance/index.d.mts +1 -1
- package/dist/conformance/index.d.ts +1 -1
- package/dist/conformance/index.mjs +1 -1
- package/dist/conformance/suite.d.mts +10 -1
- package/dist/conformance/suite.d.ts +10 -1
- package/dist/conformance/suite.mjs +1 -1
- package/dist/index.d.mts +72 -7
- package/dist/index.d.ts +72 -7
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/CLOUDFLARE_CAPABILITIES-DXNmuRst.mjs +1 -0
- package/dist/packem_shared/createReferenceHost-NGFPX8d2.mjs +1 -0
- package/dist/packem_shared/resolveShard-BPCXp8pD.mjs +1 -0
- package/dist/packem_shared/{socket-host.d-CINxcosS.d.ts → socket-host.d-Dy2QiVn6.d.mts} +105 -18
- package/dist/packem_shared/{socket-host.d-CINxcosS.d.mts → socket-host.d-Dy2QiVn6.d.ts} +105 -18
- package/package.json +1 -1
- package/dist/packem_shared/CLOUDFLARE_CAPABILITIES-B0yosrNL.mjs +0 -1
- package/dist/packem_shared/createReferenceHost-GO8Hp4ft.mjs +0 -1
- package/dist/packem_shared/resolveShard-uGhAKuTB.mjs +0 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { type C as ConformanceHost, type a as ConformanceHostFactory, type R as ReferenceHost, type VitestApi, c as createReferenceHost, defineHostContractSuite } from "./suite.mjs";
|
|
2
|
-
import "../packem_shared/socket-host.d-
|
|
2
|
+
import "../packem_shared/socket-host.d-Dy2QiVn6.mjs";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { type C as ConformanceHost, type a as ConformanceHostFactory, type R as ReferenceHost, type VitestApi, c as createReferenceHost, defineHostContractSuite } from "./suite.js";
|
|
2
|
-
import "../packem_shared/socket-host.d-
|
|
2
|
+
import "../packem_shared/socket-host.d-Dy2QiVn6.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createReferenceHost as o}from"../packem_shared/createReferenceHost-
|
|
1
|
+
import{createReferenceHost as o}from"../packem_shared/createReferenceHost-NGFPX8d2.mjs";import{defineHostContractSuite as f}from"./suite.mjs";export{o as createReferenceHost,f as defineHostContractSuite};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { f as ShardDirectory, j as ShardKvStore,
|
|
1
|
+
import { f as ShardDirectory, j as ShardKvStore, o as SocketHandle, c as SchedulerHost, g as ShardHost, p as SocketHost } from "../packem_shared/socket-host.d-Dy2QiVn6.mjs";
|
|
2
2
|
/**
|
|
3
3
|
* A conformance host bundles all four platform contracts so a single factory
|
|
4
4
|
* can stand up a complete, isolated test environment.
|
|
@@ -12,6 +12,15 @@ interface ConformanceHost {
|
|
|
12
12
|
* contract — platform delivery is the platform's test, not the adapter's.
|
|
13
13
|
*/
|
|
14
14
|
awaitAlarmFired?: (target: number) => Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve once the host has actually dispatched a scheduled job — invoked
|
|
17
|
+
* its delivery path, not merely expired the timer. Optional, but NOT for a
|
|
18
|
+
* host that declares `scheduler.deadLetter`: that member is the
|
|
19
|
+
* at-least-once claim, and a host that cannot show the TCK a dispatch is
|
|
20
|
+
* claiming what the suite cannot check.
|
|
21
|
+
* @returns `true` when the job was dispatched at least once.
|
|
22
|
+
*/
|
|
23
|
+
awaitJobDispatched?: (id: string) => Promise<boolean>;
|
|
15
24
|
/** Optional cleanup hook (close DBs, release timers). */
|
|
16
25
|
cleanup?: () => void;
|
|
17
26
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { f as ShardDirectory, j as ShardKvStore,
|
|
1
|
+
import { f as ShardDirectory, j as ShardKvStore, o as SocketHandle, c as SchedulerHost, g as ShardHost, p as SocketHost } from "../packem_shared/socket-host.d-Dy2QiVn6.js";
|
|
2
2
|
/**
|
|
3
3
|
* A conformance host bundles all four platform contracts so a single factory
|
|
4
4
|
* can stand up a complete, isolated test environment.
|
|
@@ -12,6 +12,15 @@ interface ConformanceHost {
|
|
|
12
12
|
* contract — platform delivery is the platform's test, not the adapter's.
|
|
13
13
|
*/
|
|
14
14
|
awaitAlarmFired?: (target: number) => Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve once the host has actually dispatched a scheduled job — invoked
|
|
17
|
+
* its delivery path, not merely expired the timer. Optional, but NOT for a
|
|
18
|
+
* host that declares `scheduler.deadLetter`: that member is the
|
|
19
|
+
* at-least-once claim, and a host that cannot show the TCK a dispatch is
|
|
20
|
+
* claiming what the suite cannot check.
|
|
21
|
+
* @returns `true` when the job was dispatched at least once.
|
|
22
|
+
*/
|
|
23
|
+
awaitJobDispatched?: (id: string) => Promise<boolean>;
|
|
15
24
|
/** Optional cleanup hook (close DBs, release timers). */
|
|
16
25
|
cleanup?: () => void;
|
|
17
26
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolveShard as l}from"../packem_shared/resolveShard-uGhAKuTB.mjs";const m=(u,h,w)=>{const{describe:d,expect:t,it:o}=w;d(`host contract: ${u}`,()=>{const r=async()=>h(),n=e=>e.createSocket?.()??{};d("ShardHost",()=>{o("serializes mutations so no two closures interleave",async()=>{t.assertions(1);const e=await r(),a=[];await Promise.all([e.shard.runSerialized(async()=>{a.push("a-start"),await new Promise(c=>{setTimeout(c,10)}),a.push("a-end")}),e.shard.runSerialized(async()=>{a.push("b-start"),await new Promise(c=>{setTimeout(c,5)}),a.push("b-end")})]);const s=a.join("").includes("a-starta-end"),i=a.join("").includes("b-startb-end");t(s&&i).toBe(!0),e.cleanup?.()}),o("rolls back a transaction that throws",async()=>{t.assertions(2);const e=await r();await e.shard.transaction(async()=>{e.shard.sql.exec("CREATE TABLE IF NOT EXISTS rollback_test (id INTEGER PRIMARY KEY)"),e.shard.sql.exec("INSERT INTO rollback_test (id) VALUES (1)")}),await t(e.shard.transaction(async()=>{throw e.shard.sql.exec("INSERT INTO rollback_test (id) VALUES (2)"),new Error("boom")})).rejects.toThrow("boom");const a=e.shard.sql.exec("SELECT id FROM rollback_test WHERE id = 2").toArray();t(a).toHaveLength(0),e.cleanup?.()}),o("observes its own writes inside a transaction",async()=>{t.assertions(1);const e=await r(),a=await e.shard.transaction(async()=>(e.shard.sql.exec("CREATE TABLE IF NOT EXISTS ryw_test (id INTEGER PRIMARY KEY, value TEXT)"),e.shard.sql.exec("INSERT INTO ryw_test (id, value) VALUES (1, 'hello')"),e.shard.sql.exec("SELECT value FROM ryw_test WHERE id = 1").toArray()[0]?.value));t(a).toBe("hello"),e.cleanup?.()}),o("returns a cursor that buffers, yields one row, and iterates",async()=>{t.assertions(3);const e=await r();await e.shard.transaction(async()=>{e.shard.sql.exec("CREATE TABLE IF NOT EXISTS cursor_test (id INTEGER PRIMARY KEY)"),e.shard.sql.exec("INSERT INTO cursor_test (id) VALUES (1)"),e.shard.sql.exec("INSERT INTO cursor_test (id) VALUES (2)")}),t(e.shard.sql.exec("SELECT id FROM cursor_test ORDER BY id").toArray()).toHaveLength(2),t(e.shard.sql.exec("SELECT id FROM cursor_test WHERE id = 1").one()).toEqual({id:1}),t([...e.shard.sql.exec("SELECT id FROM cursor_test ORDER BY id")]).toHaveLength(2),e.cleanup?.()}),o("reports a pending alarm, and clears it once fired",async()=>{t.assertions(2);const e=await r(),a=Date.now()+50;if(await e.shard.alarms.set(a),t(await e.shard.alarms.get()).toBe(a),e.awaitAlarmFired===void 0){t(await e.shard.alarms.get()).toBe(a),e.cleanup?.();return}await e.awaitAlarmFired(a),t(await e.shard.alarms.get()).toBeNull(),e.cleanup?.()}),o("deletes a pending alarm",async()=>{t.assertions(1);const e=await r();await e.shard.alarms.set(Date.now()+1e4),await e.shard.alarms.delete(),t(await e.shard.alarms.get()).toBeNull(),e.cleanup?.()})}),d("SocketHost",()=>{o("accepts a socket and can send/close",async()=>{const e=await r(),a=e.socket.accept(n(e),{user:"ada"});t(e.socket.idFor(a)).toBeDefined(),a.send("hello"),t(e.socket.getSockets().map(s=>e.socket.idFor(s))).toContain(e.socket.idFor(a)),e.readFrames!==void 0&&t(e.readFrames(a)).toStrictEqual(["hello"]),t(()=>{a.close(1e3,"done")}).not.toThrow(),e.cleanup?.()}),o("round-trips an attachment on a live socket",async()=>{t.assertions(1);const e=await r(),a={roomId:"room-1",roles:["admin"]},s=e.socket.accept(n(e),a);t(s.deserializeAttachment()).toEqual(a),e.cleanup?.()}),o("round-trips attachments across a recycle",async()=>{t.assertions(1);const e=await r();if(e.simulateRecycle===void 0||e.restoreSocket===void 0){t(!0).toBe(!0),e.cleanup?.();return}const a={roomId:"room-1",roles:["admin"]},s=e.socket.accept(n(e),a);e.simulateRecycle();const i=e.restoreSocket(e.socket.idFor(s),a);t(i.deserializeAttachment()).toEqual(a),e.cleanup?.()}),o("returns exactly the sockets carrying an accept-time tag",async()=>{t.assertions(4);const e=await r(),a=e.socket.accept(n(e),{},["room-a"]),s=e.socket.accept(n(e),{},["room-b"]),i=e.socket.accept(n(e),{}),c=p=>e.socket.idFor(p);t(e.socket.getSockets("room-a").map(c)).toStrictEqual([c(a)]),t(e.socket.getSockets("room-b").map(c)).toStrictEqual([c(s)]),t(e.socket.getSockets("room-c").map(c)).toStrictEqual([]),t(e.socket.getSockets().map(c)).toContain(c(i)),e.cleanup?.()}),o("resolves a raw socket back to its handle",async()=>{t.assertions(2);const e=await r(),a=n(e),s=e.socket.accept(a,{}),i=e.socket.handleFor(a);t(i!==void 0&&e.socket.idFor(i)).toBe(e.socket.idFor(s)),t(e.socket.handleFor(n(e))).toBeUndefined(),e.cleanup?.()}),o("reports a plausible outbound queue depth, if any",async()=>{t.assertions(1);const e=await r(),a=e.socket.accept(n(e),{}),{bufferedAmount:s}=a;t(s===void 0||typeof s=="number"&&s>=0).toBe(!0),e.cleanup?.()}),o("retags a live socket when the host declares mutable tags",async()=>{t.assertions(2);const e=await r();if(e.socket.setTag===void 0){t(e.socket.removeTag).toBeUndefined(),t(!0).toBe(!0),e.cleanup?.();return}const a=e.socket.accept(n(e),{});e.socket.setTag(a,"room-a"),t(e.socket.getSockets("room-a").map(s=>e.socket.idFor(s))).toStrictEqual([e.socket.idFor(a)]),e.socket.removeTag?.(a,"room-a"),t(e.socket.getSockets("room-a").map(s=>e.socket.idFor(s))).toStrictEqual([]),e.cleanup?.()})}),d("ShardDirectory",()=>{o("resolves shard keys deterministically",async()=>{t.assertions(1);const e=await r(),a=await l(e.directory,"tenant-42").fetch(new Request("http://localhost/")),s=await l(e.directory,"tenant-42").fetch(new Request("http://localhost/"));await t(a.text()).resolves.toBe(await s.text()),e.cleanup?.()}),o("dispatches fetch to a resolved stub",async()=>{t.assertions(1);const e=await r(),a=await l(e.directory,"tenant-42").fetch(new Request("http://localhost/"));t(a).toBeInstanceOf(Response),e.cleanup?.()})}),d("SchedulerHost",()=>{o("schedules a job for a future timestamp",async()=>{t.assertions(2);const e=await r();if(e.scheduler===void 0){t(e.scheduler).toBeUndefined(),t(!0).toBe(!0),e.cleanup?.();return}const a=Date.now(),s=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:50});t(s.scheduledFor).toBeGreaterThanOrEqual(a+50),t(s.id).toBeDefined(),e.cleanup?.()}),o("cancels a scheduled job",async()=>{t.assertions(1);const e=await r();if(e.scheduler===void 0){t(e.scheduler).toBeUndefined(),e.cleanup?.();return}const a=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4}),s=await e.scheduler.cancel(a.id);t(s).toBe(!0),e.cleanup?.()}),o("reports a second cancel of the same job as false",async()=>{t.assertions(2);const e=await r();if(e.scheduler===void 0){t(e.scheduler).toBeUndefined(),t(!0).toBe(!0),e.cleanup?.();return}const a=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});t(await e.scheduler.cancel(a.id)).toBe(!0),t(await e.scheduler.cancel(a.id)).toBe(!1),e.cleanup?.()}),o("gives two identical schedules independently cancellable ids",async()=>{t.assertions(3);const e=await r();if(e.scheduler===void 0){t(e.scheduler).toBeUndefined(),t(!0).toBe(!0),t(!0).toBe(!0),e.cleanup?.();return}const a=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4}),s=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4});t(s.id).not.toBe(a.id),t(await e.scheduler.cancel(a.id)).toBe(!0),t(await e.scheduler.cancel(s.id)).toBe(!0),e.cleanup?.()}),o("lists a pending job with a zero attempt count",async()=>{t.assertions(2);const e=await r();if(e.scheduler?.list===void 0){t(e.scheduler?.list).toBeUndefined(),t(!0).toBe(!0),e.cleanup?.();return}const a=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4}),s=(await e.scheduler.list()).find(i=>i.id===a.id);t(s?.functionPath).toBe("tasks/remind"),t(s?.attempts).toBe(0),e.cleanup?.()}),o("keeps the pending and dead-letter listings disjoint",async()=>{t.assertions(2);const e=await r();if(e.scheduler?.list===void 0||e.scheduler.deadLetter===void 0||e.simulateDeadLetter===void 0){t(e.simulateDeadLetter).toBeUndefined(),t(!0).toBe(!0),e.cleanup?.();return}const a=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});await e.simulateDeadLetter(a.id);const s=await e.scheduler.list(),i=await e.scheduler.deadLetter.list();t(s.some(c=>c.id===a.id)).toBe(!1),t(i.some(c=>c.id===a.id)).toBe(!0),e.cleanup?.()}),o("returns a requeued job to the pending set with a fresh budget",async()=>{t.assertions(4);const e=await r();if(e.scheduler?.list===void 0||e.scheduler.deadLetter===void 0||e.simulateDeadLetter===void 0){t(e.simulateDeadLetter).toBeUndefined(),t(!0).toBe(!0),t(!0).toBe(!0),t(!0).toBe(!0),e.cleanup?.();return}const a=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});await e.simulateDeadLetter(a.id),t(await e.scheduler.deadLetter.requeue(a.id)).toBe(!0);const s=(await e.scheduler.list()).find(c=>c.id===a.id);t(s).toBeDefined(),t(s?.attempts).toBe(0);const i=await e.scheduler.deadLetter.list();t(i.some(c=>c.id===a.id)).toBe(!1),e.cleanup?.()}),o("reports a requeue of an unparked job as false",async()=>{t.assertions(1);const e=await r();if(e.scheduler?.deadLetter===void 0){t(e.scheduler?.deadLetter).toBeUndefined(),e.cleanup?.();return}t(await e.scheduler.deadLetter.requeue("job-does-not-exist")).toBe(!1),e.cleanup?.()})}),d("ShardKvStore",()=>{o("reads back a written value",async()=>{t.assertions(2);const e=await r();if(e.kv===void 0){t(e.kv).toBeUndefined(),t(!0).toBe(!0),e.cleanup?.();return}await e.kv.put("s:token-1",{userId:"ada"}),t(await e.kv.get("s:token-1")).toEqual({userId:"ada"}),t(await e.kv.get("s:missing")).toBeUndefined(),e.cleanup?.()}),o("deletes a key idempotently",async()=>{t.assertions(3);const e=await r();if(e.kv===void 0){t(e.kv).toBeUndefined(),t(!0).toBe(!0),t(!0).toBe(!0),e.cleanup?.();return}await e.kv.put("k",1),t(await e.kv.delete("k")).toBe(!0),t(await e.kv.delete("k")).toBe(!1),t(await e.kv.get("k")).toBeUndefined(),e.cleanup?.()}),o("enumerates exactly the keys under a prefix",async()=>{t.assertions(2);const e=await r();if(e.kv===void 0){t(e.kv).toBeUndefined(),t(!0).toBe(!0),e.cleanup?.();return}await e.kv.put("s:a",1),await e.kv.put("s:b",2),await e.kv.put("other",3);const a=await e.kv.list({prefix:"s:"}),s=await e.kv.list();t([...a.keys()].toSorted((i,c)=>i.localeCompare(c))).toStrictEqual(["s:a","s:b"]),t(s.size).toBe(3),e.cleanup?.()})})})};export{m as defineHostContractSuite};
|
|
1
|
+
import{resolveShard as h}from"../packem_shared/resolveShard-BPCXp8pD.mjs";const E=(n,m,k)=>{const{describe:u,expect:t,it:i}=k;u(`host contract: ${n}`,()=>{const w=async()=>m(),l=s=>s.createSocket?.()??{},c=async s=>{const e=await w();try{await s(e)}finally{e.cleanup?.()}};u("ShardHost",()=>{i("serializes mutations so no two closures interleave",async()=>{t.assertions(1),await c(async s=>{const e=[];await Promise.all([s.shard.runSerialized(async()=>{e.push("a-start"),await new Promise(r=>{setTimeout(r,10)}),e.push("a-end")}),s.shard.runSerialized(async()=>{e.push("b-start"),await new Promise(r=>{setTimeout(r,5)}),e.push("b-end")})]);const a=e.join("").includes("a-starta-end"),o=e.join("").includes("b-startb-end");t(a&&o).toBe(!0)})}),i("rolls back a transaction that throws",async()=>{t.assertions(2),await c(async s=>{await s.shard.transaction(async()=>{s.shard.sql.exec("CREATE TABLE IF NOT EXISTS rollback_test (id INTEGER PRIMARY KEY)"),s.shard.sql.exec("INSERT INTO rollback_test (id) VALUES (1)")}),await t(s.shard.transaction(async()=>{throw s.shard.sql.exec("INSERT INTO rollback_test (id) VALUES (2)"),new Error("boom")})).rejects.toThrow("boom");const e=s.shard.sql.exec("SELECT id FROM rollback_test WHERE id = 2").toArray();t(e).toHaveLength(0)})}),i("rejects with the value the closure threw, and stays usable",async()=>{t.assertions(3),await c(async s=>{const e=Object.assign(new Error("closure failed"),{code:"NOT_FOUND",status:404});await t(s.shard.transaction(()=>Promise.reject(e))).rejects.toBe(e),await t(s.shard.runSerialized(()=>Promise.reject(e))).rejects.toBe(e),await t(s.shard.runSerialized(async()=>s.shard.transaction(async()=>"still here"))).resolves.toBe("still here")})}),i("observes its own writes inside a transaction",async()=>{t.assertions(1),await c(async s=>{const e=await s.shard.transaction(async()=>(s.shard.sql.exec("CREATE TABLE IF NOT EXISTS ryw_test (id INTEGER PRIMARY KEY, value TEXT)"),s.shard.sql.exec("INSERT INTO ryw_test (id, value) VALUES (1, 'hello')"),s.shard.sql.exec("SELECT value FROM ryw_test WHERE id = 1").toArray()[0]?.value));t(e).toBe("hello")})}),i("keeps overlapping transactions atomic",async()=>{t.assertions(2),await c(async s=>{s.shard.sql.exec("CREATE TABLE IF NOT EXISTS overlap_test (id TEXT PRIMARY KEY)");const e=s.shard.transaction(async()=>{s.shard.sql.exec("INSERT INTO overlap_test (id) VALUES ('A')"),await new Promise(r=>{setTimeout(r,20)}),s.shard.sql.exec("INSERT INTO overlap_test (id) VALUES ('B')")}),a=s.shard.transaction(async()=>{s.shard.sql.exec("INSERT INTO overlap_test (id) VALUES ('C')")});await Promise.all([e,a]);const o=s.shard.sql.exec("SELECT id FROM overlap_test ORDER BY id").toArray();t(o.map(r=>r.id)).toStrictEqual(["A","B","C"]),await t(s.shard.transaction(async()=>{throw s.shard.sql.exec("INSERT INTO overlap_test (id) VALUES ('D')"),new Error("boom")})).rejects.toThrow("boom")})}),i("returns a cursor that buffers, yields one row, and iterates",async()=>{t.assertions(3),await c(async s=>{await s.shard.transaction(async()=>{s.shard.sql.exec("CREATE TABLE IF NOT EXISTS cursor_test (id INTEGER PRIMARY KEY)"),s.shard.sql.exec("INSERT INTO cursor_test (id) VALUES (1)"),s.shard.sql.exec("INSERT INTO cursor_test (id) VALUES (2)")}),t(s.shard.sql.exec("SELECT id FROM cursor_test ORDER BY id").toArray()).toHaveLength(2),t(s.shard.sql.exec("SELECT id FROM cursor_test WHERE id = 1").one()).toEqual({id:1}),t([...s.shard.sql.exec("SELECT id FROM cursor_test ORDER BY id")]).toHaveLength(2)})}),i("reports a pending alarm, and clears it once fired",async()=>{t.assertions(2),await c(async s=>{const e=Date.now()+50;if(await s.shard.alarms.set(e),t(await s.shard.alarms.get()).toBe(e),s.awaitAlarmFired===void 0){t(await s.shard.alarms.get()).toBe(e);return}await s.awaitAlarmFired(e),t(await s.shard.alarms.get()).toBeNull()})}),i("deletes a pending alarm",async()=>{t.assertions(1),await c(async s=>{await s.shard.alarms.set(Date.now()+1e4),await s.shard.alarms.delete(),t(await s.shard.alarms.get()).toBeNull()})})}),u("SocketHost",()=>{i("accepts a socket and can send/close",async()=>{await c(async s=>{const e=s.socket.accept(l(s),{user:"ada"});t(s.socket.idFor(e)).toBeDefined(),e.send("hello"),t(s.socket.getSockets().map(a=>s.socket.idFor(a))).toContain(s.socket.idFor(e)),s.readFrames!==void 0&&t(s.readFrames(e)).toStrictEqual(["hello"]),t(()=>{e.close(1e3,"done")}).not.toThrow()})}),i("round-trips an attachment on a live socket",async()=>{t.assertions(1),await c(async s=>{const e={roomId:"room-1",roles:["admin"]},a=s.socket.accept(l(s),e);t(a.deserializeAttachment()).toEqual(e)})}),i("round-trips attachments across a recycle",async s=>{await c(async e=>{if(e.simulateRecycle===void 0||e.restoreSocket===void 0){s.skip(`${n} does not implement simulateRecycle/restoreSocket`);return}t.assertions(1);const a={roomId:"room-1",roles:["admin"]},o=e.socket.accept(l(e),a);e.simulateRecycle();const r=e.restoreSocket(e.socket.idFor(o),a);t(r.deserializeAttachment()).toEqual(a)})}),i("keeps idFor stable across repeated calls within a wake",async()=>{t.assertions(1),await c(async s=>{const e=s.socket.accept(l(s),{}),a=s.socket.idFor(e),o=s.socket.idFor(e);t(o).toBe(a)})}),i("keeps idFor stable across a recycle",async s=>{await c(async e=>{if(e.simulateRecycle===void 0||e.restoreSocket===void 0){s.skip(`${n} does not implement simulateRecycle/restoreSocket`);return}t.assertions(1);const a={roomId:"room-1",roles:["admin"]},o=e.socket.accept(l(e),a),r=e.socket.idFor(o);e.simulateRecycle();const d=e.restoreSocket(r,a);t(e.socket.idFor(d)).toBe(r)})}),i("returns exactly the sockets carrying an accept-time tag",async()=>{t.assertions(4),await c(async s=>{const e=s.socket.accept(l(s),{},["room-a"]),a=s.socket.accept(l(s),{},["room-b"]),o=s.socket.accept(l(s),{}),r=d=>s.socket.idFor(d);t(s.socket.getSockets("room-a").map(r)).toStrictEqual([r(e)]),t(s.socket.getSockets("room-b").map(r)).toStrictEqual([r(a)]),t(s.socket.getSockets("room-c").map(r)).toStrictEqual([]),t(s.socket.getSockets().map(r)).toContain(r(o))})}),i("accepts the portable budget of nine caller tags",async()=>{t.assertions(9);const s=await w(),e=Array.from({length:9},(r,d)=>`tag-${String(d)}`),a=s.socket.accept(l(s),{},e),o=r=>s.socket.idFor(r);for(const r of e)t(s.socket.getSockets(r).map(o)).toStrictEqual([o(a)]);s.cleanup?.()}),i("resolves a raw socket back to its handle",async()=>{t.assertions(2),await c(async s=>{const e=l(s),a=s.socket.accept(e,{}),o=s.socket.handleFor(e);t(o!==void 0&&s.socket.idFor(o)).toBe(s.socket.idFor(a)),t(s.socket.handleFor(l(s))).toBeUndefined()})}),i("reports a plausible outbound queue depth, if any",async()=>{t.assertions(1),await c(async s=>{const e=s.socket.accept(l(s),{}),{bufferedAmount:a}=e;t(a===void 0||typeof a=="number"&&a>=0).toBe(!0)})}),i("retags a live socket when the host declares mutable tags",async s=>{await c(async e=>{if(e.socket.setTag===void 0){s.skip(`${n} does not implement mutable socket tags (setTag)`);return}t.assertions(2);const a=e.socket.accept(l(e),{});e.socket.setTag(a,"room-a"),t(e.socket.getSockets("room-a").map(o=>e.socket.idFor(o))).toStrictEqual([e.socket.idFor(a)]),e.socket.removeTag?.(a,"room-a"),t(e.socket.getSockets("room-a").map(o=>e.socket.idFor(o))).toStrictEqual([])})})}),u("ShardDirectory",()=>{i("resolves shard keys deterministically",async()=>{t.assertions(1),await c(async s=>{const e=await h(s.directory,"tenant-42").fetch(new Request("http://localhost/")),a=await h(s.directory,"tenant-42").fetch(new Request("http://localhost/"));await t(e.text()).resolves.toBe(await a.text())})}),i("dispatches fetch to a resolved stub",async()=>{t.assertions(1),await c(async s=>{const e=await h(s.directory,"tenant-42").fetch(new Request("http://localhost/"));t(e).toBeInstanceOf(Response)})})}),u("SchedulerHost",()=>{i("schedules a job for a future timestamp",async s=>{await c(async e=>{if(e.scheduler===void 0){s.skip(`${n} does not implement SchedulerHost`);return}t.assertions(2);const a=Date.now(),o=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:50});t(o.scheduledFor).toBeGreaterThanOrEqual(a+50),t(o.id).toBeDefined()})}),i("dispatches a scheduled job at least once",async s=>{await c(async e=>{if(e.scheduler===void 0){s.skip(`${n} does not implement SchedulerHost`);return}const a=e.scheduler.deadLetter!==void 0;if(!a&&e.awaitJobDispatched===void 0){s.skip(`${n} does not claim at-least-once delivery (no deadLetter) and supplies no awaitJobDispatched hook`);return}if(a&&t(e.awaitJobDispatched).toBeDefined(),e.awaitJobDispatched===void 0)return;const o=e.scheduler.list!==void 0;t.assertions(1+(a?1:0)+(o?1:0));const r=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:30});if(await t(e.awaitJobDispatched(r.id)).resolves.toBe(!0),o){const d=await e.scheduler.list?.();t(d?.some(y=>y.id===r.id)).toBe(!1)}})}),i("cancels a scheduled job",async s=>{await c(async e=>{if(e.scheduler===void 0){s.skip(`${n} does not implement SchedulerHost`);return}t.assertions(1);const a=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4}),o=await e.scheduler.cancel(a.id);t(o).toBe(!0)})}),i("reports a second cancel of the same job as false",async s=>{await c(async e=>{if(e.scheduler===void 0){s.skip(`${n} does not implement SchedulerHost`);return}t.assertions(2);const a=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});t(await e.scheduler.cancel(a.id)).toBe(!0),t(await e.scheduler.cancel(a.id)).toBe(!1)})}),i("gives two identical schedules independently cancellable ids",async s=>{await c(async e=>{if(e.scheduler===void 0){s.skip(`${n} does not implement SchedulerHost`);return}t.assertions(3);const a=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4}),o=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4});t(o.id).not.toBe(a.id),t(await e.scheduler.cancel(a.id)).toBe(!0),t(await e.scheduler.cancel(o.id)).toBe(!0)})}),i("lists a pending job with a zero attempt count",async s=>{await c(async e=>{if(e.scheduler?.list===void 0){s.skip(`${n} does not implement SchedulerHost.list`);return}t.assertions(2);const a=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4}),o=(await e.scheduler.list()).find(r=>r.id===a.id);t(o?.functionPath).toBe("tasks/remind"),t(o?.attempts).toBe(0)})}),i("keeps the pending and dead-letter listings disjoint",async s=>{await c(async e=>{if(e.scheduler?.list===void 0||e.scheduler.deadLetter===void 0||e.simulateDeadLetter===void 0){s.skip(`${n} does not implement scheduler.list/deadLetter/simulateDeadLetter`);return}t.assertions(2);const a=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});await e.simulateDeadLetter(a.id);const o=await e.scheduler.list(),r=await e.scheduler.deadLetter.list();t(o.some(d=>d.id===a.id)).toBe(!1),t(r.some(d=>d.id===a.id)).toBe(!0)})}),i("returns a requeued job to the pending set with a fresh budget",async s=>{await c(async e=>{if(e.scheduler?.list===void 0||e.scheduler.deadLetter===void 0||e.simulateDeadLetter===void 0){s.skip(`${n} does not implement scheduler.list/deadLetter/simulateDeadLetter`);return}t.assertions(4);const a=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});await e.simulateDeadLetter(a.id),t(await e.scheduler.deadLetter.requeue(a.id)).toBe(!0);const o=(await e.scheduler.list()).find(d=>d.id===a.id);t(o).toBeDefined(),t(o?.attempts).toBe(0);const r=await e.scheduler.deadLetter.list();t(r.some(d=>d.id===a.id)).toBe(!1)})}),i("reports a requeue of an unparked job as false",async s=>{await c(async e=>{if(e.scheduler?.deadLetter===void 0){s.skip(`${n} does not implement scheduler.deadLetter`);return}t.assertions(1),t(await e.scheduler.deadLetter.requeue("job-does-not-exist")).toBe(!1)})})}),u("ShardKvStore",()=>{i("reads back a written value",async s=>{await c(async e=>{if(e.kv===void 0){s.skip(`${n} does not implement ShardKvStore`);return}t.assertions(2),await e.kv.put("s:token-1",{userId:"ada"}),t(await e.kv.get("s:token-1")).toEqual({userId:"ada"}),t(await e.kv.get("s:missing")).toBeUndefined()})}),i("deletes a key idempotently",async s=>{await c(async e=>{if(e.kv===void 0){s.skip(`${n} does not implement ShardKvStore`);return}t.assertions(3),await e.kv.put("k",1),t(await e.kv.delete("k")).toBe(!0),t(await e.kv.delete("k")).toBe(!1),t(await e.kv.get("k")).toBeUndefined()})}),i("enumerates exactly the keys under a prefix",async s=>{await c(async e=>{if(e.kv===void 0){s.skip(`${n} does not implement ShardKvStore`);return}t.assertions(2),await e.kv.put("s:a",1),await e.kv.put("s:b",2),await e.kv.put("other",3);const a=await e.kv.list({prefix:"s:"}),o=await e.kv.list();t([...a.keys()].toSorted((r,d)=>r.localeCompare(d))).toStrictEqual(["s:a","s:b"]),t(o.size).toBe(3)})})})})};export{E as defineHostContractSuite};
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type D as DirectShardDirectory, type S as ScheduleOptions, type a as ScheduledJob, type b as ScheduledJobStatus, type c as SchedulerHost, type d as ShardAlarms, type e as ShardAsyncSqlExec, type f as ShardDirectory, type g as ShardHost, type h as ShardJurisdiction, type i as ShardKvListOptions, type j as ShardKvStore, type k as
|
|
1
|
+
export { type D as DirectShardDirectory, type S as ScheduleOptions, type a as ScheduledJob, type b as ScheduledJobStatus, type c as SchedulerHost, type d as ShardAlarms, type e as ShardAsyncSqlExec, type f as ShardDirectory, type g as ShardHost, type h as ShardJurisdiction, type i as ShardKvListOptions, type j as ShardKvStore, type k as ShardRegionHint, type l as ShardSqlCursor, type m as ShardSqlExec, type n as ShardStub, type o as SocketHandle, type p as SocketHost, type q as SqlRow, type T as TwoStepShardDirectory, r as resolveShard } from "./packem_shared/socket-host.d-Dy2QiVn6.mjs";
|
|
2
2
|
/**
|
|
3
3
|
* The subset of the Cloudflare `ExecutionContext` the Lunora worker entry and
|
|
4
4
|
* the framework mount seams rely on — `waitUntil` for fire-and-forget work that
|
|
@@ -317,11 +317,12 @@ interface R2BucketLike {
|
|
|
317
317
|
objects: R2ObjectLike[];
|
|
318
318
|
truncated?: boolean;
|
|
319
319
|
}>;
|
|
320
|
-
put: (key: string, body: ReadableStream | ArrayBuffer | Blob | string | null, options?: {
|
|
320
|
+
put: (key: string, body: ReadableStream | ArrayBuffer | ArrayBufferView | Blob | string | null, options?: {
|
|
321
321
|
customMetadata?: Record<string, string>;
|
|
322
322
|
httpMetadata?: {
|
|
323
323
|
contentType?: string;
|
|
324
324
|
};
|
|
325
|
+
sha256?: ArrayBuffer | string;
|
|
325
326
|
}) => Promise<R2ObjectLike>;
|
|
326
327
|
/** Resume an in-progress multipart upload by id (R2 `resumeMultipartUpload`). Optional; see {@link Storage.resumeMultipartUpload}. */
|
|
327
328
|
resumeMultipartUpload?: (key: string, uploadId: string) => R2MultipartUploadLike;
|
|
@@ -417,15 +418,15 @@ interface MessageSendRequestLike<Body = unknown> {
|
|
|
417
418
|
delaySeconds?: number;
|
|
418
419
|
}
|
|
419
420
|
/**
|
|
420
|
-
* Minimal structural projection of workers-types' `Queue
|
|
421
|
+
* Minimal structural projection of workers-types' `Queue<Body>` (the producer
|
|
421
422
|
* binding). The real binding's `send`/`sendBatch` resolve to a metadata object;
|
|
422
|
-
* we widen the return to `Promise
|
|
423
|
+
* we widen the return to `Promise<unknown>` so a plain-object fake satisfies it.
|
|
423
424
|
*/
|
|
424
425
|
interface QueueBindingLike<Body = unknown> {
|
|
425
426
|
send: (message: Body, options?: QueueSendOptions) => Promise<unknown>;
|
|
426
427
|
sendBatch: (messages: Iterable<MessageSendRequestLike<Body>>, options?: QueueSendBatchOptions) => Promise<unknown>;
|
|
427
428
|
}
|
|
428
|
-
/** Structural mirror of workers-types' `Message
|
|
429
|
+
/** Structural mirror of workers-types' `Message<Body>` (one delivered message). */
|
|
429
430
|
interface MessageLike<Body = unknown> {
|
|
430
431
|
/** Acknowledge this message so it is not redelivered. */
|
|
431
432
|
ack: () => void;
|
|
@@ -436,7 +437,7 @@ interface MessageLike<Body = unknown> {
|
|
|
436
437
|
retry: (options?: QueueRetryOptions) => void;
|
|
437
438
|
readonly timestamp: Date;
|
|
438
439
|
}
|
|
439
|
-
/** Structural mirror of workers-types' `MessageBatch
|
|
440
|
+
/** Structural mirror of workers-types' `MessageBatch<Body>` handed to a consumer. */
|
|
440
441
|
interface MessageBatchLike<Body = unknown> {
|
|
441
442
|
/** Acknowledge every message in the batch. */
|
|
442
443
|
ackAll: () => void;
|
|
@@ -485,6 +486,12 @@ interface PlatformCapabilities {
|
|
|
485
486
|
containers?: Capability;
|
|
486
487
|
/** Cross-shard fan-out queries. */
|
|
487
488
|
crossShardFanout?: Capability;
|
|
489
|
+
/**
|
|
490
|
+
* Durable streams: a `.stream()` run whose chunks are persisted and
|
|
491
|
+
* whose producer outlives the socket that opened it, so a reconnecting
|
|
492
|
+
* or second client resumes the same transcript.
|
|
493
|
+
*/
|
|
494
|
+
durableStreams?: Capability;
|
|
488
495
|
/** Global (replicated) tables backed by a SQL store. */
|
|
489
496
|
globalTables?: Capability;
|
|
490
497
|
/** BYO database via connection pooling (Hyperdrive / RDS Proxy). */
|
|
@@ -497,6 +504,16 @@ interface PlatformCapabilities {
|
|
|
497
504
|
mail?: Capability;
|
|
498
505
|
/** Object storage (R2 / S3 / MinIO). */
|
|
499
506
|
objectStorage?: Capability;
|
|
507
|
+
/**
|
|
508
|
+
* Snapshot backups kept in object storage rather than on the machine
|
|
509
|
+
* that took them — `lunora backup create|list|restore --bucket`, and
|
|
510
|
+
* the platform's own `backupCron`. Distinct from
|
|
511
|
+
* `objectStorage` above because it needs three things a
|
|
512
|
+
* bucket alone does not imply: an admin-gated read of one object
|
|
513
|
+
* (`GET /_lunora/admin/storage/object`), a checksum-verified write, and
|
|
514
|
+
* a scheduler to run the unattended half.
|
|
515
|
+
*/
|
|
516
|
+
objectStorageBackups?: Capability;
|
|
500
517
|
/** Pipelines / streaming data. */
|
|
501
518
|
pipelines?: Capability;
|
|
502
519
|
/** Queue-backed workpools. */
|
|
@@ -509,6 +526,10 @@ interface PlatformCapabilities {
|
|
|
509
526
|
shardAlarms?: Capability;
|
|
510
527
|
/** Durable Object-style sharded state. */
|
|
511
528
|
shardedState?: Capability;
|
|
529
|
+
/** Geographic placement of a shard (`ShardPlacement.locationHint`). */
|
|
530
|
+
shardPlacement?: Capability;
|
|
531
|
+
/** Region-local read replicas of a shard, for one-shot queries. */
|
|
532
|
+
shardReadReplicas?: Capability;
|
|
512
533
|
/** Vector database (Vectorize / pgvector / Pinecone). */
|
|
513
534
|
vectorStore?: Capability;
|
|
514
535
|
/** Hibernated WebSocket subscriptions. */
|
|
@@ -531,4 +552,48 @@ interface PlatformCapabilities {
|
|
|
531
552
|
* itself must not be reported as native even when it works flawlessly.
|
|
532
553
|
*/
|
|
533
554
|
declare const CLOUDFLARE_CAPABILITIES: PlatformCapabilities;
|
|
534
|
-
|
|
555
|
+
/**
|
|
556
|
+
* The Node capability matrix — `@lunora/platform-node`'s honest self-rating
|
|
557
|
+
* (plan 234).
|
|
558
|
+
*
|
|
559
|
+
* `@lunora/platform-node` implements every contract in this package
|
|
560
|
+
* (`ShardHost`, `SocketHost`, `ShardDirectory`, `ShardKvStore`,
|
|
561
|
+
* `SchedulerHost`) over `better-sqlite3` and an in-process registry, plus the
|
|
562
|
+
* `.global()` table backend via `@lunora/sql-store`. It began as a spike to run
|
|
563
|
+
* the conformance TCK against a second host; the durability gaps that spike
|
|
564
|
+
* surfaced — alarms and scheduler jobs that were persisted but never re-armed,
|
|
565
|
+
* socket attachments that lived only in memory — are closed, and each is now
|
|
566
|
+
* pinned by a restart test rather than only by a simulated recycle.
|
|
567
|
+
*
|
|
568
|
+
* `scheduler` and `shardAlarms` were rated `"unsupported"` under plan 267, on
|
|
569
|
+
* the grounds that the host stored and timed both while its timer body only
|
|
570
|
+
* cleared bookkeeping — nothing dispatched the scheduled function or woke the
|
|
571
|
+
* alarm. That rating was correct for the code it described, and the code is
|
|
572
|
+
* what changed: both now dispatch (through `onDispatch` / `onAlarm`) and both
|
|
573
|
+
* re-arm from their durable rows on construction, so `"emulated"` — built on
|
|
574
|
+
* lower-level primitives and *working* — is now the honest reading.
|
|
575
|
+
*
|
|
576
|
+
* What remains genuinely absent is everything a single Node process cannot
|
|
577
|
+
* distribute: placement across nodes, failover, and most Cloudflare-specific
|
|
578
|
+
* product bindings (Vectorize, Workers AI, Containers, Browser Rendering,
|
|
579
|
+
* Analytics Engine, Secrets Store, Hyperdrive). Workflows, object storage and
|
|
580
|
+
* queues are the three that CAN be emulated locally — `defineWorkflow` handlers
|
|
581
|
+
* compile onto the `@visulima/workflow` engine, R2 becomes a filesystem bucket,
|
|
582
|
+
* and Queues becomes a durable table with the same batch/ack/retry/dead-letter
|
|
583
|
+
* semantics — so those three are rated `"emulated"`; the rest of the Cloudflare
|
|
584
|
+
* products most `ctx.*` surfaces are built on are rated `"unsupported"` here
|
|
585
|
+
* rather than left undeclared — see `gateAgainstMatrix` in `@lunora/codegen`,
|
|
586
|
+
* whose fail-closed gate (plan 229) treats an undeclared feature as unsupported
|
|
587
|
+
* anyway, but under a different diagnostic name than an honest, explicit rating.
|
|
588
|
+
*
|
|
589
|
+
* Almost nothing here is rated `"native"`, and that is the matrix's own
|
|
590
|
+
* definition doing its job rather than a hedge: `native` means the platform
|
|
591
|
+
* itself provides the feature, and a bare Node process provides essentially
|
|
592
|
+
* none of them — Lunora builds alarms out of `setTimeout` plus a durable row,
|
|
593
|
+
* a KV store out of a SQL table, and `.global()` tables out of a second SQLite
|
|
594
|
+
* file. `localSql` is the exception, because SQLite genuinely is the platform
|
|
595
|
+
* primitive there. The ratings say who does the work; the notes say how well.
|
|
596
|
+
* Both are argued in detail in `plans/234-node-host-findings.md`.
|
|
597
|
+
*/
|
|
598
|
+
declare const NODE_CAPABILITIES: PlatformCapabilities;
|
|
599
|
+
export { type AnalyticsEngineDataPoint, type AnalyticsEngineDataPointLike, type AnalyticsEngineDatasetLike, CLOUDFLARE_CAPABILITIES, type Capability, type CapabilityLevel, type D1DatabaseLike, type D1PreparedStatementLike, type D1SessionLike, type ExecutionContextLike, type KVNamespaceLike, type KvGetOptions, type KvListKey, type KvNamespaceListResult, type KvNamespacePutOptions, type KvValue, type KvValueType, type MessageBatchLike, type MessageLike, type MessageSendRequestLike, NODE_CAPABILITIES, NOOP_EXECUTION_CONTEXT, type PlatformCapabilities, type QueueBindingLike, type QueueContentType, type QueueMessageLike, type QueueRetryOptions, type QueueSendBatchOptions, type QueueSendOptions, type QueueSendOptionsLike, type QueueSendRequestLike, type R2BucketLike, type R2MultipartUploadLike, type R2ObjectBodyLike, type R2ObjectLike, type R2RangeLike, type R2UploadedPartLike, type VectorMatchLike, type VectorMetric, type VectorRecordLike, type VectorizeDeleteMutation, type VectorizeIndexDetails, type VectorizeIndexLike, type VectorizeMatch, type VectorizeMatches, type VectorizeQueryOptions, type VectorizeUpsertMutation, type VectorizeVector };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type D as DirectShardDirectory, type S as ScheduleOptions, type a as ScheduledJob, type b as ScheduledJobStatus, type c as SchedulerHost, type d as ShardAlarms, type e as ShardAsyncSqlExec, type f as ShardDirectory, type g as ShardHost, type h as ShardJurisdiction, type i as ShardKvListOptions, type j as ShardKvStore, type k as
|
|
1
|
+
export { type D as DirectShardDirectory, type S as ScheduleOptions, type a as ScheduledJob, type b as ScheduledJobStatus, type c as SchedulerHost, type d as ShardAlarms, type e as ShardAsyncSqlExec, type f as ShardDirectory, type g as ShardHost, type h as ShardJurisdiction, type i as ShardKvListOptions, type j as ShardKvStore, type k as ShardRegionHint, type l as ShardSqlCursor, type m as ShardSqlExec, type n as ShardStub, type o as SocketHandle, type p as SocketHost, type q as SqlRow, type T as TwoStepShardDirectory, r as resolveShard } from "./packem_shared/socket-host.d-Dy2QiVn6.js";
|
|
2
2
|
/**
|
|
3
3
|
* The subset of the Cloudflare `ExecutionContext` the Lunora worker entry and
|
|
4
4
|
* the framework mount seams rely on — `waitUntil` for fire-and-forget work that
|
|
@@ -317,11 +317,12 @@ interface R2BucketLike {
|
|
|
317
317
|
objects: R2ObjectLike[];
|
|
318
318
|
truncated?: boolean;
|
|
319
319
|
}>;
|
|
320
|
-
put: (key: string, body: ReadableStream | ArrayBuffer | Blob | string | null, options?: {
|
|
320
|
+
put: (key: string, body: ReadableStream | ArrayBuffer | ArrayBufferView | Blob | string | null, options?: {
|
|
321
321
|
customMetadata?: Record<string, string>;
|
|
322
322
|
httpMetadata?: {
|
|
323
323
|
contentType?: string;
|
|
324
324
|
};
|
|
325
|
+
sha256?: ArrayBuffer | string;
|
|
325
326
|
}) => Promise<R2ObjectLike>;
|
|
326
327
|
/** Resume an in-progress multipart upload by id (R2 `resumeMultipartUpload`). Optional; see {@link Storage.resumeMultipartUpload}. */
|
|
327
328
|
resumeMultipartUpload?: (key: string, uploadId: string) => R2MultipartUploadLike;
|
|
@@ -417,15 +418,15 @@ interface MessageSendRequestLike<Body = unknown> {
|
|
|
417
418
|
delaySeconds?: number;
|
|
418
419
|
}
|
|
419
420
|
/**
|
|
420
|
-
* Minimal structural projection of workers-types' `Queue
|
|
421
|
+
* Minimal structural projection of workers-types' `Queue<Body>` (the producer
|
|
421
422
|
* binding). The real binding's `send`/`sendBatch` resolve to a metadata object;
|
|
422
|
-
* we widen the return to `Promise
|
|
423
|
+
* we widen the return to `Promise<unknown>` so a plain-object fake satisfies it.
|
|
423
424
|
*/
|
|
424
425
|
interface QueueBindingLike<Body = unknown> {
|
|
425
426
|
send: (message: Body, options?: QueueSendOptions) => Promise<unknown>;
|
|
426
427
|
sendBatch: (messages: Iterable<MessageSendRequestLike<Body>>, options?: QueueSendBatchOptions) => Promise<unknown>;
|
|
427
428
|
}
|
|
428
|
-
/** Structural mirror of workers-types' `Message
|
|
429
|
+
/** Structural mirror of workers-types' `Message<Body>` (one delivered message). */
|
|
429
430
|
interface MessageLike<Body = unknown> {
|
|
430
431
|
/** Acknowledge this message so it is not redelivered. */
|
|
431
432
|
ack: () => void;
|
|
@@ -436,7 +437,7 @@ interface MessageLike<Body = unknown> {
|
|
|
436
437
|
retry: (options?: QueueRetryOptions) => void;
|
|
437
438
|
readonly timestamp: Date;
|
|
438
439
|
}
|
|
439
|
-
/** Structural mirror of workers-types' `MessageBatch
|
|
440
|
+
/** Structural mirror of workers-types' `MessageBatch<Body>` handed to a consumer. */
|
|
440
441
|
interface MessageBatchLike<Body = unknown> {
|
|
441
442
|
/** Acknowledge every message in the batch. */
|
|
442
443
|
ackAll: () => void;
|
|
@@ -485,6 +486,12 @@ interface PlatformCapabilities {
|
|
|
485
486
|
containers?: Capability;
|
|
486
487
|
/** Cross-shard fan-out queries. */
|
|
487
488
|
crossShardFanout?: Capability;
|
|
489
|
+
/**
|
|
490
|
+
* Durable streams: a `.stream()` run whose chunks are persisted and
|
|
491
|
+
* whose producer outlives the socket that opened it, so a reconnecting
|
|
492
|
+
* or second client resumes the same transcript.
|
|
493
|
+
*/
|
|
494
|
+
durableStreams?: Capability;
|
|
488
495
|
/** Global (replicated) tables backed by a SQL store. */
|
|
489
496
|
globalTables?: Capability;
|
|
490
497
|
/** BYO database via connection pooling (Hyperdrive / RDS Proxy). */
|
|
@@ -497,6 +504,16 @@ interface PlatformCapabilities {
|
|
|
497
504
|
mail?: Capability;
|
|
498
505
|
/** Object storage (R2 / S3 / MinIO). */
|
|
499
506
|
objectStorage?: Capability;
|
|
507
|
+
/**
|
|
508
|
+
* Snapshot backups kept in object storage rather than on the machine
|
|
509
|
+
* that took them — `lunora backup create|list|restore --bucket`, and
|
|
510
|
+
* the platform's own `backupCron`. Distinct from
|
|
511
|
+
* `objectStorage` above because it needs three things a
|
|
512
|
+
* bucket alone does not imply: an admin-gated read of one object
|
|
513
|
+
* (`GET /_lunora/admin/storage/object`), a checksum-verified write, and
|
|
514
|
+
* a scheduler to run the unattended half.
|
|
515
|
+
*/
|
|
516
|
+
objectStorageBackups?: Capability;
|
|
500
517
|
/** Pipelines / streaming data. */
|
|
501
518
|
pipelines?: Capability;
|
|
502
519
|
/** Queue-backed workpools. */
|
|
@@ -509,6 +526,10 @@ interface PlatformCapabilities {
|
|
|
509
526
|
shardAlarms?: Capability;
|
|
510
527
|
/** Durable Object-style sharded state. */
|
|
511
528
|
shardedState?: Capability;
|
|
529
|
+
/** Geographic placement of a shard (`ShardPlacement.locationHint`). */
|
|
530
|
+
shardPlacement?: Capability;
|
|
531
|
+
/** Region-local read replicas of a shard, for one-shot queries. */
|
|
532
|
+
shardReadReplicas?: Capability;
|
|
512
533
|
/** Vector database (Vectorize / pgvector / Pinecone). */
|
|
513
534
|
vectorStore?: Capability;
|
|
514
535
|
/** Hibernated WebSocket subscriptions. */
|
|
@@ -531,4 +552,48 @@ interface PlatformCapabilities {
|
|
|
531
552
|
* itself must not be reported as native even when it works flawlessly.
|
|
532
553
|
*/
|
|
533
554
|
declare const CLOUDFLARE_CAPABILITIES: PlatformCapabilities;
|
|
534
|
-
|
|
555
|
+
/**
|
|
556
|
+
* The Node capability matrix — `@lunora/platform-node`'s honest self-rating
|
|
557
|
+
* (plan 234).
|
|
558
|
+
*
|
|
559
|
+
* `@lunora/platform-node` implements every contract in this package
|
|
560
|
+
* (`ShardHost`, `SocketHost`, `ShardDirectory`, `ShardKvStore`,
|
|
561
|
+
* `SchedulerHost`) over `better-sqlite3` and an in-process registry, plus the
|
|
562
|
+
* `.global()` table backend via `@lunora/sql-store`. It began as a spike to run
|
|
563
|
+
* the conformance TCK against a second host; the durability gaps that spike
|
|
564
|
+
* surfaced — alarms and scheduler jobs that were persisted but never re-armed,
|
|
565
|
+
* socket attachments that lived only in memory — are closed, and each is now
|
|
566
|
+
* pinned by a restart test rather than only by a simulated recycle.
|
|
567
|
+
*
|
|
568
|
+
* `scheduler` and `shardAlarms` were rated `"unsupported"` under plan 267, on
|
|
569
|
+
* the grounds that the host stored and timed both while its timer body only
|
|
570
|
+
* cleared bookkeeping — nothing dispatched the scheduled function or woke the
|
|
571
|
+
* alarm. That rating was correct for the code it described, and the code is
|
|
572
|
+
* what changed: both now dispatch (through `onDispatch` / `onAlarm`) and both
|
|
573
|
+
* re-arm from their durable rows on construction, so `"emulated"` — built on
|
|
574
|
+
* lower-level primitives and *working* — is now the honest reading.
|
|
575
|
+
*
|
|
576
|
+
* What remains genuinely absent is everything a single Node process cannot
|
|
577
|
+
* distribute: placement across nodes, failover, and most Cloudflare-specific
|
|
578
|
+
* product bindings (Vectorize, Workers AI, Containers, Browser Rendering,
|
|
579
|
+
* Analytics Engine, Secrets Store, Hyperdrive). Workflows, object storage and
|
|
580
|
+
* queues are the three that CAN be emulated locally — `defineWorkflow` handlers
|
|
581
|
+
* compile onto the `@visulima/workflow` engine, R2 becomes a filesystem bucket,
|
|
582
|
+
* and Queues becomes a durable table with the same batch/ack/retry/dead-letter
|
|
583
|
+
* semantics — so those three are rated `"emulated"`; the rest of the Cloudflare
|
|
584
|
+
* products most `ctx.*` surfaces are built on are rated `"unsupported"` here
|
|
585
|
+
* rather than left undeclared — see `gateAgainstMatrix` in `@lunora/codegen`,
|
|
586
|
+
* whose fail-closed gate (plan 229) treats an undeclared feature as unsupported
|
|
587
|
+
* anyway, but under a different diagnostic name than an honest, explicit rating.
|
|
588
|
+
*
|
|
589
|
+
* Almost nothing here is rated `"native"`, and that is the matrix's own
|
|
590
|
+
* definition doing its job rather than a hedge: `native` means the platform
|
|
591
|
+
* itself provides the feature, and a bare Node process provides essentially
|
|
592
|
+
* none of them — Lunora builds alarms out of `setTimeout` plus a durable row,
|
|
593
|
+
* a KV store out of a SQL table, and `.global()` tables out of a second SQLite
|
|
594
|
+
* file. `localSql` is the exception, because SQLite genuinely is the platform
|
|
595
|
+
* primitive there. The ratings say who does the work; the notes say how well.
|
|
596
|
+
* Both are argued in detail in `plans/234-node-host-findings.md`.
|
|
597
|
+
*/
|
|
598
|
+
declare const NODE_CAPABILITIES: PlatformCapabilities;
|
|
599
|
+
export { type AnalyticsEngineDataPoint, type AnalyticsEngineDataPointLike, type AnalyticsEngineDatasetLike, CLOUDFLARE_CAPABILITIES, type Capability, type CapabilityLevel, type D1DatabaseLike, type D1PreparedStatementLike, type D1SessionLike, type ExecutionContextLike, type KVNamespaceLike, type KvGetOptions, type KvListKey, type KvNamespaceListResult, type KvNamespacePutOptions, type KvValue, type KvValueType, type MessageBatchLike, type MessageLike, type MessageSendRequestLike, NODE_CAPABILITIES, NOOP_EXECUTION_CONTEXT, type PlatformCapabilities, type QueueBindingLike, type QueueContentType, type QueueMessageLike, type QueueRetryOptions, type QueueSendBatchOptions, type QueueSendOptions, type QueueSendOptionsLike, type QueueSendRequestLike, type R2BucketLike, type R2MultipartUploadLike, type R2ObjectBodyLike, type R2ObjectLike, type R2RangeLike, type R2UploadedPartLike, type VectorMatchLike, type VectorMetric, type VectorRecordLike, type VectorizeDeleteMutation, type VectorizeIndexDetails, type VectorizeIndexLike, type VectorizeMatch, type VectorizeMatches, type VectorizeQueryOptions, type VectorizeUpsertMutation, type VectorizeVector };
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{NOOP_EXECUTION_CONTEXT as
|
|
1
|
+
import{NOOP_EXECUTION_CONTEXT as E}from"./packem_shared/NOOP_EXECUTION_CONTEXT-YmXqH-jH.mjs";import{CLOUDFLARE_CAPABILITIES as O,NODE_CAPABILITIES as e}from"./packem_shared/CLOUDFLARE_CAPABILITIES-DXNmuRst.mjs";import{resolveShard as C}from"./packem_shared/resolveShard-BPCXp8pD.mjs";export{O as CLOUDFLARE_CAPABILITIES,e as NODE_CAPABILITIES,E as NOOP_EXECUTION_CONTEXT,C as resolveShard};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e={id:"cloudflare",name:"Cloudflare",features:{shardedState:{level:"native",note:"Durable Objects with SQLite"},globalTables:{level:"native",note:"D1 with Sessions API"},websocketHibernation:{level:"native",note:"DO WebSocket hibernation"},durableStreams:{level:"emulated",note:"Lunora persists each chunk to the shard's SQLite under a monotonic seq and keeps the producer alive past the socket via waitUntil; the platform has no streaming primitive of its own, and a run whose DO is evicted mid-flight ends as STREAM_INTERRUPTED rather than resuming"},localSql:{level:"native",note:"state.storage.sql (SQLite)"},shardAlarms:{level:"native",note:"state.storage.setAlarm"},shardPlacement:{level:"native",note:"DurableObjectNamespace.get/getByName locationHint — best-effort, and honoured only by the resolution that creates the object"},shardReadReplicas:{level:"emulated",note:"Lunora follows the shard's CDC changelog into a replica DO placed in the reader's region; the platform replicates for durability, not for reads, so the follow loop is ours"},crossShardFanout:{level:"emulated",note:"Lunora query coordinator + relay tier over Durable Objects"},queues:{level:"native",note:"Cloudflare Queues"},workflows:{level:"native",note:"Cloudflare Workflows"},scheduler:{level:"emulated",note:"SchedulerDO (Lunora, on DO alarms) + declarative Cron Triggers; no runtime cron registration"},objectStorage:{level:"native",note:"R2"},objectStorageBackups:{level:"native",note:"`lunora backup create|list|restore --bucket` writes NDJSON snapshots + a manifest sidecar per snapshot through the admin storage routes (checksum-verified upload, admin-gated object read), and `backupCron`/`backupStore` runs the same layout unattended on a Cron Trigger. Both are bounded by what a single request body / a Worker isolate can hold, not by R2"},keyValueStore:{level:"native",note:"Workers KV"},vectorStore:{level:"native",note:"Vectorize; query/upsert namespace scoping is native (remote filter), but getByIds/deleteByIds id-path tenant isolation is facade-enforced (client-side verification) since Vectorize's id operations take no namespace option"},ai:{level:"native",note:"Workers AI"},browser:{level:"native",note:"Browser Rendering"},containers:{level:"native",note:"Cloudflare Containers"},analytics:{level:"native",note:"Analytics Engine"},pipelines:{level:"native",note:"Cloudflare Pipelines"},mail:{level:"emulated",note:"Resend (third-party) via Cloudflare Queues"},secrets:{level:"native",note:"Secrets Store"},hyperdrive:{level:"native",note:"Cloudflare Hyperdrive"}}},t={id:"node",name:"Node",features:{shardedState:{level:"emulated",note:"One better-sqlite3 database per shard key, one process — no distributed placement or failover"},globalTables:{level:"emulated",note:"The @lunora/sql-store core on its own SQLite file via the reference sqliteDialect — full store semantics, but one node with no replication"},websocketHibernation:{level:"emulated",note:"Socket registry with attachments/tags persisted to SQLite, so subscription state survives a process restart; nothing is ever actually evicted from memory, so this is durability without hibernation's memory saving"},durableStreams:{level:"unsupported",note:"The transcript store is host-neutral (@lunora/shard-engine), but the attach/produce state machine lives in @lunora/do and nothing in this host mounts it — a durable stream declared here would silently behave as an ephemeral one"},localSql:{level:"native",note:"better-sqlite3 (synchronous, embedded)"},shardAlarms:{level:"emulated",note:"setTimeout over a durable row, dispatched to onAlarm and re-armed on construction, so an alarm survives a restart and one whose time elapsed while the process was down fires late rather than never"},shardPlacement:{level:"unsupported",note:"One process — every shard lives where the process does, so a location hint has nowhere to place it"},shardReadReplicas:{level:"unsupported",note:"One process and one region: a replica here would be a second copy of a database already on the same disk"},crossShardFanout:{level:"emulated",note:"@lunora/runtime's query coordinator over the in-process shard registry; listShardKeys is seeded from the shard files on disk, and answers every shard rather than only those holding the table (a correct superset, at the cost of visiting shards with nothing to say)"},queues:{level:"emulated",note:`createNodeQueueHost (@lunora/platform-node) — a QueueBindingLike producer per declared queue over a durable _lunora_queue_messages table, and a batched consumer feeding the same dispatchQueueBatch the Cloudflare host uses. delaySeconds (capped at 12h), all four content types, maxBatchSize/maxBatchTimeout assembly, per-message ack/retry with workerd's implicit-ack-on-return and retry-on-throw, maxRetries into a declared deadLetterQueue (or parked in place, never dropped), and a visibility window so a crash mid-handler redelivers. Delivery is driven by poll(); there is no timer, because this host has no dev server to own one. mode: "pull" queues are written but not consumed — nothing here serves the HTTP pull endpoint`},workflows:{level:"emulated",note:"createNodeWorkflowHost (@lunora/platform-node) compiles defineWorkflow handlers onto the @visulima/workflow engine (createRuntime): step/sleep/waitForEvent are durable + replay-safe, status maps to complete/errored/waiting/terminated, create({ id }) is honoured through a durable alias row (so ctx.spawn resolves and a retried create is one run), and runs survive a restart when backed by createNodeWorkflowStore (a SQLite WorkflowStore; the store is required, so no caller silently gets in-process-only state). Gaps: no pause/restart; terminate is not a barrier, so an activation already in flight overwrites the tombstone; ctx.run dispatches to an endpoint no Node HTTP server serves; ctx.parallel's synchronous join cannot interleave within one trigger activation"},scheduler:{level:"emulated",note:"SQLite job table dispatched to onDispatch and re-armed on construction, with retry backoff and a dead-letter queue; the only host implementing runtime cron registration (SchedulerHost.cron), which Cloudflare cannot offer"},objectStorageBackups:{level:"emulated",note:"The commands work unchanged, but the bucket underneath is createNodeR2Bucket — a directory on the same machine the CLI runs on, so a bucket-backed backup here is not the separate failure domain it is on Cloudflare. The scheduled half additionally needs this host's scheduler, which exists but is not a shipping target"},objectStorage:{level:"emulated",note:"createNodeR2Bucket (@lunora/platform-node) — an R2BucketLike over the local filesystem (fs/promises, head/list/range). One file per object with the metadata in a trailer, so the single rename that publishes the bytes publishes their checksum and content-type with them, and a get reads body and metadata through one handle rather than reopening the path. put streams into the staged file and .body streams the requested range; .arrayBuffer()/.text() still allocate the range they return. The body is single-use, as R2's is. Keys fold the way the host filesystem folds them, so `A` and `a` are one object on a case-insensitive volume where real R2 keeps two. No multipart uploads, no presigned URLs"},keyValueStore:{level:"emulated",note:"better-sqlite3 table behind the ShardKvStore API — not a dedicated KV product"},vectorStore:{level:"unsupported",note:"No Vectorize-equivalent binding implemented"},ai:{level:"unsupported",note:"No Workers AI-equivalent binding implemented"},browser:{level:"unsupported",note:"No headless-browser binding implemented"},containers:{level:"unsupported",note:"No container orchestration implemented"},analytics:{level:"unsupported",note:"No Analytics Engine-equivalent binding implemented"},pipelines:{level:"unsupported",note:"No Pipelines-equivalent binding implemented"},mail:{level:"unsupported",note:"@lunora/mail's queue-backed sends need a queues binding, which this target does not provide"},secrets:{level:"unsupported",note:"No Secrets Store-equivalent binding implemented (a real host would likely map this to env vars)"},hyperdrive:{level:"unsupported",note:"No connection-pooling binding implemented"}}};export{e as CLOUDFLARE_CAPABILITIES,t as NODE_CAPABILITIES};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{DatabaseSync as R}from"node:sqlite";let b=0,S=0;const j=()=>(b+=1,`socket-${b}`),C=()=>(S+=1,`job-${S}`),q=n=>n===void 0?null:n,E=n=>typeof n=="string"?new TextEncoder().encode(n).buffer:n instanceof ArrayBuffer?n:ArrayBuffer.isView(n)?n.buffer.slice(n.byteOffset,n.byteOffset+n.byteLength):new ArrayBuffer(0),O=()=>{const n=new R(":memory:"),s={alarmAt:null,alarmTimeout:null,pending:[],running:!1},l=new Map,f=new Map,u=new Map,x={exec:(e,...t)=>{const a=n.prepare(e),r=t.map(q),o=e.trim().toLowerCase().startsWith("select")?a.all(...r):(a.run(...r),[]);return{[Symbol.iterator]:()=>o[Symbol.iterator](),one:()=>{if(o.length!==1)throw new Error(`expected exactly one row, got ${String(o.length)}`);return o[0]},toArray:()=>[...o]}}},M={all:async(e,t)=>n.prepare(e).all(...t),run:async(e,t)=>{const a=n.prepare(e).run(...t);return{rowsAffected:Number(a.changes)}}},h=()=>{if(s.running||s.pending.length===0)return;const e=s.pending.shift();e!==void 0&&(s.running=!0,e.function_().then(e.resolve,e.reject).finally(()=>{s.running=!1,h()}))},F=e=>new Promise((t,a)=>{s.pending.push({function_:e,reject:r=>{a(r)},resolve:r=>{t(r)}}),h()});let g=Promise.resolve();const p=async e=>{n.exec("BEGIN");try{const t=await e();return n.exec("COMMIT"),t}catch(t){throw n.exec("ROLLBACK"),t}},D={alarms:{delete:()=>{s.alarmAt=null,s.alarmTimeout!==null&&(clearTimeout(s.alarmTimeout),s.alarmTimeout=null)},get:()=>s.alarmAt,set:e=>{const t=typeof e=="number"?e:e.getTime();s.alarmAt=t,s.alarmTimeout!==null&&clearTimeout(s.alarmTimeout);const a=Math.max(0,t-Date.now());s.alarmTimeout=setTimeout(()=>{s.alarmAt=null,s.alarmTimeout=null},a)}},asyncSql:M,runSerialized:F,sql:x,transaction:e=>{const t=g.then(()=>p(e),()=>p(e));return g=t.then(()=>{},()=>{}),t},waitUntil:()=>{}},c=new WeakMap,w=e=>{const t={bufferedAmount:e.bufferedAmount,close:(a,r)=>{e.closed=!0},deserializeAttachment:()=>e.attachment,send:a=>{e.received.push(typeof a=="string"?a:E(a))},serializeAttachment:a=>{e.attachment=a,f.set(e.id,a)}};return e.handle=t,c.set(t,e.id),t},P={accept:(e,t,a)=>{const r=j(),o={attachment:t,bufferedAmount:0,closed:!1,raw:e,handle:null,id:r,received:[],tags:new Set(a)};return l.set(r,o),u.set(r,new Set(a)),t!==void 0&&f.set(r,t),w(o)},getSockets:e=>{const t=[...l.values()];return(e===void 0?t:t.filter(a=>a.tags.has(e))).map(a=>a.handle)},handleFor:e=>[...l.values()].find(t=>t.raw===e)?.handle,idFor:e=>{const t=c.get(e);if(t===void 0)throw new Error("reference host: idFor called with a handle this host never issued");return t},removeTag:(e,t)=>{const a=c.get(e)??"",r=l.get(a);r!==void 0&&(t===void 0?r.tags.clear():r.tags.delete(t),u.set(a,new Set(r.tags)))},setTag:(e,t)=>{const a=c.get(e)??"",r=l.get(a);r!==void 0&&(r.tags.add(t),u.set(a,new Set(r.tags)))}},y={get:e=>({fetch:async()=>new Response(String(e))}),getByName:e=>({fetch:async()=>new Response(e)}),idForName:e=>`shard:${e}`,jurisdiction:e=>y},d=new Map,k={delete:async e=>d.delete(e),get:async e=>d.get(e),list:async e=>{const t=e?.prefix??"",a=new Map;for(const[r,o]of d)r.startsWith(t)&&a.set(r,o);return a},put:async(e,t)=>{d.set(e,structuredClone(t))}},i=new Map,m=new Map,v=new Set,T=(e,t)=>({attempts:t.attempts,functionPath:t.functionPath,id:e,scheduledFor:t.scheduledFor});return{awaitAlarmFired:async e=>{await new Promise(t=>{setTimeout(t,Math.max(0,e-Date.now())+30)})},awaitJobDispatched:async e=>{const t=i.get(e);return t!==void 0&&await new Promise(a=>{setTimeout(a,Math.max(0,t.scheduledFor-Date.now())+30)}),v.has(e)},cleanup:()=>{n.close(),s.alarmTimeout!==null&&clearTimeout(s.alarmTimeout);for(const e of i.values())clearTimeout(e.timer)},directory:y,kv:k,readFrames:e=>(l.get(c.get(e)??"")?.received??[]).filter(t=>typeof t=="string"),restoreSocket:(e,t)=>{const a={attachment:t,bufferedAmount:0,closed:!1,handle:null,id:e,raw:void 0,received:[],tags:new Set(u.get(e))};return l.set(e,a),w(a)},scheduler:{cancel:async e=>{const t=i.get(e);return t===void 0?!1:(clearTimeout(t.timer),i.delete(e),!0)},cron:async()=>{},deadLetter:{list:async()=>[...m].map(([e,t])=>T(e,t)),requeue:async e=>{const t=m.get(e);return t===void 0?!1:(m.delete(e),i.set(e,{...t,attempts:0,timer:void 0}),!0)}},list:async()=>[...i].map(([e,t])=>T(e,t)),schedule:async(e,t,a)=>{const r=C();let o;a?.at===void 0?o=Date.now()+(a?.delayMs??0):o=typeof a.at=="number"?a.at:a.at.getTime();const B=Math.max(0,o-Date.now()),L=setTimeout(()=>{const A=i.get(r);A!==void 0&&(A.attempts+=1),v.add(r),i.delete(r)},B);return i.set(r,{args:t,attempts:0,functionPath:e,options:a??{},scheduledFor:o,timer:L}),{id:r,scheduledFor:o}}},simulateDeadLetter:async e=>{const t=i.get(e);return t===void 0?!1:(clearTimeout(t.timer),i.delete(e),m.set(e,{...t,attempts:(t.options.retry?.maxAttempts??5)+1,timer:void 0}),!0)},shard:D,simulateRecycle:()=>{l.clear()},socket:P}};export{O as createReferenceHost};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const a=(e,o,t)=>e.getByName!==void 0?e.getByName(o,t):e.get(e.idForName(o),t);export{a as resolveShard};
|
|
@@ -180,20 +180,32 @@ interface SchedulerHost {
|
|
|
180
180
|
schedule: (functionPath: string, args: Record<string, unknown>, options?: ScheduleOptions) => Promise<ScheduledJob>;
|
|
181
181
|
}
|
|
182
182
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
183
|
+
* Edge geography → placement region, shared by `@lunora/runtime` (which reads
|
|
184
|
+
* `request.cf` to pick where a shard, replica, or region-local socket should
|
|
185
|
+
* live) and `@lunora/do` (which parses a region out of its own DO name). Kept
|
|
186
|
+
* here — inlined into each consumer's bundle — so the two sides can never drift
|
|
187
|
+
* on the region vocabulary without creating a runtime dependency edge between
|
|
188
|
+
* the packages.
|
|
187
189
|
*
|
|
188
|
-
* The
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
190
|
+
* The values are Cloudflare's Durable Object location hints, which is also the
|
|
191
|
+
* only vocabulary a Lunora deployment needs today: a region is *only* ever used
|
|
192
|
+
* as a placement hint and as a name segment, never as data. Wrong-but-close is
|
|
193
|
+
* fine by construction — a misrouted read is one longer hop, never a wrong
|
|
194
|
+
* answer — so this maps coarsely and returns `undefined` rather than guessing
|
|
195
|
+
* when the request carries no usable geography.
|
|
193
196
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
197
|
+
* Zero-dependency by design (see the repo's `shared/` rules): only relative /
|
|
198
|
+
* builtin imports, named exports, no `.js` extensions.
|
|
196
199
|
*/
|
|
200
|
+
/**
|
|
201
|
+
* The placement regions a name may carry and a hint may request — Cloudflare's
|
|
202
|
+
* `DurableObjectLocationHint` values, listed so the set can be validated at a
|
|
203
|
+
* trust boundary (a region parsed out of a DO name is attacker-influenced input
|
|
204
|
+
* on any route that mints names from a client-supplied shard key).
|
|
205
|
+
*/
|
|
206
|
+
declare const REGION_HINTS: readonly ["wnam", "enam", "sam", "weur", "eeur", "apac", "apac-ne", "apac-se", "oc", "afr", "me"];
|
|
207
|
+
/** One placement region. Structurally identical to Cloudflare's `DurableObjectLocationHint`. */
|
|
208
|
+
type RegionHint = (typeof REGION_HINTS)[number];
|
|
197
209
|
/**
|
|
198
210
|
* Cloudflare Durable Object jurisdictions restrict where a DO runs and
|
|
199
211
|
* persists data, for data-residency / compliance regimes (GDPR, FedRAMP, US
|
|
@@ -206,6 +218,23 @@ interface SchedulerHost {
|
|
|
206
218
|
* leave them unsupported.
|
|
207
219
|
*/
|
|
208
220
|
type ShardJurisdiction = "eu" | "fedramp" | "us" | (Record<never, never> & string);
|
|
221
|
+
/**
|
|
222
|
+
* A geographic placement region — where a shard should be created, when the
|
|
223
|
+
* caller has an opinion.
|
|
224
|
+
*
|
|
225
|
+
* One vocabulary, defined once: `shared/region-hint.ts` owns the region list
|
|
226
|
+
* (it is also what derives a region from edge geography), and this contract
|
|
227
|
+
* re-exports it rather than restating the strings. A second list is how the two
|
|
228
|
+
* ends of a placement request drift apart.
|
|
229
|
+
*
|
|
230
|
+
* Unlike a jurisdiction — a hard constraint the caller must fail closed on — a
|
|
231
|
+
* region is **best effort and advisory**: a provider may ignore it, and on
|
|
232
|
+
* Cloudflare it is honoured only by the call that first creates the object.
|
|
233
|
+
* Everything downstream must work identically whether the hint was honoured,
|
|
234
|
+
* ignored, or never supplied, and no caller may treat a resolved stub's
|
|
235
|
+
* location as known.
|
|
236
|
+
*/
|
|
237
|
+
type ShardRegionHint = RegionHint;
|
|
209
238
|
/**
|
|
210
239
|
* A resolved shard stub. The engine calls `fetch` (or an equivalent RPC
|
|
211
240
|
* method) to dispatch work to the shard.
|
|
@@ -222,9 +251,9 @@ interface ShardStub {
|
|
|
222
251
|
*/
|
|
223
252
|
interface DirectShardDirectory {
|
|
224
253
|
/** Resolve an opaque id (from `idForName`) to a stub, when the provider has ids. */
|
|
225
|
-
get?: (id: unknown) => ShardStub;
|
|
254
|
+
get?: (id: unknown, locationHint?: ShardRegionHint) => ShardStub;
|
|
226
255
|
/** Resolve a shard key to a stub. */
|
|
227
|
-
getByName: (name: string) => ShardStub;
|
|
256
|
+
getByName: (name: string, locationHint?: ShardRegionHint) => ShardStub;
|
|
228
257
|
/** Derive a stable, opaque shard id from a shard key, when the provider has ids. */
|
|
229
258
|
idForName?: (name: string) => unknown;
|
|
230
259
|
/** See {@link ShardDirectory}. */
|
|
@@ -237,7 +266,7 @@ interface DirectShardDirectory {
|
|
|
237
266
|
*/
|
|
238
267
|
interface TwoStepShardDirectory {
|
|
239
268
|
/** Resolve an opaque id (from `idForName`) to a stub. */
|
|
240
|
-
get: (id: unknown) => ShardStub;
|
|
269
|
+
get: (id: unknown, locationHint?: ShardRegionHint) => ShardStub;
|
|
241
270
|
/**
|
|
242
271
|
* Absent — the discriminant that selects the two-step branch.
|
|
243
272
|
*/
|
|
@@ -269,8 +298,13 @@ type ShardDirectory = DirectShardDirectory | TwoStepShardDirectory;
|
|
|
269
298
|
* Resolve a shard key to a stub against either directory shape. Uses direct
|
|
270
299
|
* name lookup when the provider has it, and falls back to the two-step
|
|
271
300
|
* `idForName` + `get` dance otherwise.
|
|
301
|
+
*
|
|
302
|
+
* `locationHint` is forwarded to whichever branch runs. It is advisory in
|
|
303
|
+
* both: a provider with no placement concept ignores the extra argument, which
|
|
304
|
+
* is exactly what an implementation written against the pre-placement
|
|
305
|
+
* signature does.
|
|
272
306
|
*/
|
|
273
|
-
declare const resolveShard: (directory: ShardDirectory, name: string) => ShardStub;
|
|
307
|
+
declare const resolveShard: (directory: ShardDirectory, name: string, locationHint?: ShardRegionHint) => ShardStub;
|
|
274
308
|
/**
|
|
275
309
|
* `ShardHost` — the provider-neutral contract for a single-writer, durable
|
|
276
310
|
* shard execution slot. On Cloudflare this is backed by one Durable Object
|
|
@@ -382,6 +416,13 @@ interface ShardHost {
|
|
|
382
416
|
* Run `fn` with exclusive ownership of the shard. Concurrent calls are
|
|
383
417
|
* queued; no two closures run at once for the same shard key. On
|
|
384
418
|
* Cloudflare this maps to `state.blockConcurrencyWhile`.
|
|
419
|
+
*
|
|
420
|
+
* A closure that throws must reject with **the value it threw**, and must
|
|
421
|
+
* leave the host usable for the next call. Engine errors carry a `code` and
|
|
422
|
+
* `status` the RPC edge renders from, so a host that lets its platform
|
|
423
|
+
* substitute a copy silently downgrades every coded error to an internal
|
|
424
|
+
* fault — and a host that tears itself down on a throw makes an ordinary
|
|
425
|
+
* application error cost every other caller on that shard.
|
|
385
426
|
*/
|
|
386
427
|
runSerialized: <T>(function_: () => Promise<T>) => Promise<T>;
|
|
387
428
|
/**
|
|
@@ -401,8 +442,10 @@ interface ShardHost {
|
|
|
401
442
|
sql: ShardSqlExec;
|
|
402
443
|
/**
|
|
403
444
|
* Run `fn` inside a durable transaction. If `fn` throws, all writes roll
|
|
404
|
-
* back
|
|
405
|
-
*
|
|
445
|
+
* back and the call rejects with **the value `fn` threw** — see
|
|
446
|
+
* {@link ShardHost.runSerialized} for why the identity matters. Raw
|
|
447
|
+
* `BEGIN`/`COMMIT`/`ROLLBACK` are forbidden inside the closure; the host
|
|
448
|
+
* manages the transaction boundary.
|
|
406
449
|
*/
|
|
407
450
|
transaction: <T>(function_: () => Promise<T>) => Promise<T>;
|
|
408
451
|
/**
|
|
@@ -441,6 +484,17 @@ interface ShardHost {
|
|
|
441
484
|
* — optional. Presence declares that the host can retag a live socket. Hosts
|
|
442
485
|
* that cannot (Cloudflare) omit both methods, and callers that need to
|
|
443
486
|
* retag must instead close and re-accept the socket with new tags.
|
|
487
|
+
*
|
|
488
|
+
* **Reserved-slot budget.** A host may reserve some of its accept-time tag
|
|
489
|
+
* slots for its own bookkeeping — Cloudflare's adapter prepends one identity
|
|
490
|
+
* tag (so `idFor` survives hibernation) before every `acceptWebSocket` call.
|
|
491
|
+
* Cloudflare's own cap is 10 tags per socket, 256 characters each
|
|
492
|
+
* (developers.cloudflare.com/durable-objects/api/state/), so with one slot
|
|
493
|
+
* reserved a portable caller should assume **at most 9 usable tags, each
|
|
494
|
+
* bounded to at most 256 characters** — a budget-exceeding `accept` call
|
|
495
|
+
* fails loudly on the host that enforces it (see
|
|
496
|
+
* {@link SocketHost.accept}) rather than passing silently on hosts with no
|
|
497
|
+
* cap and only failing, opaquely, on Cloudflare.
|
|
444
498
|
*/
|
|
445
499
|
/**
|
|
446
500
|
* The socket the engine sends through.
|
|
@@ -499,6 +553,14 @@ interface SocketHost {
|
|
|
499
553
|
* survive recycling too and must be honoured by
|
|
500
554
|
* {@link SocketHost.getSockets}. Returns a handle the engine can
|
|
501
555
|
* send/close through.
|
|
556
|
+
*
|
|
557
|
+
* Portable callers should assume a budget of **at most 9 usable tags, each
|
|
558
|
+
* at most 256 characters** — some hosts (Cloudflare) reserve one tag slot
|
|
559
|
+
* of their own 10-tag cap for bookkeeping; see this file's module-header
|
|
560
|
+
* "Reserved-slot budget" note. A host that enforces a cap rejects an
|
|
561
|
+
* over-budget call rather than accepting it and silently dropping or
|
|
562
|
+
* truncating tags, which would break {@link SocketHost.getSockets}'s
|
|
563
|
+
* exactness requirement.
|
|
502
564
|
*/
|
|
503
565
|
accept: (socket: unknown, attachment?: unknown, tags?: ReadonlyArray<string>) => SocketHandle;
|
|
504
566
|
/**
|
|
@@ -535,6 +597,31 @@ interface SocketHost {
|
|
|
535
597
|
* recycle, since the engine uses it to reassociate a rehydrated socket with
|
|
536
598
|
* its subscription state. Callers outside the O(subscribers) loops are the
|
|
537
599
|
* intended consumers; do not reach for this per socket per frame.
|
|
600
|
+
*
|
|
601
|
+
* **A socket this host never {@link SocketHost.accept}ed** (a foreign socket
|
|
602
|
+
* the runtime hands back — a whisper sender in another pool, a relay peer)
|
|
603
|
+
* is outside that "own socket" contract, but a host must still pick ONE
|
|
604
|
+
* consistent answer for it, never a fresh value per call:
|
|
605
|
+
*
|
|
606
|
+
* - Throw, when a `SocketHandle` from this host can *only* ever originate
|
|
607
|
+
* from this host's own `accept`/`recycle` — an unrecognized handle then
|
|
608
|
+
* means caller error (a handle crossed from a different host instance),
|
|
609
|
+
* and failing loud beats returning a plausible-looking wrong id. This is
|
|
610
|
+
* the reference host's choice, since its `SocketHandle` is an opaque
|
|
611
|
+
* object it mints itself.
|
|
612
|
+
* - Mint and cache a read-only fallback id, when `SocketHandle` doubles as
|
|
613
|
+
* the provider's own transport socket (see {@link SocketHandle}'s "not a
|
|
614
|
+
* wrapper" rationale) — a foreign-but-genuine socket then structurally
|
|
615
|
+
* satisfies the type without ever going through `accept`, so throwing
|
|
616
|
+
* would fire on legitimate traffic, not just caller error. This is the
|
|
617
|
+
* Cloudflare host's choice: it caches into a separate map from its
|
|
618
|
+
* accept-time ownership evidence, so an `idFor` lookup can never promote
|
|
619
|
+
* a socket into "ours" for {@link SocketHost.handleFor}.
|
|
620
|
+
*
|
|
621
|
+
* Either is a valid implementation as long as it is consistent: what is not
|
|
622
|
+
* valid is minting a new id on every call for a socket the host does not
|
|
623
|
+
* recognize, which defeats the "same string for the same socket" property
|
|
624
|
+
* every caller of `idFor` — owned or not — depends on.
|
|
538
625
|
*/
|
|
539
626
|
idFor: (socket: SocketHandle) => string;
|
|
540
627
|
/**
|
|
@@ -552,4 +639,4 @@ interface SocketHost {
|
|
|
552
639
|
*/
|
|
553
640
|
setTag?: (socket: SocketHandle, tag: string) => void;
|
|
554
641
|
}
|
|
555
|
-
export { DirectShardDirectory as D, ScheduleOptions as S, TwoStepShardDirectory as T, ScheduledJob as a, ScheduledJobStatus as b, SchedulerHost as c, ShardAlarms as d, ShardAsyncSqlExec as e, ShardDirectory as f, ShardHost as g, ShardJurisdiction as h, ShardKvListOptions as i, ShardKvStore as j,
|
|
642
|
+
export { DirectShardDirectory as D, ScheduleOptions as S, TwoStepShardDirectory as T, ScheduledJob as a, ScheduledJobStatus as b, SchedulerHost as c, ShardAlarms as d, ShardAsyncSqlExec as e, ShardDirectory as f, ShardHost as g, ShardJurisdiction as h, ShardKvListOptions as i, ShardKvStore as j, ShardRegionHint as k, ShardSqlCursor as l, ShardSqlExec as m, ShardStub as n, SocketHandle as o, SocketHost as p, SqlRow as q, resolveShard as r };
|
|
@@ -180,20 +180,32 @@ interface SchedulerHost {
|
|
|
180
180
|
schedule: (functionPath: string, args: Record<string, unknown>, options?: ScheduleOptions) => Promise<ScheduledJob>;
|
|
181
181
|
}
|
|
182
182
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
183
|
+
* Edge geography → placement region, shared by `@lunora/runtime` (which reads
|
|
184
|
+
* `request.cf` to pick where a shard, replica, or region-local socket should
|
|
185
|
+
* live) and `@lunora/do` (which parses a region out of its own DO name). Kept
|
|
186
|
+
* here — inlined into each consumer's bundle — so the two sides can never drift
|
|
187
|
+
* on the region vocabulary without creating a runtime dependency edge between
|
|
188
|
+
* the packages.
|
|
187
189
|
*
|
|
188
|
-
* The
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
190
|
+
* The values are Cloudflare's Durable Object location hints, which is also the
|
|
191
|
+
* only vocabulary a Lunora deployment needs today: a region is *only* ever used
|
|
192
|
+
* as a placement hint and as a name segment, never as data. Wrong-but-close is
|
|
193
|
+
* fine by construction — a misrouted read is one longer hop, never a wrong
|
|
194
|
+
* answer — so this maps coarsely and returns `undefined` rather than guessing
|
|
195
|
+
* when the request carries no usable geography.
|
|
193
196
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
197
|
+
* Zero-dependency by design (see the repo's `shared/` rules): only relative /
|
|
198
|
+
* builtin imports, named exports, no `.js` extensions.
|
|
196
199
|
*/
|
|
200
|
+
/**
|
|
201
|
+
* The placement regions a name may carry and a hint may request — Cloudflare's
|
|
202
|
+
* `DurableObjectLocationHint` values, listed so the set can be validated at a
|
|
203
|
+
* trust boundary (a region parsed out of a DO name is attacker-influenced input
|
|
204
|
+
* on any route that mints names from a client-supplied shard key).
|
|
205
|
+
*/
|
|
206
|
+
declare const REGION_HINTS: readonly ["wnam", "enam", "sam", "weur", "eeur", "apac", "apac-ne", "apac-se", "oc", "afr", "me"];
|
|
207
|
+
/** One placement region. Structurally identical to Cloudflare's `DurableObjectLocationHint`. */
|
|
208
|
+
type RegionHint = (typeof REGION_HINTS)[number];
|
|
197
209
|
/**
|
|
198
210
|
* Cloudflare Durable Object jurisdictions restrict where a DO runs and
|
|
199
211
|
* persists data, for data-residency / compliance regimes (GDPR, FedRAMP, US
|
|
@@ -206,6 +218,23 @@ interface SchedulerHost {
|
|
|
206
218
|
* leave them unsupported.
|
|
207
219
|
*/
|
|
208
220
|
type ShardJurisdiction = "eu" | "fedramp" | "us" | (Record<never, never> & string);
|
|
221
|
+
/**
|
|
222
|
+
* A geographic placement region — where a shard should be created, when the
|
|
223
|
+
* caller has an opinion.
|
|
224
|
+
*
|
|
225
|
+
* One vocabulary, defined once: `shared/region-hint.ts` owns the region list
|
|
226
|
+
* (it is also what derives a region from edge geography), and this contract
|
|
227
|
+
* re-exports it rather than restating the strings. A second list is how the two
|
|
228
|
+
* ends of a placement request drift apart.
|
|
229
|
+
*
|
|
230
|
+
* Unlike a jurisdiction — a hard constraint the caller must fail closed on — a
|
|
231
|
+
* region is **best effort and advisory**: a provider may ignore it, and on
|
|
232
|
+
* Cloudflare it is honoured only by the call that first creates the object.
|
|
233
|
+
* Everything downstream must work identically whether the hint was honoured,
|
|
234
|
+
* ignored, or never supplied, and no caller may treat a resolved stub's
|
|
235
|
+
* location as known.
|
|
236
|
+
*/
|
|
237
|
+
type ShardRegionHint = RegionHint;
|
|
209
238
|
/**
|
|
210
239
|
* A resolved shard stub. The engine calls `fetch` (or an equivalent RPC
|
|
211
240
|
* method) to dispatch work to the shard.
|
|
@@ -222,9 +251,9 @@ interface ShardStub {
|
|
|
222
251
|
*/
|
|
223
252
|
interface DirectShardDirectory {
|
|
224
253
|
/** Resolve an opaque id (from `idForName`) to a stub, when the provider has ids. */
|
|
225
|
-
get?: (id: unknown) => ShardStub;
|
|
254
|
+
get?: (id: unknown, locationHint?: ShardRegionHint) => ShardStub;
|
|
226
255
|
/** Resolve a shard key to a stub. */
|
|
227
|
-
getByName: (name: string) => ShardStub;
|
|
256
|
+
getByName: (name: string, locationHint?: ShardRegionHint) => ShardStub;
|
|
228
257
|
/** Derive a stable, opaque shard id from a shard key, when the provider has ids. */
|
|
229
258
|
idForName?: (name: string) => unknown;
|
|
230
259
|
/** See {@link ShardDirectory}. */
|
|
@@ -237,7 +266,7 @@ interface DirectShardDirectory {
|
|
|
237
266
|
*/
|
|
238
267
|
interface TwoStepShardDirectory {
|
|
239
268
|
/** Resolve an opaque id (from `idForName`) to a stub. */
|
|
240
|
-
get: (id: unknown) => ShardStub;
|
|
269
|
+
get: (id: unknown, locationHint?: ShardRegionHint) => ShardStub;
|
|
241
270
|
/**
|
|
242
271
|
* Absent — the discriminant that selects the two-step branch.
|
|
243
272
|
*/
|
|
@@ -269,8 +298,13 @@ type ShardDirectory = DirectShardDirectory | TwoStepShardDirectory;
|
|
|
269
298
|
* Resolve a shard key to a stub against either directory shape. Uses direct
|
|
270
299
|
* name lookup when the provider has it, and falls back to the two-step
|
|
271
300
|
* `idForName` + `get` dance otherwise.
|
|
301
|
+
*
|
|
302
|
+
* `locationHint` is forwarded to whichever branch runs. It is advisory in
|
|
303
|
+
* both: a provider with no placement concept ignores the extra argument, which
|
|
304
|
+
* is exactly what an implementation written against the pre-placement
|
|
305
|
+
* signature does.
|
|
272
306
|
*/
|
|
273
|
-
declare const resolveShard: (directory: ShardDirectory, name: string) => ShardStub;
|
|
307
|
+
declare const resolveShard: (directory: ShardDirectory, name: string, locationHint?: ShardRegionHint) => ShardStub;
|
|
274
308
|
/**
|
|
275
309
|
* `ShardHost` — the provider-neutral contract for a single-writer, durable
|
|
276
310
|
* shard execution slot. On Cloudflare this is backed by one Durable Object
|
|
@@ -382,6 +416,13 @@ interface ShardHost {
|
|
|
382
416
|
* Run `fn` with exclusive ownership of the shard. Concurrent calls are
|
|
383
417
|
* queued; no two closures run at once for the same shard key. On
|
|
384
418
|
* Cloudflare this maps to `state.blockConcurrencyWhile`.
|
|
419
|
+
*
|
|
420
|
+
* A closure that throws must reject with **the value it threw**, and must
|
|
421
|
+
* leave the host usable for the next call. Engine errors carry a `code` and
|
|
422
|
+
* `status` the RPC edge renders from, so a host that lets its platform
|
|
423
|
+
* substitute a copy silently downgrades every coded error to an internal
|
|
424
|
+
* fault — and a host that tears itself down on a throw makes an ordinary
|
|
425
|
+
* application error cost every other caller on that shard.
|
|
385
426
|
*/
|
|
386
427
|
runSerialized: <T>(function_: () => Promise<T>) => Promise<T>;
|
|
387
428
|
/**
|
|
@@ -401,8 +442,10 @@ interface ShardHost {
|
|
|
401
442
|
sql: ShardSqlExec;
|
|
402
443
|
/**
|
|
403
444
|
* Run `fn` inside a durable transaction. If `fn` throws, all writes roll
|
|
404
|
-
* back
|
|
405
|
-
*
|
|
445
|
+
* back and the call rejects with **the value `fn` threw** — see
|
|
446
|
+
* {@link ShardHost.runSerialized} for why the identity matters. Raw
|
|
447
|
+
* `BEGIN`/`COMMIT`/`ROLLBACK` are forbidden inside the closure; the host
|
|
448
|
+
* manages the transaction boundary.
|
|
406
449
|
*/
|
|
407
450
|
transaction: <T>(function_: () => Promise<T>) => Promise<T>;
|
|
408
451
|
/**
|
|
@@ -441,6 +484,17 @@ interface ShardHost {
|
|
|
441
484
|
* — optional. Presence declares that the host can retag a live socket. Hosts
|
|
442
485
|
* that cannot (Cloudflare) omit both methods, and callers that need to
|
|
443
486
|
* retag must instead close and re-accept the socket with new tags.
|
|
487
|
+
*
|
|
488
|
+
* **Reserved-slot budget.** A host may reserve some of its accept-time tag
|
|
489
|
+
* slots for its own bookkeeping — Cloudflare's adapter prepends one identity
|
|
490
|
+
* tag (so `idFor` survives hibernation) before every `acceptWebSocket` call.
|
|
491
|
+
* Cloudflare's own cap is 10 tags per socket, 256 characters each
|
|
492
|
+
* (developers.cloudflare.com/durable-objects/api/state/), so with one slot
|
|
493
|
+
* reserved a portable caller should assume **at most 9 usable tags, each
|
|
494
|
+
* bounded to at most 256 characters** — a budget-exceeding `accept` call
|
|
495
|
+
* fails loudly on the host that enforces it (see
|
|
496
|
+
* {@link SocketHost.accept}) rather than passing silently on hosts with no
|
|
497
|
+
* cap and only failing, opaquely, on Cloudflare.
|
|
444
498
|
*/
|
|
445
499
|
/**
|
|
446
500
|
* The socket the engine sends through.
|
|
@@ -499,6 +553,14 @@ interface SocketHost {
|
|
|
499
553
|
* survive recycling too and must be honoured by
|
|
500
554
|
* {@link SocketHost.getSockets}. Returns a handle the engine can
|
|
501
555
|
* send/close through.
|
|
556
|
+
*
|
|
557
|
+
* Portable callers should assume a budget of **at most 9 usable tags, each
|
|
558
|
+
* at most 256 characters** — some hosts (Cloudflare) reserve one tag slot
|
|
559
|
+
* of their own 10-tag cap for bookkeeping; see this file's module-header
|
|
560
|
+
* "Reserved-slot budget" note. A host that enforces a cap rejects an
|
|
561
|
+
* over-budget call rather than accepting it and silently dropping or
|
|
562
|
+
* truncating tags, which would break {@link SocketHost.getSockets}'s
|
|
563
|
+
* exactness requirement.
|
|
502
564
|
*/
|
|
503
565
|
accept: (socket: unknown, attachment?: unknown, tags?: ReadonlyArray<string>) => SocketHandle;
|
|
504
566
|
/**
|
|
@@ -535,6 +597,31 @@ interface SocketHost {
|
|
|
535
597
|
* recycle, since the engine uses it to reassociate a rehydrated socket with
|
|
536
598
|
* its subscription state. Callers outside the O(subscribers) loops are the
|
|
537
599
|
* intended consumers; do not reach for this per socket per frame.
|
|
600
|
+
*
|
|
601
|
+
* **A socket this host never {@link SocketHost.accept}ed** (a foreign socket
|
|
602
|
+
* the runtime hands back — a whisper sender in another pool, a relay peer)
|
|
603
|
+
* is outside that "own socket" contract, but a host must still pick ONE
|
|
604
|
+
* consistent answer for it, never a fresh value per call:
|
|
605
|
+
*
|
|
606
|
+
* - Throw, when a `SocketHandle` from this host can *only* ever originate
|
|
607
|
+
* from this host's own `accept`/`recycle` — an unrecognized handle then
|
|
608
|
+
* means caller error (a handle crossed from a different host instance),
|
|
609
|
+
* and failing loud beats returning a plausible-looking wrong id. This is
|
|
610
|
+
* the reference host's choice, since its `SocketHandle` is an opaque
|
|
611
|
+
* object it mints itself.
|
|
612
|
+
* - Mint and cache a read-only fallback id, when `SocketHandle` doubles as
|
|
613
|
+
* the provider's own transport socket (see {@link SocketHandle}'s "not a
|
|
614
|
+
* wrapper" rationale) — a foreign-but-genuine socket then structurally
|
|
615
|
+
* satisfies the type without ever going through `accept`, so throwing
|
|
616
|
+
* would fire on legitimate traffic, not just caller error. This is the
|
|
617
|
+
* Cloudflare host's choice: it caches into a separate map from its
|
|
618
|
+
* accept-time ownership evidence, so an `idFor` lookup can never promote
|
|
619
|
+
* a socket into "ours" for {@link SocketHost.handleFor}.
|
|
620
|
+
*
|
|
621
|
+
* Either is a valid implementation as long as it is consistent: what is not
|
|
622
|
+
* valid is minting a new id on every call for a socket the host does not
|
|
623
|
+
* recognize, which defeats the "same string for the same socket" property
|
|
624
|
+
* every caller of `idFor` — owned or not — depends on.
|
|
538
625
|
*/
|
|
539
626
|
idFor: (socket: SocketHandle) => string;
|
|
540
627
|
/**
|
|
@@ -552,4 +639,4 @@ interface SocketHost {
|
|
|
552
639
|
*/
|
|
553
640
|
setTag?: (socket: SocketHandle, tag: string) => void;
|
|
554
641
|
}
|
|
555
|
-
export { DirectShardDirectory as D, ScheduleOptions as S, TwoStepShardDirectory as T, ScheduledJob as a, ScheduledJobStatus as b, SchedulerHost as c, ShardAlarms as d, ShardAsyncSqlExec as e, ShardDirectory as f, ShardHost as g, ShardJurisdiction as h, ShardKvListOptions as i, ShardKvStore as j,
|
|
642
|
+
export { DirectShardDirectory as D, ScheduleOptions as S, TwoStepShardDirectory as T, ScheduledJob as a, ScheduledJobStatus as b, SchedulerHost as c, ShardAlarms as d, ShardAsyncSqlExec as e, ShardDirectory as f, ShardHost as g, ShardJurisdiction as h, ShardKvListOptions as i, ShardKvStore as j, ShardRegionHint as k, ShardSqlCursor as l, ShardSqlExec as m, ShardStub as n, SocketHandle as o, SocketHost as p, SqlRow as q, resolveShard as r };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/platform",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.10",
|
|
4
4
|
"description": "Provider-neutral host contracts for Lunora: shard/socket/directory/scheduler interfaces, binding projections, and the platform capability matrix",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
const e={id:"cloudflare",name:"Cloudflare",features:{shardedState:{level:"native",note:"Durable Objects with SQLite"},globalTables:{level:"native",note:"D1 with Sessions API"},websocketHibernation:{level:"native",note:"DO WebSocket hibernation"},localSql:{level:"native",note:"state.storage.sql (SQLite)"},shardAlarms:{level:"native",note:"state.storage.setAlarm"},crossShardFanout:{level:"emulated",note:"Lunora query coordinator + relay tier over Durable Objects"},queues:{level:"native",note:"Cloudflare Queues"},workflows:{level:"native",note:"Cloudflare Workflows"},scheduler:{level:"native",note:"SchedulerDO + Cron Triggers"},objectStorage:{level:"native",note:"R2"},keyValueStore:{level:"native",note:"Workers KV"},vectorStore:{level:"native",note:"Vectorize"},ai:{level:"native",note:"Workers AI"},browser:{level:"native",note:"Browser Rendering"},containers:{level:"native",note:"Cloudflare Containers"},analytics:{level:"native",note:"Analytics Engine"},pipelines:{level:"native",note:"Cloudflare Pipelines"},mail:{level:"emulated",note:"Resend (third-party) via Cloudflare Queues"},secrets:{level:"native",note:"Secrets Store"},hyperdrive:{level:"native",note:"Cloudflare Hyperdrive"}}};export{e as CLOUDFLARE_CAPABILITIES};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{DatabaseSync as k}from"node:sqlite";let w=0,v=0;const B=()=>(w+=1,`socket-${w}`),D=()=>(v+=1,`job-${v}`),L=n=>n===void 0?null:n,C=n=>typeof n=="string"?new TextEncoder().encode(n).buffer:n instanceof ArrayBuffer?n:ArrayBuffer.isView(n)?n.buffer.slice(n.byteOffset,n.byteOffset+n.byteLength):new ArrayBuffer(0),R=()=>{const n=new k(":memory:"),s={alarmAt:null,alarmTimeout:null,pending:[],running:!1},i=new Map,f=new Map,u=new Map,T={exec:(e,...t)=>{const r=n.prepare(e),a=t.map(L),o=e.trim().toLowerCase().startsWith("select")?r.all(...a):(r.run(...a),[]);return{[Symbol.iterator]:()=>o[Symbol.iterator](),one:()=>{if(o.length!==1)throw new Error(`expected exactly one row, got ${String(o.length)}`);return o[0]},toArray:()=>[...o]}}},A={all:async(e,t)=>n.prepare(e).all(...t),run:async(e,t)=>{const r=n.prepare(e).run(...t);return{rowsAffected:Number(r.changes)}}},g=()=>{if(s.running||s.pending.length===0)return;const e=s.pending.shift();e!==void 0&&(s.running=!0,e.function_().then(e.resolve,e.reject).finally(()=>{s.running=!1,g()}))},b={alarms:{delete:()=>{s.alarmAt=null,s.alarmTimeout!==null&&(clearTimeout(s.alarmTimeout),s.alarmTimeout=null)},get:()=>s.alarmAt,set:e=>{const t=typeof e=="number"?e:e.getTime();s.alarmAt=t,s.alarmTimeout!==null&&clearTimeout(s.alarmTimeout);const r=Math.max(0,t-Date.now());s.alarmTimeout=setTimeout(()=>{s.alarmAt=null,s.alarmTimeout=null},r)}},asyncSql:A,runSerialized:e=>new Promise((t,r)=>{s.pending.push({function_:e,reject:a=>{r(a)},resolve:a=>{t(a)}}),g()}),sql:T,transaction:async e=>{n.exec("BEGIN");try{const t=await e();return n.exec("COMMIT"),t}catch(t){throw n.exec("ROLLBACK"),t}},waitUntil:()=>{}},c=new WeakMap,h=e=>{const t={bufferedAmount:e.bufferedAmount,close:(r,a)=>{e.closed=!0},deserializeAttachment:()=>e.attachment,send:r=>{e.received.push(typeof r=="string"?r:C(r))},serializeAttachment:r=>{e.attachment=r,f.set(e.id,r)}};return e.handle=t,c.set(t,e.id),t},M={accept:(e,t,r)=>{const a=B(),o={attachment:t,bufferedAmount:0,closed:!1,raw:e,handle:null,id:a,received:[],tags:new Set(r)};return i.set(a,o),u.set(a,new Set(r)),t!==void 0&&f.set(a,t),h(o)},getSockets:e=>{const t=[...i.values()];return(e===void 0?t:t.filter(r=>r.tags.has(e))).map(r=>r.handle)},handleFor:e=>[...i.values()].find(t=>t.raw===e)?.handle,idFor:e=>{const t=c.get(e);if(t===void 0)throw new Error("reference host: idFor called with a handle this host never issued");return t},removeTag:(e,t)=>{const r=i.get(c.get(e)??"");r!==void 0&&(t===void 0?r.tags.clear():r.tags.delete(t),u.set(c.get(e)??"",new Set(r.tags)))},setTag:(e,t)=>{const r=c.get(e)??"",a=i.get(r);a!==void 0&&(a.tags.add(t),u.set(r,new Set(a.tags)))}},p={get:e=>({fetch:async()=>new Response(String(e))}),getByName:e=>({fetch:async()=>new Response(e)}),idForName:e=>`shard:${e}`,jurisdiction:e=>p},d=new Map,S={delete:async e=>d.delete(e),get:async e=>d.get(e),list:async e=>{const t=e?.prefix??"",r=new Map;for(const[a,o]of d)a.startsWith(t)&&r.set(a,o);return r},put:async(e,t)=>{d.set(e,structuredClone(t))}},l=new Map,m=new Map,y=(e,t)=>({attempts:t.attempts,functionPath:t.functionPath,id:e,scheduledFor:t.scheduledFor});return{awaitAlarmFired:async e=>{await new Promise(t=>{setTimeout(t,Math.max(0,e-Date.now())+30)})},cleanup:()=>{n.close(),s.alarmTimeout!==null&&clearTimeout(s.alarmTimeout);for(const e of l.values())clearTimeout(e.timer)},directory:p,kv:S,readFrames:e=>(i.get(c.get(e)??"")?.received??[]).filter(t=>typeof t=="string"),restoreSocket:(e,t)=>{const r={attachment:t,bufferedAmount:0,closed:!1,handle:null,id:e,raw:void 0,received:[],tags:new Set(u.get(e))};return i.set(e,r),h(r)},scheduler:{cancel:async e=>{const t=l.get(e);return t===void 0?!1:(clearTimeout(t.timer),l.delete(e),!0)},cron:async()=>{},deadLetter:{list:async()=>[...m].map(([e,t])=>y(e,t)),requeue:async e=>{const t=m.get(e);return t===void 0?!1:(m.delete(e),l.set(e,{...t,attempts:0,timer:void 0}),!0)}},list:async()=>[...l].map(([e,t])=>y(e,t)),schedule:async(e,t,r)=>{const a=D();let o;r?.at===void 0?o=Date.now()+(r?.delayMs??0):o=typeof r.at=="number"?r.at:r.at.getTime();const x=Math.max(0,o-Date.now()),F=setTimeout(()=>{l.delete(a)},x);return l.set(a,{args:t,attempts:0,functionPath:e,options:r??{},scheduledFor:o,timer:F}),{id:a,scheduledFor:o}}},simulateDeadLetter:async e=>{const t=l.get(e);return t===void 0?!1:(clearTimeout(t.timer),l.delete(e),m.set(e,{...t,attempts:(t.options.retry?.maxAttempts??5)+1,timer:void 0}),!0)},shard:b,simulateRecycle:()=>{i.clear()},socket:M}};export{R as createReferenceHost};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
const t=(e,o)=>e.getByName!==void 0?e.getByName(o):e.get(e.idForName(o));export{t as resolveShard};
|