@lunora/platform 1.0.0-alpha.1 → 1.0.0-alpha.11

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.
@@ -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-CINxcosS.mjs";
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-CINxcosS.js";
2
+ import "../packem_shared/socket-host.d-Dy2QiVn6.js";
@@ -1 +1 @@
1
- import{createReferenceHost as o}from"../packem_shared/createReferenceHost-GO8Hp4ft.mjs";import{defineHostContractSuite as f}from"./suite.mjs";export{o as createReferenceHost,f as defineHostContractSuite};
1
+ import{createReferenceHost as o}from"../packem_shared/createReferenceHost-baEGrNOu.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, n as SocketHandle, c as SchedulerHost, g as ShardHost, o as SocketHost } from "../packem_shared/socket-host.d-CINxcosS.mjs";
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, n as SocketHandle, c as SchedulerHost, g as ShardHost, o as SocketHost } from "../packem_shared/socket-host.d-CINxcosS.js";
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 k}from"../packem_shared/resolveShard-BzKOUEO4.mjs";const E=(n,m,y)=>{const{describe:u,expect:t,it:c}=y;u(`host contract: ${n}`,()=>{const p=async()=>m(),l=a=>a.createSocket?.()??{},o=async a=>{const e=await p();try{await a(e)}finally{e.cleanup?.()}};u("ShardHost",()=>{c("serializes mutations so no two closures interleave",async()=>{t.assertions(1),await o(async a=>{const e=[];await Promise.all([a.shard.runSerialized(async()=>{e.push("a-start"),await new Promise(i=>{setTimeout(i,10)}),e.push("a-end")}),a.shard.runSerialized(async()=>{e.push("b-start"),await new Promise(i=>{setTimeout(i,5)}),e.push("b-end")})]);const s=e.join("").includes("a-starta-end"),r=e.join("").includes("b-startb-end");t(s&&r).toBe(!0)})}),c("rolls back a transaction that throws",async()=>{t.assertions(2),await o(async a=>{await a.shard.transaction(async()=>{a.shard.sql.exec("CREATE TABLE IF NOT EXISTS rollback_test (id INTEGER PRIMARY KEY)"),a.shard.sql.exec("INSERT INTO rollback_test (id) VALUES (1)")}),await t(a.shard.transaction(async()=>{throw a.shard.sql.exec("INSERT INTO rollback_test (id) VALUES (2)"),new Error("boom")})).rejects.toThrow("boom");const e=a.shard.sql.exec("SELECT id FROM rollback_test WHERE id = 2").toArray();t(e).toHaveLength(0)})}),c("rejects with the value the closure threw, and stays usable",async()=>{t.assertions(3),await o(async a=>{const e=Object.assign(new Error("closure failed"),{code:"NOT_FOUND",status:404});await t(a.shard.transaction(()=>Promise.reject(e))).rejects.toBe(e),await t(a.shard.runSerialized(()=>Promise.reject(e))).rejects.toBe(e),await t(a.shard.runSerialized(async()=>a.shard.transaction(async()=>"still here"))).resolves.toBe("still here")})}),c("observes its own writes inside a transaction",async()=>{t.assertions(1),await o(async a=>{const e=await a.shard.transaction(async()=>(a.shard.sql.exec("CREATE TABLE IF NOT EXISTS ryw_test (id INTEGER PRIMARY KEY, value TEXT)"),a.shard.sql.exec("INSERT INTO ryw_test (id, value) VALUES (1, 'hello')"),a.shard.sql.exec("SELECT value FROM ryw_test WHERE id = 1").toArray()[0]?.value));t(e).toBe("hello")})}),c("keeps overlapping transactions atomic",async()=>{t.assertions(2),await o(async a=>{a.shard.sql.exec("CREATE TABLE IF NOT EXISTS overlap_test (id TEXT PRIMARY KEY)");const e=a.shard.transaction(async()=>{a.shard.sql.exec("INSERT INTO overlap_test (id) VALUES ('A')"),await new Promise(i=>{setTimeout(i,20)}),a.shard.sql.exec("INSERT INTO overlap_test (id) VALUES ('B')")}),s=a.shard.transaction(async()=>{a.shard.sql.exec("INSERT INTO overlap_test (id) VALUES ('C')")});await Promise.all([e,s]);const r=a.shard.sql.exec("SELECT id FROM overlap_test ORDER BY id").toArray();t(r.map(i=>i.id)).toStrictEqual(["A","B","C"]),await t(a.shard.transaction(async()=>{throw a.shard.sql.exec("INSERT INTO overlap_test (id) VALUES ('D')"),new Error("boom")})).rejects.toThrow("boom")})}),c("returns a cursor that buffers, yields one row, and iterates",async()=>{t.assertions(3),await o(async a=>{await a.shard.transaction(async()=>{a.shard.sql.exec("CREATE TABLE IF NOT EXISTS cursor_test (id INTEGER PRIMARY KEY)"),a.shard.sql.exec("INSERT INTO cursor_test (id) VALUES (1)"),a.shard.sql.exec("INSERT INTO cursor_test (id) VALUES (2)")}),t(a.shard.sql.exec("SELECT id FROM cursor_test ORDER BY id").toArray()).toHaveLength(2),t(a.shard.sql.exec("SELECT id FROM cursor_test WHERE id = 1").one()).toEqual({id:1}),t([...a.shard.sql.exec("SELECT id FROM cursor_test ORDER BY id")]).toHaveLength(2)})}),c("reports a pending alarm, and clears it once fired",async()=>{t.assertions(2),await o(async a=>{const e=Date.now()+50;if(await a.shard.alarms.set(e),t(await a.shard.alarms.get()).toBe(e),a.awaitAlarmFired===void 0){t(await a.shard.alarms.get()).toBe(e);return}await a.awaitAlarmFired(e),t(await a.shard.alarms.get()).toBeNull()})}),c("deletes a pending alarm",async()=>{t.assertions(1),await o(async a=>{await a.shard.alarms.set(Date.now()+1e4),await a.shard.alarms.delete(),t(await a.shard.alarms.get()).toBeNull()})})}),u("SocketHost",()=>{c("accepts a socket and can send/close",async()=>{await o(async a=>{const e=a.socket.accept(l(a),{user:"ada"});t(a.socket.idFor(e)).toBeDefined(),e.send("hello"),t(a.socket.getSockets().map(s=>a.socket.idFor(s))).toContain(a.socket.idFor(e)),a.readFrames!==void 0&&t(a.readFrames(e)).toStrictEqual(["hello"]),t(()=>{e.close(1e3,"done")}).not.toThrow()})}),c("round-trips an attachment on a live socket",async()=>{t.assertions(1),await o(async a=>{const e={roomId:"room-1",roles:["admin"]},s=a.socket.accept(l(a),e);t(s.deserializeAttachment()).toEqual(e)})}),c("round-trips attachments across a recycle",async a=>{await o(async e=>{if(e.simulateRecycle===void 0||e.restoreSocket===void 0){a.skip(`${n} does not implement simulateRecycle/restoreSocket`);return}t.assertions(1);const s={roomId:"room-1",roles:["admin"]},r=e.socket.accept(l(e),s);e.simulateRecycle();const i=e.restoreSocket(e.socket.idFor(r),s);t(i.deserializeAttachment()).toEqual(s)})}),c("keeps idFor stable across repeated calls within a wake",async()=>{t.assertions(1),await o(async a=>{const e=a.socket.accept(l(a),{}),s=a.socket.idFor(e),r=a.socket.idFor(e);t(r).toBe(s)})}),c("keeps idFor stable across a recycle",async a=>{await o(async e=>{if(e.simulateRecycle===void 0||e.restoreSocket===void 0){a.skip(`${n} does not implement simulateRecycle/restoreSocket`);return}t.assertions(1);const s={roomId:"room-1",roles:["admin"]},r=e.socket.accept(l(e),s),i=e.socket.idFor(r);e.simulateRecycle();const d=e.restoreSocket(i,s);t(e.socket.idFor(d)).toBe(i)})}),c("returns exactly the sockets carrying an accept-time tag",async()=>{t.assertions(4),await o(async a=>{const e=a.socket.accept(l(a),{},["room-a"]),s=a.socket.accept(l(a),{},["room-b"]),r=a.socket.accept(l(a),{}),i=d=>a.socket.idFor(d);t(a.socket.getSockets("room-a").map(i)).toStrictEqual([i(e)]),t(a.socket.getSockets("room-b").map(i)).toStrictEqual([i(s)]),t(a.socket.getSockets("room-c").map(i)).toStrictEqual([]),t(a.socket.getSockets().map(i)).toContain(i(r))})}),c("accepts the portable budget of nine caller tags",async()=>{t.assertions(9);const a=await p(),e=Array.from({length:9},(i,d)=>`tag-${String(d)}`),s=a.socket.accept(l(a),{},e),r=i=>a.socket.idFor(i);for(const i of e)t(a.socket.getSockets(i).map(r)).toStrictEqual([r(s)]);a.cleanup?.()}),c("resolves a raw socket back to its handle",async()=>{t.assertions(2),await o(async a=>{const e=l(a),s=a.socket.accept(e,{}),r=a.socket.handleFor(e);t(r!==void 0&&a.socket.idFor(r)).toBe(a.socket.idFor(s)),t(a.socket.handleFor(l(a))).toBeUndefined()})}),c("reports a plausible outbound queue depth, if any",async()=>{t.assertions(1),await o(async a=>{const e=a.socket.accept(l(a),{}),{bufferedAmount:s}=e;t(s===void 0||typeof s=="number"&&s>=0).toBe(!0)})}),c("retags a live socket when the host declares mutable tags",async a=>{await o(async e=>{if(e.socket.setTag===void 0){a.skip(`${n} does not implement mutable socket tags (setTag)`);return}t.assertions(2);const s=e.socket.accept(l(e),{});e.socket.setTag(s,"room-a"),t(e.socket.getSockets("room-a").map(r=>e.socket.idFor(r))).toStrictEqual([e.socket.idFor(s)]),e.socket.removeTag?.(s,"room-a"),t(e.socket.getSockets("room-a").map(r=>e.socket.idFor(r))).toStrictEqual([])})})}),u("ShardDirectory",()=>{c("resolves shard keys deterministically",async()=>{t.assertions(1),await o(async a=>{const e=await k(a.directory,"tenant-42").fetch(new Request("http://localhost/")),s=await k(a.directory,"tenant-42").fetch(new Request("http://localhost/"));await t(e.text()).resolves.toBe(await s.text())})}),c("dispatches fetch to a resolved stub",async()=>{t.assertions(1),await o(async a=>{const s=await k(a.directory,"tenant-42").fetch(new Request("http://localhost/"));t(s).toBeInstanceOf(Response)})})}),u("SchedulerHost",()=>{c("schedules a job for a future timestamp",async a=>{await o(async e=>{if(e.scheduler===void 0){a.skip(`${n} does not implement SchedulerHost`);return}t.assertions(2);const s=Date.now(),r=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:50});t(r.scheduledFor).toBeGreaterThanOrEqual(s+50),t(r.id).toBeDefined()})}),c("dispatches a scheduled job at least once",async a=>{await o(async e=>{if(e.scheduler===void 0){a.skip(`${n} does not implement SchedulerHost`);return}const s=e.scheduler.deadLetter!==void 0;if(!s&&e.awaitJobDispatched===void 0){a.skip(`${n} does not claim at-least-once delivery (no deadLetter) and supplies no awaitJobDispatched hook`);return}if(s&&t(e.awaitJobDispatched).toBeDefined(),e.awaitJobDispatched===void 0)return;const r=e.scheduler.list!==void 0;t.assertions(1+(s?1:0)+(r?1:0));const i=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:30});if(await t(e.awaitJobDispatched(i.id)).resolves.toBe(!0),r){const d=await e.scheduler.list?.();t(d?.some(w=>w.id===i.id)).toBe(!1)}})}),c("cancels a scheduled job",async a=>{await o(async e=>{if(e.scheduler===void 0){a.skip(`${n} does not implement SchedulerHost`);return}t.assertions(1);const s=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4}),r=await e.scheduler.cancel(s.id);t(r).toBe(!0)})}),c("reports a second cancel of the same job as false",async a=>{await o(async e=>{if(e.scheduler===void 0){a.skip(`${n} does not implement SchedulerHost`);return}t.assertions(2);const s=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});t(await e.scheduler.cancel(s.id)).toBe(!0),t(await e.scheduler.cancel(s.id)).toBe(!1)})}),c("gives two identical schedules independently cancellable ids",async a=>{await o(async e=>{if(e.scheduler===void 0){a.skip(`${n} does not implement SchedulerHost`);return}t.assertions(3);const s=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4}),r=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4});t(r.id).not.toBe(s.id),t(await e.scheduler.cancel(s.id)).toBe(!0),t(await e.scheduler.cancel(r.id)).toBe(!0)})}),c("lists a pending job with a zero attempt count",async a=>{await o(async e=>{if(e.scheduler?.list===void 0){a.skip(`${n} does not implement SchedulerHost.list`);return}t.assertions(2);const s=await e.scheduler.schedule("tasks/remind",{user:"ada"},{delayMs:1e4}),i=(await e.scheduler.list()).find(d=>d.id===s.id);t(i?.functionPath).toBe("tasks/remind"),t(i?.attempts).toBe(0)})}),c("keeps the pending and dead-letter listings disjoint",async a=>{await o(async e=>{if(e.scheduler?.list===void 0||e.scheduler.deadLetter===void 0||e.simulateDeadLetter===void 0){a.skip(`${n} does not implement scheduler.list/deadLetter/simulateDeadLetter`);return}t.assertions(2);const s=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});await e.simulateDeadLetter(s.id);const r=await e.scheduler.list(),i=await e.scheduler.deadLetter.list();t(r.some(d=>d.id===s.id)).toBe(!1),t(i.some(d=>d.id===s.id)).toBe(!0)})}),c("returns a requeued job to the pending set with a fresh budget",async a=>{await o(async e=>{if(e.scheduler?.list===void 0||e.scheduler.deadLetter===void 0||e.simulateDeadLetter===void 0){a.skip(`${n} does not implement scheduler.list/deadLetter/simulateDeadLetter`);return}t.assertions(4);const s=await e.scheduler.schedule("tasks/remind",{},{delayMs:1e4});await e.simulateDeadLetter(s.id),t(await e.scheduler.deadLetter.requeue(s.id)).toBe(!0);const i=(await e.scheduler.list()).find(w=>w.id===s.id);t(i).toBeDefined(),t(i?.attempts).toBe(0);const d=await e.scheduler.deadLetter.list();t(d.some(w=>w.id===s.id)).toBe(!1)})}),c("reports a requeue of an unparked job as false",async a=>{await o(async e=>{if(e.scheduler?.deadLetter===void 0){a.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",()=>{c("reads back a written value",async a=>{await o(async e=>{if(e.kv===void 0){a.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()})}),c("deletes a key idempotently",async a=>{await o(async e=>{if(e.kv===void 0){a.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()})}),c("enumerates exactly the keys under a prefix",async a=>{await o(async e=>{if(e.kv===void 0){a.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 s=await e.kv.list({prefix:"s:"}),r=await e.kv.list();t([...s.keys()].toSorted((i,d)=>i.localeCompare(d))).toStrictEqual(["s:a","s:b"]),t(r.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 ShardSqlCursor, type l as ShardSqlExec, type m as ShardStub, type n as SocketHandle, type o as SocketHost, type p as SqlRow, type T as TwoStepShardDirectory, r as resolveShard } from "./packem_shared/socket-host.d-CINxcosS.mjs";
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&lt;Body>` (the producer
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&lt;unknown>` so a plain-object fake satisfies it.
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&lt;Body>` (one delivered 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&lt;Body>` handed to a consumer. */
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,76 @@ 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
- 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, 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 };
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,
600
+ /**
601
+ * `@lunora/platform` — provider-neutral host contracts for Lunora.
602
+ *
603
+ * This package defines the structural interfaces that separate the Lunora
604
+ * engine from any specific host (Cloudflare Workers, AWS, Rivet, Node, etc.).
605
+ * It contains **types and capability metadata only** — near-zero runtime code.
606
+ *
607
+ * The contracts fall into four groups:
608
+ *
609
+ * 1. **Shard host** (`ShardHost`) — single-writer execution, transactions,
610
+ * local SQL, alarms, and background continuation per shard key.
611
+ * 2. **Socket host** (`SocketHost`) — hibernated WebSocket subscriptions with
612
+ * durable attachments and tagged fan-out.
613
+ * 3. **Shard directory** (`ShardDirectory`) — deterministic placement and RPC
614
+ * dispatch from shard keys to stubs.
615
+ * 4. **Scheduler host** (`SchedulerHost`) — durable delayed jobs, cron, and
616
+ * at-least-once dispatch.
617
+ *
618
+ * Plus canonical binding projections (`KVNamespaceLike`, `R2BucketLike`,
619
+ * `QueueBindingLike`, `D1DatabaseLike`, `VectorizeIndexLike`, …) and the
620
+ * `PlatformCapabilities` matrix that codegen uses to tailor emitted types per
621
+ * target.
622
+ *
623
+ * This package is **zero-dependency** and safe on every runtime (browser,
624
+ * workerd, Node). It is intended to be the leaf dependency every other
625
+ * `@lunora/*` package can import without creating cycles.
626
+ */
627
+ 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 ShardSqlCursor, type l as ShardSqlExec, type m as ShardStub, type n as SocketHandle, type o as SocketHost, type p as SqlRow, type T as TwoStepShardDirectory, r as resolveShard } from "./packem_shared/socket-host.d-CINxcosS.js";
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&lt;Body>` (the producer
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&lt;unknown>` so a plain-object fake satisfies it.
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&lt;Body>` (one delivered 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&lt;Body>` handed to a consumer. */
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,76 @@ 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
- 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, 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 };
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,
600
+ /**
601
+ * `@lunora/platform` — provider-neutral host contracts for Lunora.
602
+ *
603
+ * This package defines the structural interfaces that separate the Lunora
604
+ * engine from any specific host (Cloudflare Workers, AWS, Rivet, Node, etc.).
605
+ * It contains **types and capability metadata only** — near-zero runtime code.
606
+ *
607
+ * The contracts fall into four groups:
608
+ *
609
+ * 1. **Shard host** (`ShardHost`) — single-writer execution, transactions,
610
+ * local SQL, alarms, and background continuation per shard key.
611
+ * 2. **Socket host** (`SocketHost`) — hibernated WebSocket subscriptions with
612
+ * durable attachments and tagged fan-out.
613
+ * 3. **Shard directory** (`ShardDirectory`) — deterministic placement and RPC
614
+ * dispatch from shard keys to stubs.
615
+ * 4. **Scheduler host** (`SchedulerHost`) — durable delayed jobs, cron, and
616
+ * at-least-once dispatch.
617
+ *
618
+ * Plus canonical binding projections (`KVNamespaceLike`, `R2BucketLike`,
619
+ * `QueueBindingLike`, `D1DatabaseLike`, `VectorizeIndexLike`, …) and the
620
+ * `PlatformCapabilities` matrix that codegen uses to tailor emitted types per
621
+ * target.
622
+ *
623
+ * This package is **zero-dependency** and safe on every runtime (browser,
624
+ * workerd, Node). It is intended to be the leaf dependency every other
625
+ * `@lunora/*` package can import without creating cycles.
626
+ */
627
+ 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 e}from"./packem_shared/NOOP_EXECUTION_CONTEXT-YmXqH-jH.mjs";import{CLOUDFLARE_CAPABILITIES as O}from"./packem_shared/CLOUDFLARE_CAPABILITIES-B0yosrNL.mjs";import{resolveShard as I}from"./packem_shared/resolveShard-uGhAKuTB.mjs";export{O as CLOUDFLARE_CAPABILITIES,e as NOOP_EXECUTION_CONTEXT,I as resolveShard};
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-BzKOUEO4.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 J}from"node:sqlite";let S=0,x=0;const L=()=>(S+=1,`socket-${S}`),P=()=>(x+=1,`job-${x}`),z=s=>s===void 0?null:s,I=s=>typeof s=="string"?new TextEncoder().encode(s).buffer:s instanceof ArrayBuffer?s:ArrayBuffer.isView(s)?s.buffer.slice(s.byteOffset,s.byteOffset+s.byteLength):new ArrayBuffer(0),W=()=>{const s=new J(":memory:"),a={alarmAt:null,alarmTimeout:null,pending:[],running:!1},i=new Map,h=new Map,d=new Map,M={exec:(e,...t)=>{const n=s.prepare(e),r=t.map(z),l=e.trim().toLowerCase().startsWith("select")?n.all(...r):(n.run(...r),[]);return{[Symbol.iterator]:()=>l[Symbol.iterator](),one:()=>{if(l.length!==1)throw new Error(`expected exactly one row, got ${String(l.length)}`);return l[0]},toArray:()=>[...l]}}},F={all:async(e,t)=>s.prepare(e).all(...t),run:async(e,t)=>{const r=s.prepare(e).run(...t);return{rowsAffected:Number(r.changes)}}},g=()=>{if(a.running||a.pending.length===0)return;const e=a.pending.shift();e!==void 0&&(a.running=!0,e.function_().then(e.resolve,e.reject).finally(()=>{a.running=!1,g()}))},j=e=>new Promise((t,n)=>{a.pending.push({function_:e,reject:r=>{n(r)},resolve:r=>{t(r)}}),g()});let w=Promise.resolve();const y=async e=>{s.exec("BEGIN");try{const t=await e();return s.exec("COMMIT"),t}catch(t){throw s.exec("ROLLBACK"),t}},k={alarms:{delete:()=>{a.alarmAt=null,a.alarmTimeout!==null&&(clearTimeout(a.alarmTimeout),a.alarmTimeout=null)},get:()=>a.alarmAt,set:e=>{const t=typeof e=="number"?e:e.getTime();a.alarmAt=t,a.alarmTimeout!==null&&clearTimeout(a.alarmTimeout);const n=Math.max(0,t-Date.now());a.alarmTimeout=setTimeout(()=>{a.alarmAt=null,a.alarmTimeout=null},n)}},asyncSql:F,runSerialized:j,sql:M,transaction:e=>{const t=w.then(()=>y(e),()=>y(e));return w=t.then(()=>{},()=>{}),t},waitUntil:()=>{}},u=new WeakMap,p=e=>{const t={bufferedAmount:e.bufferedAmount,close:(n,r)=>{e.closed=!0},deserializeAttachment:()=>e.attachment,send:n=>{e.received.push(typeof n=="string"?n:I(n))},serializeAttachment:n=>{e.attachment=n,h.set(e.id,n)}};return e.handle=t,u.set(t,e.id),t},D={accept:(e,t,n)=>{const r=L(),c={attachment:t,bufferedAmount:0,closed:!1,raw:e,handle:null,id:r,received:[],tags:new Set(n)};return i.set(r,c),d.set(r,new Set(n)),t!==void 0&&h.set(r,t),p(c)},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=u.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 n=u.get(e)??"",r=i.get(n);r!==void 0&&(t===void 0?r.tags.clear():r.tags.delete(t),d.set(n,new Set(r.tags)))},setTag:(e,t)=>{const n=u.get(e)??"",r=i.get(n);r!==void 0&&(r.tags.add(t),d.set(n,new Set(r.tags)))}},b={get:e=>({fetch:async()=>new Response(String(e))}),getByName:e=>({fetch:async()=>new Response(e)}),idForName:e=>`shard:${e}`,jurisdiction:e=>b},m=new Map,B={delete:async e=>m.delete(e),get:async e=>m.get(e),list:async e=>{const t=e?.prefix??"",n=new Map;for(const[r,c]of m)r.startsWith(t)&&n.set(r,c);return n},put:async(e,t)=>{m.set(e,structuredClone(t))}},o=new Map,f=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=o.get(e);return t!==void 0&&await new Promise(n=>{setTimeout(n,Math.max(0,t.scheduledFor-Date.now())+30)}),v.has(e)},cleanup:()=>{s.close(),a.alarmTimeout!==null&&clearTimeout(a.alarmTimeout);for(const e of o.values())clearTimeout(e.timer)},directory:b,kv:B,readFrames:e=>(i.get(u.get(e)??"")?.received??[]).filter(t=>typeof t=="string"),restoreSocket:(e,t)=>{const n={attachment:t,bufferedAmount:0,closed:!1,handle:null,id:e,raw:void 0,received:[],tags:new Set(d.get(e))};return i.set(e,n),p(n)},scheduler:{cancel:async e=>{const t=o.get(e);return t===void 0?!1:(clearTimeout(t.timer),o.delete(e),!0)},cron:async()=>{},deadLetter:{list:async()=>[...f].map(([e,t])=>T(e,t)),requeue:async e=>{const t=f.get(e);return t===void 0?!1:(f.delete(e),o.set(e,{...t,attempts:0,timer:void 0}),!0)}},list:async()=>[...o].map(([e,t])=>T(e,t)),schedule:async(e,t,n)=>{const r=P();let c;n?.at===void 0?c=Date.now()+(n?.delayMs??0):c=typeof n.at=="number"?n.at:n.at.getTime();const l=Math.max(0,c-Date.now()),C=setTimeout(()=>{const A=o.get(r);A!==void 0&&(A.attempts+=1),v.add(r),o.delete(r)},l);return o.set(r,{args:t,attempts:0,functionPath:e,options:n??{},scheduledFor:c,timer:C}),{id:r,scheduledFor:c}}},simulateDeadLetter:async e=>{const t=o.get(e);return t===void 0?!1:(clearTimeout(t.timer),o.delete(e),f.set(e,{...t,attempts:(t.options.retry?.maxAttempts??5)+1,timer:void 0}),!0)},shard:k,simulateRecycle:()=>{i.clear()},socket:D}};export{W as createReferenceHost};
@@ -0,0 +1 @@
1
+ const a=(e,g,N)=>e.getByName!==void 0?e.getByName(g,N):e.get(e.idForName(g),N);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
- * `ShardDirectory` the provider-neutral contract for resolving shard keys to
184
- * callable stubs. On Cloudflare this is backed by `DurableObjectNamespace`
185
- * (`idFromName` + `get` + `jurisdiction`). On another provider it may be an
186
- * actor registry, a consistent-hash router, or a local in-process map.
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 engine relies on two capabilities:
189
- * 1. **Deterministic placement** a shard key always resolves to the same
190
- * logical shard (`idForName`).
191
- * 2. **RPC dispatch** — a resolved stub can receive a `fetch` request (or
192
- * equivalent RPC call) that the shard handles.
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
- * Placement hints (jurisdiction, region) are provider-mapped and may be
195
- * unsupported per the capability matrix.
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. Raw `BEGIN`/`COMMIT`/`ROLLBACK` are forbidden inside the closure;
405
- * the host manages the transaction boundary.
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, ShardSqlCursor as k, ShardSqlExec as l, ShardStub as m, SocketHandle as n, SocketHost as o, SqlRow as p, resolveShard as r };
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
- * `ShardDirectory` the provider-neutral contract for resolving shard keys to
184
- * callable stubs. On Cloudflare this is backed by `DurableObjectNamespace`
185
- * (`idFromName` + `get` + `jurisdiction`). On another provider it may be an
186
- * actor registry, a consistent-hash router, or a local in-process map.
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 engine relies on two capabilities:
189
- * 1. **Deterministic placement** a shard key always resolves to the same
190
- * logical shard (`idForName`).
191
- * 2. **RPC dispatch** — a resolved stub can receive a `fetch` request (or
192
- * equivalent RPC call) that the shard handles.
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
- * Placement hints (jurisdiction, region) are provider-mapped and may be
195
- * unsupported per the capability matrix.
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. Raw `BEGIN`/`COMMIT`/`ROLLBACK` are forbidden inside the closure;
405
- * the host manages the transaction boundary.
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, ShardSqlCursor as k, ShardSqlExec as l, ShardStub as m, SocketHandle as n, SocketHost as o, SqlRow as p, resolveShard as r };
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.1",
3
+ "version": "1.0.0-alpha.11",
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};