@substrat-run/adapter-cloudflare 0.120.0 → 0.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -14,13 +14,14 @@
14
14
  * driving `drainDue`/`runDueSchedules` per scope (#461)
15
15
  */
16
16
  export { CloudflareScopeHost } from './host.js';
17
- export type { CloudflareScopeHostOptions, ConnectorDelegation, EventDrainDelegation, PeerSwitchDelegation, SystemSwitchDelegation, } from './host.js';
17
+ export type { CloudflareScopeHostOptions, ConnectorDelegation, EventDrainDelegation, PeerSwitchDelegation, ImportCursorDelegation, SystemSwitchDelegation, } from './host.js';
18
18
  export { defineScopeDO } from './scope-do.js';
19
19
  export { ControlPlaneDO } from './control-plane-do.js';
20
20
  export { OperationQueue } from './serialization.js';
21
21
  export { doScopedSql } from './sql.js';
22
22
  export { createDoTupleChecker } from './checker.js';
23
23
  export { definePlatformSweeperDO, PLATFORM_SWEEPER_NAME } from './platform-sweeper-do.js';
24
+ export { defineKickCoalescerDO, kickCoalescerName, CROSS_VERTICAL_KICK_WINDOW_MS, type KickCoalescerConfig, type KickCoalescerDo, type KickOutcome, type KickProducer, } from './kick-coalescer-do.js';
24
25
  export type { PlatformSweeperDoConfig, PlatformSweepOutcome } from './platform-sweeper-do.js';
25
26
  export { defineScopeSweeperDO, SCOPE_SWEEPER_NAME } from './scope-sweeper-do.js';
26
27
  export type { ScopeSweeperDo, ScopeSweeperDoConfig, ScopeSweepHost, ScopeSweepOutcome, ScopeSweepReport, } from './scope-sweeper-do.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAChD,YAAY,EACV,0BAA0B,EAC1B,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAC1F,YAAY,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AACjF,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC7E,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAChD,YAAY,EACV,0BAA0B,EAC1B,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAE1F,OAAO,EACL,qBAAqB,EACrB,iBAAiB,EACjB,6BAA6B,EAC7B,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,YAAY,GAClB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AACjF,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC7E,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -20,6 +20,8 @@ export { OperationQueue } from './serialization.js';
20
20
  export { doScopedSql } from './sql.js';
21
21
  export { createDoTupleChecker } from './checker.js';
22
22
  export { definePlatformSweeperDO, PLATFORM_SWEEPER_NAME } from './platform-sweeper-do.js';
23
+ // #1705 PR 2: the global bound on the router's cross-vertical kick, one object per producer.
24
+ export { defineKickCoalescerDO, kickCoalescerName, CROSS_VERTICAL_KICK_WINDOW_MS, } from './kick-coalescer-do.js';
23
25
  export { defineScopeSweeperDO, SCOPE_SWEEPER_NAME } from './scope-sweeper-do.js';
24
26
  export { createD1TenantStores, d1TenantRelationalStore, tenantStoreDatabaseName, } from './d1.js';
25
27
  export { blobStoreBucketName, createR2BlobStores, r2TenantBlobStore, } from './r2.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAQhD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAE1F,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAQjF,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,SAAS,CAAC;AAEjB,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,SAAS,CAAC;AAEjB,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,EAAE,uBAAuB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAShD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAC1F,6FAA6F;AAC7F,OAAO,EACL,qBAAqB,EACrB,iBAAiB,EACjB,6BAA6B,GAK9B,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAQjF,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,SAAS,CAAC;AAEjB,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,SAAS,CAAC;AAEjB,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,EAAE,uBAAuB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,57 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import type { ScopeId, TenantId } from '@substrat-run/contracts';
3
+ /**
4
+ * How long one producer's cross-vertical kicks coalesce (#1705 PR 2): after a pass STARTS, the
5
+ * next may start no sooner than this. A burst inside the window costs the pass that started it
6
+ * plus one trailing pass at the window's end, fleet-wide. It is not per isolate, because one
7
+ * Durable Object per producer holds the bit.
8
+ */
9
+ export declare const CROSS_VERTICAL_KICK_WINDOW_MS = 5000;
10
+ /** What one kick did: started a pass, joined the one running, or left it for the window's end. */
11
+ export type KickOutcome = 'ran' | 'coalesced' | 'deferred';
12
+ /** The producer a kick names: the scope the router resolved, and its tenant. Nothing else. */
13
+ export interface KickProducer {
14
+ tenantId: TenantId;
15
+ scopeId: ScopeId;
16
+ }
17
+ /** The RPC surface a `defineKickCoalescerDO` class exposes over its stub. */
18
+ export interface KickCoalescerDo {
19
+ kick(tenantId: TenantId, scopeId: ScopeId): Promise<KickOutcome>;
20
+ }
21
+ export interface KickCoalescerConfig<Env> {
22
+ /** Default {@link CROSS_VERTICAL_KICK_WINDOW_MS}. */
23
+ windowMs?: number;
24
+ /**
25
+ * One pass for this producer. CODE-TIME, closed over like `definePlatformSweeperDO`'s
26
+ * `sweep`, since a Durable Object cannot receive a closure over RPC. It is where the
27
+ * authority lives: the deployment runs `runCrossVerticalFrom` here with the same reach and
28
+ * gates as its sweep, so this object decides only WHEN, never what or for whom.
29
+ */
30
+ run(env: Env, producer: KickProducer): Promise<void>;
31
+ /** A pass that threw. Never rethrown: a lost kick costs latency, and the sweep is the backstop. */
32
+ onError?(err: unknown, env: Env): void;
33
+ }
34
+ /** The one instance per producer: `ns.get(ns.idFromName(kickCoalescerName(tenantId, scopeId)))`. */
35
+ export declare function kickCoalescerName(tenantId: TenantId, scopeId: ScopeId): string;
36
+ /**
37
+ * `defineKickCoalescerDO` — the global bound on the router kick (#1705 PR 2).
38
+ *
39
+ * The kick is raised by a response header that tenant code sets, so a vertical can raise it on
40
+ * every response it serves. Each one is a producer-scoped cross-vertical pass: a directory read,
41
+ * registry reads, and up to `maxConsumers × (1 + 2 × sources)` `/internal` calls. The phase caps
42
+ * one pass, not passes per unit of time. This object is that second cap, and it holds nothing
43
+ * else: no authority and no data, only a start time and a dirty bit.
44
+ *
45
+ * - A kick with no pass running and none started within the window starts one, and waits for it.
46
+ * - A kick while a pass runs marks the producer dirty and returns at once. When the pass ends,
47
+ * a dirty producer gets one trailing pass at the window's end, by alarm.
48
+ * - A kick inside the window marks it dirty and returns at once. The same alarm runs the
49
+ * trailing pass.
50
+ *
51
+ * So passes for one producer start at least `windowMs` apart. A burst of N kicks costs one pass
52
+ * plus one trailing pass, and nothing a tenant sends can raise that. The trailing pass is what
53
+ * keeps coalescing lossless: an event committed after the first pass read its outbox is still
54
+ * moved within one window, not left for the next sweep.
55
+ */
56
+ export declare function defineKickCoalescerDO<Env>(config: KickCoalescerConfig<Env>): new (ctx: DurableObjectState, env: Env) => DurableObject<Env> & KickCoalescerDo;
57
+ //# sourceMappingURL=kick-coalescer-do.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kick-coalescer-do.d.ts","sourceRoot":"","sources":["../src/kick-coalescer-do.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEjE;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B,OAAQ,CAAC;AAEnD,kGAAkG;AAClG,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,WAAW,GAAG,UAAU,CAAC;AAE3D,8FAA8F;AAC9F,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;CAClE;AAED,MAAM,WAAW,mBAAmB,CAAC,GAAG;IACtC,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,mGAAmG;IACnG,OAAO,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,GAAG,IAAI,CAAC;CACxC;AAED,oGAAoG;AACpG,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAE9E;AAUD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EACvC,MAAM,EAAE,mBAAmB,CAAC,GAAG,CAAC,GAC/B,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,GAAG,KAAK,aAAa,CAAC,GAAG,CAAC,GAAG,eAAe,CAwEjF"}
@@ -0,0 +1,106 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ /**
3
+ * How long one producer's cross-vertical kicks coalesce (#1705 PR 2): after a pass STARTS, the
4
+ * next may start no sooner than this. A burst inside the window costs the pass that started it
5
+ * plus one trailing pass at the window's end, fleet-wide. It is not per isolate, because one
6
+ * Durable Object per producer holds the bit.
7
+ */
8
+ export const CROSS_VERTICAL_KICK_WINDOW_MS = 5_000;
9
+ /** The one instance per producer: `ns.get(ns.idFromName(kickCoalescerName(tenantId, scopeId)))`. */
10
+ export function kickCoalescerName(tenantId, scopeId) {
11
+ return `${tenantId}:${scopeId}`;
12
+ }
13
+ /**
14
+ * `defineKickCoalescerDO` — the global bound on the router kick (#1705 PR 2).
15
+ *
16
+ * The kick is raised by a response header that tenant code sets, so a vertical can raise it on
17
+ * every response it serves. Each one is a producer-scoped cross-vertical pass: a directory read,
18
+ * registry reads, and up to `maxConsumers × (1 + 2 × sources)` `/internal` calls. The phase caps
19
+ * one pass, not passes per unit of time. This object is that second cap, and it holds nothing
20
+ * else: no authority and no data, only a start time and a dirty bit.
21
+ *
22
+ * - A kick with no pass running and none started within the window starts one, and waits for it.
23
+ * - A kick while a pass runs marks the producer dirty and returns at once. When the pass ends,
24
+ * a dirty producer gets one trailing pass at the window's end, by alarm.
25
+ * - A kick inside the window marks it dirty and returns at once. The same alarm runs the
26
+ * trailing pass.
27
+ *
28
+ * So passes for one producer start at least `windowMs` apart. A burst of N kicks costs one pass
29
+ * plus one trailing pass, and nothing a tenant sends can raise that. The trailing pass is what
30
+ * keeps coalescing lossless: an event committed after the first pass read its outbox is still
31
+ * moved within one window, not left for the next sweep.
32
+ */
33
+ export function defineKickCoalescerDO(config) {
34
+ const windowMs = config.windowMs ?? CROSS_VERTICAL_KICK_WINDOW_MS;
35
+ return class KickCoalescerDO extends DurableObject {
36
+ /** The in-flight pass, when one is running. */
37
+ #running = null;
38
+ async kick(tenantId, scopeId) {
39
+ const prior = await this.ctx.storage.get('state');
40
+ const state = prior ?? { producer: { tenantId, scopeId }, lastStartedAt: 0, dirty: false };
41
+ if (this.#running) {
42
+ await this.ctx.storage.put('state', { ...state, dirty: true });
43
+ return 'coalesced';
44
+ }
45
+ const now = Date.now();
46
+ if (now - state.lastStartedAt < windowMs) {
47
+ await this.ctx.storage.put('state', { ...state, dirty: true });
48
+ await this.#armFor(state.lastStartedAt);
49
+ return 'deferred';
50
+ }
51
+ await this.#pass(state);
52
+ return 'ran';
53
+ }
54
+ /**
55
+ * The window's end: the trailing pass if a kick arrived that no pass has covered, and
56
+ * otherwise the end of this object. Its storage is deleted, so a producer that kicked once
57
+ * leaves nothing behind. That waits for the window's end, so a kick after the delete may run
58
+ * at once without breaking the bound.
59
+ */
60
+ async alarm() {
61
+ const state = await this.ctx.storage.get('state');
62
+ if (!state || this.#running)
63
+ return;
64
+ // The window holds even against an early alarm: re-arm for its end rather than act.
65
+ if (Date.now() - state.lastStartedAt < windowMs) {
66
+ await this.#armFor(state.lastStartedAt);
67
+ return;
68
+ }
69
+ if (state.dirty) {
70
+ await this.#pass(state);
71
+ return;
72
+ }
73
+ await this.ctx.storage.deleteAlarm();
74
+ await this.ctx.storage.deleteAll();
75
+ }
76
+ async #pass(state) {
77
+ const startedAt = Date.now();
78
+ await this.ctx.storage.put('state', { ...state, lastStartedAt: startedAt, dirty: false });
79
+ this.#running = (async () => {
80
+ try {
81
+ await config.run(this.env, state.producer);
82
+ }
83
+ catch (err) {
84
+ config.onError?.(err, this.env);
85
+ }
86
+ })();
87
+ try {
88
+ await this.#running;
89
+ }
90
+ finally {
91
+ this.#running = null;
92
+ // Always, at this window's end: the alarm runs the trailing pass if a kick arrived since
93
+ // this one started, and clears this object's storage if none did.
94
+ await this.#armFor(startedAt);
95
+ }
96
+ }
97
+ /** Arm the trailing pass for the end of the window that started at `startedAt`. */
98
+ async #armFor(startedAt) {
99
+ const at = startedAt + windowMs;
100
+ const existing = await this.ctx.storage.getAlarm();
101
+ if (existing === null || existing > at)
102
+ await this.ctx.storage.setAlarm(at);
103
+ }
104
+ };
105
+ }
106
+ //# sourceMappingURL=kick-coalescer-do.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kick-coalescer-do.js","sourceRoot":"","sources":["../src/kick-coalescer-do.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
2
  import { type WireFailure } from '@substrat-run/contracts';
