@substrat-run/adapter-cloudflare 0.120.0 → 0.121.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,CAkgJjE"}
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
@@ -1459,6 +1498,11 @@ export function defineScopeDO(modules, bareOps) {
1459
1498
  * which is the floor this whole surface sits on.
1460
1499
  */
1461
1500
  const liveSince = this.liveHighWaterMark();
1501
+ // #1705 PR 2: the outbox's insertion mark, so the envelope can say whether this
1502
+ // invoke (or a consumer in its tail) committed an exported type. Only in a deployment
1503
+ // that exports something, and never for a read-only session, which commits nothing.
1504
+ const exportTypes = impersonation?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
1505
+ const exportMark = exportTypes.length > 0 ? this.outboxMark() : null;
1462
1506
  let result;
1463
1507
  let committedVersion = null;
1464
1508
  // #116: set when this invocation was answered from a recording rather
@@ -1594,9 +1638,14 @@ export function defineScopeDO(modules, bareOps) {
1594
1638
  // call's own work — the same tail its consumers' emits are stamped in.
1595
1639
  if (!replayed)
1596
1640
  await this.settleCommitted(tenantId, scopeId, liveSince, this.invocationId);
1641
+ // After the tail, so an exported type a consumer emitted in it counts too.
1642
+ const exported = exportMark !== null && !replayed ? this.exportedSince(exportTypes, exportMark) : 0;
1597
1643
  return {
1598
1644
  result,
1599
1645
  platformRequests: signals.platformRequests,
1646
+ // #1705 PR 2: the coordinator fires `onExportedEvents` from this. Omitted at 0, so
1647
+ // the envelope of a deployment that exports nothing is byte-for-byte what it was.
1648
+ ...(exported > 0 ? { exported } : {}),
1600
1649
  ...(impersonation ? { impersonation: { honoured: true } } : {}),
1601
1650
  // #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
1602
1651
  ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
@@ -2166,12 +2215,7 @@ export function defineScopeDO(modules, bareOps) {
2166
2215
  }
2167
2216
  /** The kernel's schedule-switch SQL (#1666), over this DO's storage. */
2168
2217
  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
- };
2218
+ return switchSqlOver(this.sql);
2175
2219
  }
2176
2220
  /**
2177
2221
  * Where a module's schedules stand on this scope (#383, #1666) — the kernel's
@@ -2215,6 +2259,19 @@ export function defineScopeDO(modules, bareOps) {
2215
2259
  async switchSystemSchedules(moduleId, scopeId, to, at) {
2216
2260
  return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2217
2261
  }
2262
+ /**
2263
+ * The replay lever on this scope (#1705 PR 3): the kernel's `moveImportCursor`, queued with
2264
+ * every `importApply` on this scope and run as one `transactionSync`. A delivery on the same
2265
+ * edge is therefore wholly before the move, or refused by its compare-and-set after it.
2266
+ * Migrated first: the rows a replay moves aside go to `_substrat_import_replays`.
2267
+ */
2268
+ async importCursorMove(input) {
2269
+ await this.ensureMigrations();
2270
+ // The consumer's own running imports decide whether the edge exists: read here, in the
2271
+ // deployment whose handlers would run, never from the platform's request.
2272
+ const imports = this.crossVertical.consumes();
2273
+ return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => moveImportCursor(this.switchSql(), { ...input, imports })));
2274
+ }
2218
2275
  /**
2219
2276
  * Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
2220
2277
  * the schedule switch's statement with a `vertical:` subject. Queued, one transaction.