3
- import { type ModuleRegistration, type OperationHandler } from '@substrat-run/kernel';
3
+ import { type SwitchSql, type ModuleRegistration, type OperationHandler } from '@substrat-run/kernel';
4
4
  /**
5
5
  * `defineScopeDO` — one Durable Object per scope, the CF analogue of a single
6
6
  * `SqliteScopeHost` scope runtime (D-14). It closes over a CODE-TIME module set
@@ -42,6 +42,12 @@ export type CapabilityAttachmentReply<T> = {
42
42
  } | {
43
43
  failure: WireFailure;
44
44
  };
45
+ /**
46
+ * The kernel's two-method SQL handle (`SwitchSql`) over a Durable Object's storage — what the
47
+ * shared switch rules (#1666) and the directory's switch record (#1674) run through. One
48
+ * wrapper for both DOs, as the pure adapter has one `switchSqlOf`.
49
+ */
50
+ export declare function switchSqlOver(sql: SqlStorage): SwitchSql;
45
51
  export declare function splitSqlStatements(sql: string): string[];
46
52
  export declare function defineScopeDO(modules: ModuleRegistration[], bareOps: Record<string, OperationHandler<never, unknown>>): new (ctx: DurableObjectState, env: ScopeDoEnv) => DurableObject;
47
53
  //# sourceMappingURL=scope-do.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAWL,KAAK,WAAW,EAqCjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAmDL,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EA8DtB,MAAM,sBAAsB,CAAC;AA6E9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAwaD;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAgDxG,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CA48IjE"}
1
+ {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAWL,KAAK,WAAW,EAqCjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAiCL,KAAK,SAAS,EAkBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAgEtB,MAAM,sBAAsB,CAAC;AA+E9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAwaD;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAgDxG;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CA0gJjE"}
package/dist/scope-do.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
2
  import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
3
- import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
3
+ import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
4
4
  import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
5
5
  import { OperationQueue } from './serialization.js';
6
6
  import { doScopedSql, doSpineSql } from './sql.js';
@@ -414,6 +414,19 @@ function cellToJson(v) {
414
414
  */
415
415
  const IS_CREATE_TRIGGER = /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i;
416
416
  const ENDS_WITH_END = /\bEND\s*$/i;
417
+ /**
418
+ * The kernel's two-method SQL handle (`SwitchSql`) over a Durable Object's storage — what the
419
+ * shared switch rules (#1666) and the directory's switch record (#1674) run through. One
420
+ * wrapper for both DOs, as the pure adapter has one `switchSqlOf`.
421
+ */
422
+ export function switchSqlOver(sql) {
423
+ return {
424
+ all: (q, ...params) => sql.exec(q, ...params).toArray(),
425
+ run: (q, ...params) => {
426
+ sql.exec(q, ...params);
427
+ },
428
+ };
429
+ }
417
430
  export function splitSqlStatements(sql) {
418
431
  const out = [];
419
432
  let cur = '';
@@ -601,11 +614,8 @@ export function defineScopeDO(modules, bareOps) {
601
614
  this.crossVertical.register(manifest, registration.imports);
602
615
  this.modules.set(manifest.id, {
603
616
  id: manifest.id,
604
- migrations: [
605
- ...(registration.migrations ?? []),
606
- ...searchIndexMigrations(manifest.id, manifest.searchables),
607
- ...listIndexMigrations(manifest.id, manifest.lists),
608
- ],
617
+ // The order the kernel writes once (#1677): authored, then search, then list indexes.
618
+ migrations: moduleMigrations(registration),
609
619
  consumers: Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => ({
610
620
  eventType,
611
621
  handler,
@@ -889,6 +899,35 @@ export function defineScopeDO(modules, bareOps) {
889
899
  }));
890
900
  return { consumes, cursors };
891
901
  }
902
+ /**
903
+ * #1705 PR 2: does this DO hold a scope PROVISIONED here for `tenantId`?
904
+ *
905
+ * A CP-less deployment has no directory to ask whether it serves a scope. What it does
906
+ * have is what provisioning wrote: `provisionScopeLocal` projects the vertical's role
907
+ * definitions under the tenant (`_substrat_roles`), and a restore re-projects them. A scope
908
+ * this deployment never provisioned has none of that. Its DO is empty, and a cross-vertical
909
+ * verb answering from it is a wrong answer rather than a failure: a watermark of "never
910
+ * read", or a delivery journaled into a scope that is not the install. A scope provisioned
911
+ * for ANOTHER tenant has roles under that tenant's id only, so the same read is K-3's pair
912
+ * check too.
913
+ *
914
+ * Read without migrating, so asking about a foreign scope leaves its DO as empty as it found it.
915
+ */
916
+ async servesTenant(tenantId) {
917
+ const hasRoles = this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_substrat_roles'`).toArray().length > 0;
918
+ if (!hasRoles)
919
+ return false;
920
+ return this.sql.exec('SELECT 1 FROM _substrat_roles WHERE tenant_id = ? LIMIT 1', tenantId).toArray().length > 0;
921
+ }
922
+ /** #1705 PR 2: the outbox's insertion mark (`OUTBOX_MARK_SQL`). */
923
+ outboxMark() {
924
+ return Number(this.sql.exec(OUTBOX_MARK_SQL).toArray()[0]?.mark ?? 0);
925
+ }
926
+ /** #1705 PR 2: exported-type rows added above `mark`. */
927
+ exportedSince(types, mark) {
928
+ const q = exportedSinceQuery(types, mark);
929
+ return Number(this.sql.exec(q.sql, ...q.params).toArray()[0]?.n ?? 0);
930
+ }
892
931
  /**
893
932
  * #1705: apply a batch another vertical exported. The pure adapter's `deliverToPeer`
894
933
  * body, on this scope's queue: the compare-and-set on the watermark, a dead letter per
@@ -1002,10 +1041,13 @@ export function defineScopeDO(modules, bareOps) {
1002
1041
  // on it would claim twice the egress it performed, and would drift again
1003
1042
  // with any future index. The queue has this scope to itself, so a count
1004
1043
  // taken here is the count the loop below goes on to change.
1005
- const placeholders = eventIds.map(() => '?').join(',');
1044
+ //
1045
+ // The ids travel as ONE JSON array (#1776): a DO binds at most 100 parameters, and a
1046
+ // drain batch is 200 by default, so one `?` per id failed every default drain. `IN` over
1047
+ // a subquery is a set test, as the list was, so a repeated id is still counted once.
1006
1048
  const drained = this.sql
1007
1049
  .exec(`SELECT COUNT(*) AS c FROM _substrat_outbox
1008
- WHERE drained_at IS NULL AND id IN (${placeholders})`, ...eventIds)
1050
+ WHERE drained_at IS NULL AND id IN (SELECT value FROM json_each(?))`, JSON.stringify(eventIds))
1009
1051
  .toArray()[0].c;
1010
1052
  for (const id of eventIds) {
1011
1053
  // Only an UNDRAINED row is stamped, so a replayed batch cannot move an
@@ -1033,17 +1075,19 @@ export function defineScopeDO(modules, bareOps) {
1033
1075
  */
1034
1076
  async redrainEvents(drainedBefore) {
1035
1077
  return await this.queue.enqueue(() => {
1036
- const ids = this.sql
1037
- .exec(`SELECT id FROM _substrat_outbox
1038
- WHERE drained_at IS NOT NULL AND drained_at < ?
1039
- ORDER BY id LIMIT ?`, drainedBefore, REDRAIN_BATCH)
1040
- .toArray();
1041
- if (ids.length === 0)
1078
+ // The batch is chosen by ONE subquery, written once, that both statements read. It
1079
+ // used to be chosen as a list of ids bound back one `?` each, which a DO refuses past
1080
+ // 100 parameters (#1776) while the batch is 5000. The two statements run in the same
1081
+ // synchronous turn, so nothing writes between them, and `ORDER BY id` over the primary
1082
+ // key makes the LIMIT pick the same rows twice: the count is the rows the update clears.
1083
+ const batch = `SELECT id FROM _substrat_outbox
1084
+ WHERE drained_at IS NOT NULL AND drained_at < ?
1085
+ ORDER BY id LIMIT ?`;
1086
+ const reopened = this.sql.exec(`SELECT COUNT(*) AS c FROM (${batch})`, drainedBefore, REDRAIN_BATCH).toArray()[0].c;
1087
+ if (reopened === 0)
1042
1088
  return 0;
1043
- // By id, not by the window again: the rows just chosen are exactly the rows
1044
- // cleared, so the count returned cannot drift from what the statement touched.
1045
- this.sql.exec(`UPDATE _substrat_outbox SET drained_at = NULL WHERE id IN (${ids.map(() => '?').join(',')})`, ...ids.map((r) => r.id));
1046
- return ids.length;
1089
+ this.sql.exec(`UPDATE _substrat_outbox SET drained_at = NULL WHERE id IN (${batch})`, drainedBefore, REDRAIN_BATCH);
1090
+ return reopened;
1047
1091
  });
1048
1092
  }
1049
1093
  /**
@@ -1459,6 +1503,11 @@ export function defineScopeDO(modules, bareOps) {
1459
1503
  * which is the floor this whole surface sits on.
1460
1504
  */
1461
1505
  const liveSince = this.liveHighWaterMark();
1506
+ // #1705 PR 2: the outbox's insertion mark, so the envelope can say whether this
1507
+ // invoke (or a consumer in its tail) committed an exported type. Only in a deployment
1508
+ // that exports something, and never for a read-only session, which commits nothing.
1509
+ const exportTypes = impersonation?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
1510
+ const exportMark = exportTypes.length > 0 ? this.outboxMark() : null;
1462
1511
  let result;
1463
1512
  let committedVersion = null;
1464
1513
  // #116: set when this invocation was answered from a recording rather
@@ -1594,9 +1643,14 @@ export function defineScopeDO(modules, bareOps) {
1594
1643
  // call's own work — the same tail its consumers' emits are stamped in.
1595
1644
  if (!replayed)
1596
1645
  await this.settleCommitted(tenantId, scopeId, liveSince, this.invocationId);
1646
+ // After the tail, so an exported type a consumer emitted in it counts too.
1647
+ const exported = exportMark !== null && !replayed ? this.exportedSince(exportTypes, exportMark) : 0;
1597
1648
  return {
1598
1649
  result,
1599
1650
  platformRequests: signals.platformRequests,
1651
+ // #1705 PR 2: the coordinator fires `onExportedEvents` from this. Omitted at 0, so
1652
+ // the envelope of a deployment that exports nothing is byte-for-byte what it was.
1653
+ ...(exported > 0 ? { exported } : {}),
1600
1654
  ...(impersonation ? { impersonation: { honoured: true } } : {}),
1601
1655
  // #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
1602
1656
  ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
@@ -2166,12 +2220,7 @@ export function defineScopeDO(modules, bareOps) {
2166
2220
  }
2167
2221
  /** The kernel's schedule-switch SQL (#1666), over this DO's storage. */
2168
2222
  switchSql() {
2169
- return {
2170
- all: (sql, ...params) => this.sql.exec(sql, ...params).toArray(),
2171
- run: (sql, ...params) => {
2172
- this.sql.exec(sql, ...params);
2173
- },
2174
- };
2223
+ return switchSqlOver(this.sql);
2175
2224
  }
2176
2225
  /**
2177
2226
  * Where a module's schedules stand on this scope (#383, #1666) — the kernel's
@@ -2215,6 +2264,19 @@ export function defineScopeDO(modules, bareOps) {
2215
2264
  async switchSystemSchedules(moduleId, scopeId, to, at) {
2216
2265
  return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2217
2266
  }
2267
+ /**
2268
+ * The replay lever on this scope (#1705 PR 3): the kernel's `moveImportCursor`, queued with
2269
+ * every `importApply` on this scope and run as one `transactionSync`. A delivery on the same
2270
+ * edge is therefore wholly before the move, or refused by its compare-and-set after it.
2271
+ * Migrated first: the rows a replay moves aside go to `_substrat_import_replays`.
2272
+ */
2273
+ async importCursorMove(input) {
2274
+ await this.ensureMigrations();
2275
+ // The consumer's own running imports decide whether the edge exists: read here, in the
2276
+ // deployment whose handlers would run, never from the platform's request.
2277
+ const imports = this.crossVertical.consumes();
2278
+ return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => moveImportCursor(this.switchSql(), { ...input, imports })));
2279
+ }
2218
2280
  /**
2219
2281
  * Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
2220
2282
  * the schedule switch's statement with a `vertical:` subject. Queued, one transaction.
@@ -2331,9 +2393,11 @@ export function defineScopeDO(modules, bareOps) {
2331
2393
  out[t] = { observedAt: null, stateAt: null, stateOutcome: null };
2332
2394
  if (types.length === 0)
2333
2395
  return out;
2334
- const marks = types.map(() => '?').join(', ');
2396
+ // Each list travels as ONE bound JSON array (#1776): a DO binds at most 100 parameters,
2397
+ // and nothing caps how many event types the registered modules declare a window for.
2335
2398
  for (const row of this.sql
2336
- .exec(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox WHERE type IN (${marks}) GROUP BY type`, ...types)
2399
+ .exec(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
2400
+ WHERE type IN (SELECT value FROM json_each(?)) GROUP BY type`, JSON.stringify(types))
2337
2401
  .toArray()) {
2338
2402
  if (row.at !== null)
2339
2403
  out[row.type].observedAt = row.at;
@@ -2341,7 +2405,7 @@ export function defineScopeDO(modules, bareOps) {
2341
2405
  const keys = types.map((t) => `freshness:${t}`);
2342
2406
  for (const row of this.sql
2343
2407
  .exec(`SELECT schedule_op, last_run_at, last_status FROM _substrat_schedule_state
2344
- WHERE kind = 'freshness' AND schedule_op IN (${marks})`, ...keys)
2408
+ WHERE kind = 'freshness' AND schedule_op IN (SELECT value FROM json_each(?))`, JSON.stringify(keys))
2345
2409
  .toArray()) {
2346
2410
  const t = row.schedule_op.slice('freshness:'.length);
2347
2411
  if (out[t]) {