@nimbus-sh/loom 0.1.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/src/actor.ts ADDED
@@ -0,0 +1,638 @@
1
+ /**
2
+ * actor.ts — `Actor`, a partyserver `Server` standing on the fabric floor.
3
+ *
4
+ * partyserver contributes the surface: routing, connections, tags,
5
+ * broadcast, hibernation opt-in, `onStart`/`onConnect`/`onMessage`/
6
+ * `onRequest`/`onClose`/`onError`. Fabric contributes the machinery a
7
+ * Durable Object needs under that surface, and this class wires it so an
8
+ * embedder never does:
9
+ *
10
+ * - ONE ALARM, MANY REASONS. `alarm()` dispatches fabric's reason map;
11
+ * the embedder registers reasons ({@link Actor.registerTimerReason})
12
+ * and arms them (`this.timers`). The schedule API and every outbox are
13
+ * reasons in the same map. The platform's `alarmInfo` rides through to
14
+ * every handler.
15
+ * - NOTHING ASYNC ON THE INIT GATE. The constructor is synchronous.
16
+ * partyserver's own gate runs exactly `onStart` (its `#ensureInitialized`,
17
+ * a `blockConcurrencyWhile`); a gate callback still pending at ~30 s is
18
+ * cancelled and RESETS the object, so `onStart` must stay short.
19
+ * Everything the floor defers — generation adoption, cold-start
20
+ * reconciliation, fenced-work recovery — runs on the first turn the
21
+ * actor already owns: every entry point passes {@link Actor.#enterTurn}
22
+ * after initialization and before embedder code. One platform
23
+ * exception: partyserver's fetch asks `getConnectionTags` during the
24
+ * accept, before the connect turn's floor entry — keep that hook pure.
25
+ * - HIBERNATION CONFIGURED, NOT JUST ENABLED. With
26
+ * `static options = { hibernate: true }`, the constructor also applies
27
+ * fabric's ws-hibernation config: ping/pong auto-response (a matched
28
+ * frame no longer wakes the actor) and the 5 s hibernatable-event
29
+ * timeout. The result is on `this.hibernation` for diagnostics.
30
+ * - COMPOSITION STATED ONCE. `static options = { fabric: {...} }` feeds
31
+ * `composeFabric`, and the constructor captures `ctx.exports` where the
32
+ * platform hands it over. Both are first-write-wins.
33
+ *
34
+ * Protocol frames (state sync, callable RPC — see protocol.ts) are consumed
35
+ * before `onMessage`; everything else reaches the embedder untouched. For
36
+ * that interception to hold, hooks must be prototype METHODS — an instance
37
+ * field (`onMessage = () => {}`) assigns over the wiring.
38
+ *
39
+ * The state and schedule tables live in the actor's own SQLite, so an Actor
40
+ * class must be SQLite-backed (`new_sqlite_classes` — the default for new
41
+ * classes).
42
+ */
43
+
44
+ import { Server, type Connection, type ConnectionContext, type WSMessage } from 'partyserver';
45
+ import {
46
+ adoptGeneration,
47
+ generation as fabricGeneration,
48
+ onColdStart as fabricOnColdStart,
49
+ runColdStart,
50
+ type GenerationContext,
51
+ } from '@nimbus-sh/fabric/generation.js';
52
+ import {
53
+ timers as fabricTimers,
54
+ type TimerAlarmInfo,
55
+ type TimerContext,
56
+ type TimerHandlerResult,
57
+ type TimerHandlers,
58
+ type Timers,
59
+ } from '@nimbus-sh/fabric/timers.js';
60
+ import { outbox as fabricOutbox, type Outbox, type OutboxContext, type OutboxPolicy } from '@nimbus-sh/fabric/outbox.js';
61
+ import { journal as fabricJournal, type Journal, type JournalContext } from '@nimbus-sh/fabric/journal.js';
62
+ import { facetPool, type FacetPool, type FacetPoolContext } from '@nimbus-sh/fabric/facet-pool.js';
63
+ import {
64
+ derived as fabricDerived,
65
+ derivedAsync as fabricDerivedAsync,
66
+ type Derived,
67
+ type DerivedAsync,
68
+ type DerivedAsyncHooks,
69
+ type DerivedHooks,
70
+ } from '@nimbus-sh/fabric/derived.js';
71
+ import {
72
+ connections as fabricConnections,
73
+ type ConnectionSocket,
74
+ type ConnectionsContext,
75
+ } from '@nimbus-sh/fabric/connections.js';
76
+ import {
77
+ FencedWork,
78
+ type FencedWorkHost,
79
+ type FencedWorkRecord,
80
+ type FencedWorkStorage,
81
+ } from '@nimbus-sh/fabric/fenced-work.js';
82
+ import {
83
+ adoptCtxExports,
84
+ composeFabric,
85
+ type CtxExports,
86
+ type FabricComposition,
87
+ } from '@nimbus-sh/fabric/composition.js';
88
+ import {
89
+ configureWsHibernation,
90
+ type WsHibernationConfigResult,
91
+ } from '@nimbus-sh/fabric/ws-hibernation-config.js';
92
+ import { ProcessFabric, type ProcessHost } from '@nimbus-sh/fabric/process-fabric.js';
93
+ import type { z } from 'zod/v4';
94
+ import {
95
+ ScheduleStore,
96
+ type Schedule,
97
+ type ScheduleContext,
98
+ type ScheduleCriteria,
99
+ type ScheduleOptions,
100
+ } from './schedules.js';
101
+ import { dispatchRpc } from './rpc.js';
102
+ import { isRpcRequestFrame, isStateFrame, STATE_ERROR_FRAME_TYPE, STATE_FRAME_TYPE } from './protocol.js';
103
+
104
+ /** The timer reason the schedule store dispatches under. */
105
+ export const SCHEDULE_TIMER_REASON = 'loom:schedule';
106
+
107
+ /** Static configuration, inherited through the class chain like partyserver's. */
108
+ export interface ActorOptions {
109
+ /** partyserver's hibernation opt-in; loom also applies fabric's ws config. */
110
+ hibernate?: boolean;
111
+ /**
112
+ * The embedder's fabric composition, stated once on the class. Fed to
113
+ * `composeFabric` (first-write-wins) before anything can need it.
114
+ */
115
+ fabric?: FabricComposition;
116
+ }
117
+
118
+ /**
119
+ * Typed, validated per-connection state over partyserver's `Connection`
120
+ * surface — fabric's `connections` machinery with the accept half left to
121
+ * partyserver, which owns the accept.
122
+ */
123
+ export interface TypedConnections<T> {
124
+ /** The open connection holding a tag, or null. */
125
+ get(tag: string): Connection | null;
126
+ /** Every open connection (optionally: holding a tag). */
127
+ list(tag?: string): Connection[];
128
+ /** A connection's tags — its id first, then `getConnectionTags`' additions. */
129
+ tags(connection: Connection): string[];
130
+ /**
131
+ * The attachment, validated. Null when it does not parse — an attachment
132
+ * written by a previous deploy is untrusted input.
133
+ */
134
+ read(connection: Connection): T | null;
135
+ /** Replace the attachment, validated on the way in. */
136
+ write(connection: Connection, attachment: T): void;
137
+ }
138
+
139
+ function resolveOption<K extends keyof ActorOptions>(cls: unknown, key: K): ActorOptions[K] {
140
+ for (
141
+ let current = cls as { options?: ActorOptions } | null;
142
+ current;
143
+ current = Object.getPrototypeOf(current) as { options?: ActorOptions } | null
144
+ ) {
145
+ const value = current.options?.[key];
146
+ if (value !== undefined) return value;
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ export class Actor<
152
+ Env extends Cloudflare.Env = Cloudflare.Env,
153
+ State = unknown,
154
+ Props extends Record<string, unknown> = Record<string, unknown>,
155
+ > extends Server<Env, Props> {
156
+ declare static options: ActorOptions;
157
+
158
+ /** fabric `TimerHost`: the chain serializing this instance's timer map. */
159
+ _timerChain?: Promise<unknown>;
160
+
161
+ /**
162
+ * Fabric's ws-hibernation configuration result, when
163
+ * `options.hibernate` asked for it; null otherwise. Reports honestly
164
+ * which half the runtime supported.
165
+ */
166
+ readonly hibernation: WsHibernationConfigResult | null = null;
167
+
168
+ /**
169
+ * The state broadcast to (and settable by) connections. Assign it in the
170
+ * subclass; leave it unassigned for a stateless actor.
171
+ */
172
+ declare initialState: State;
173
+
174
+ readonly #schedules: ScheduleStore;
175
+ readonly #timerHandlers: TimerHandlers = {};
176
+ readonly #outboxes = new Map<string, Outbox<never>>();
177
+ readonly #journals = new Map<string, Journal<never>>();
178
+ #processes: ProcessFabric | null = null;
179
+ #facets: FacetPool | null = null;
180
+ readonly #hibernate: boolean;
181
+ #state: State | undefined;
182
+ #stateLoaded = false;
183
+ #stateSchemaReady = false;
184
+
185
+ constructor(ctx: DurableObjectState, env: Env) {
186
+ super(ctx, env);
187
+ const cls = (Object.getPrototypeOf(this) as { constructor: unknown }).constructor;
188
+ const fabric = resolveOption(cls, 'fabric');
189
+ if (fabric) composeFabric(fabric);
190
+ const ctxExports = (ctx as { exports?: CtxExports }).exports;
191
+ if (ctxExports) adoptCtxExports(ctxExports);
192
+ this.#hibernate = resolveOption(cls, 'hibernate') === true;
193
+ if (this.#hibernate) {
194
+ this.hibernation = configureWsHibernation(ctx);
195
+ }
196
+ this.#schedules = new ScheduleStore(ctx as unknown as ScheduleContext);
197
+ this.registerTimerReason(SCHEDULE_TIMER_REASON, (now, info) => this.#dispatchSchedules(now, info));
198
+ this.#wrapHooks();
199
+ }
200
+
201
+ // ── The floor, per turn ────────────────────────────────────────────────
202
+
203
+ /**
204
+ * The deferred floor work of one incarnation, paid by the first turn that
205
+ * owns it: adopt the persisted generation counter (once per instance),
206
+ * then drain the cold-start queue — fenced-work recovery and whatever the
207
+ * embedder deferred. Never the init gate: every call site here is a turn
208
+ * that already passed initialization. A failed cold-start task is
209
+ * reported and does not fail the turn that happened to drain it.
210
+ */
211
+ async #enterTurn(): Promise<void> {
212
+ await adoptGeneration(this.ctx as unknown as GenerationContext);
213
+ try {
214
+ await runColdStart(this.ctx);
215
+ } catch (e) {
216
+ console.error(`[loom] ${this.#className()} cold-start task failed:`, e);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Instance-level wraps around the embedder's hooks, so every entry point
222
+ * pays {@link #enterTurn} and protocol frames never reach `onMessage`.
223
+ * Captured at construction: hooks defined as instance FIELDS would
224
+ * assign over these wrappers — define hooks as methods.
225
+ */
226
+ #wrapHooks(): void {
227
+ const onConnect = this.onConnect.bind(this);
228
+ const onMessage = this.onMessage.bind(this);
229
+ const onRequest = this.onRequest.bind(this);
230
+ const onClose = this.onClose.bind(this);
231
+ const onError = this.onError.bind(this);
232
+ this.onConnect = async (connection: Connection, ctx: ConnectionContext): Promise<void> => {
233
+ await this.#enterTurn();
234
+ this.#sendStateOnConnect(connection);
235
+ await onConnect(connection, ctx);
236
+ };
237
+ this.onMessage = async (connection: Connection, message: WSMessage): Promise<void> => {
238
+ await this.#enterTurn();
239
+ if (await this.#consumeProtocolFrame(connection, message)) return;
240
+ await onMessage(connection, message);
241
+ };
242
+ this.onRequest = async (request: Request): Promise<Response> => {
243
+ await this.#enterTurn();
244
+ return onRequest(request);
245
+ };
246
+ this.onClose = async (connection: Connection, code: number, reason: string, wasClean: boolean): Promise<void> => {
247
+ await this.#enterTurn();
248
+ await onClose(connection, code, reason, wasClean);
249
+ };
250
+ this.onError = async (connection: Connection, error: unknown): Promise<void> => {
251
+ await this.#enterTurn();
252
+ await onError(connection, error);
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The native-RPC entry point (`getActorByName` calls it before any
258
+ * embedder RPC method) pays the turn entry too, after partyserver has
259
+ * initialized.
260
+ */
261
+ override async setName(name: string, props?: Props): Promise<void> {
262
+ await super.setName(name, props);
263
+ await this.#enterTurn();
264
+ }
265
+
266
+ // ── One alarm, many reasons ────────────────────────────────────────────
267
+
268
+ /**
269
+ * partyserver initialization, then the floor, then `onAlarm`, then
270
+ * fabric's dispatcher runs every due reason with the platform's
271
+ * `alarmInfo`. Handlers re-arm through their return value; the map's
272
+ * earliest remaining deadline re-arms the platform alarm.
273
+ * `__unsafe_ensureInitialized` is partyserver's documented escape hatch
274
+ * for frameworks; calling it here (instead of `super.alarm()`) is what
275
+ * lets `onAlarm` run AFTER the floor, like every other embedder hook.
276
+ */
277
+ override async alarm(alarmInfo?: TimerAlarmInfo): Promise<void> {
278
+ await this.__unsafe_ensureInitialized();
279
+ await this.#enterTurn();
280
+ await this.onAlarm();
281
+ await this.timers.dispatch(this.#timerHandlers, undefined, alarmInfo);
282
+ }
283
+
284
+ /** partyserver logs "implement onAlarm" per fire; an empty hook is the default here. */
285
+ override onAlarm(): void | Promise<void> {}
286
+
287
+ /**
288
+ * Register the handler for one timer reason. Reasons are the alarm's
289
+ * multiplexing key: register in the constructor, arm with
290
+ * `this.timers.schedule(reason, whenMs)`, re-arm by returning
291
+ * `{ rearmAt }` from the handler. One handler per reason, for the
292
+ * instance's lifetime.
293
+ */
294
+ protected registerTimerReason(
295
+ reason: string,
296
+ handler: (now: number, info?: TimerAlarmInfo) => TimerHandlerResult | Promise<TimerHandlerResult>,
297
+ ): void {
298
+ if (reason in this.#timerHandlers) {
299
+ throw new Error(`loom: timer reason '${reason}' is already registered on ${this.#className()}`);
300
+ }
301
+ this.#timerHandlers[reason] = handler;
302
+ }
303
+
304
+ // ── Scheduling ─────────────────────────────────────────────────────────
305
+
306
+ /**
307
+ * Schedule a method call: `when` is a delay in seconds, an absolute
308
+ * `Date`, or a cron expression. The callback fires as
309
+ * `this[callback](payload, invocation)`; the invocation carries the
310
+ * schedule, the attempt number, and the platform's `alarmInfo`. Retries
311
+ * are durable rows (see schedules.ts), governed by `options.retry`.
312
+ */
313
+ async schedule<T = unknown>(
314
+ when: number | Date | string,
315
+ callback: keyof this & string,
316
+ payload?: T,
317
+ options?: ScheduleOptions,
318
+ ): Promise<Schedule<T>> {
319
+ this.#assertScheduleCallback(callback);
320
+ const schedule = this.#schedules.create(when, callback, payload, options);
321
+ await this.timers.schedule(SCHEDULE_TIMER_REASON, schedule.time);
322
+ return schedule;
323
+ }
324
+
325
+ /** Schedule a method call every `intervalSeconds`, first fire one interval from now. */
326
+ async scheduleEvery<T = unknown>(
327
+ intervalSeconds: number,
328
+ callback: keyof this & string,
329
+ payload?: T,
330
+ options?: ScheduleOptions,
331
+ ): Promise<Schedule<T>> {
332
+ this.#assertScheduleCallback(callback);
333
+ const schedule = this.#schedules.every(intervalSeconds, callback, payload, options);
334
+ await this.timers.schedule(SCHEDULE_TIMER_REASON, schedule.time);
335
+ return schedule;
336
+ }
337
+
338
+ async getScheduleById<T = unknown>(id: string): Promise<Schedule<T> | undefined> {
339
+ return this.#schedules.byId<T>(id);
340
+ }
341
+
342
+ async listSchedules<T = unknown>(criteria?: ScheduleCriteria): Promise<Array<Schedule<T>>> {
343
+ return this.#schedules.list<T>(criteria);
344
+ }
345
+
346
+ /** True when the id existed and is now cancelled. */
347
+ async cancelSchedule(id: string): Promise<boolean> {
348
+ return this.#schedules.cancel(id);
349
+ }
350
+
351
+ /**
352
+ * A schedule's retry budget is spent (or its callback is not a method).
353
+ * The default names the failure; override to route it.
354
+ */
355
+ onScheduleError(schedule: Schedule, error: unknown): void {
356
+ console.error(
357
+ `[loom] ${this.#className()} schedule '${schedule.id}' (${schedule.callback}) failed for good:`,
358
+ error,
359
+ );
360
+ }
361
+
362
+ #assertScheduleCallback(callback: string): void {
363
+ if (typeof (this as unknown as Record<string, unknown>)[callback] !== 'function') {
364
+ throw new Error(`loom: this.${callback} is not a function`);
365
+ }
366
+ }
367
+
368
+ async #dispatchSchedules(now: number, info?: TimerAlarmInfo): Promise<TimerHandlerResult> {
369
+ const result = await this.#schedules.dispatchDue(this, now, info, (schedule, error) => {
370
+ try {
371
+ this.onScheduleError(schedule, error);
372
+ } catch (e) {
373
+ console.error(`[loom] ${this.#className()} onScheduleError itself failed:`, e);
374
+ }
375
+ });
376
+ return result.rearmAt === null ? undefined : { rearmAt: result.rearmAt };
377
+ }
378
+
379
+ // ── State sync ─────────────────────────────────────────────────────────
380
+
381
+ /**
382
+ * The synced state. Loaded from SQLite on first read; `initialState`
383
+ * before anything was ever set; undefined for a stateless actor.
384
+ */
385
+ get state(): State {
386
+ if (!this.#stateLoaded) {
387
+ this.#ensureStateSchema();
388
+ const rows = [...this.#sql.exec(`SELECT state FROM loom_state WHERE id = 1`)] as Array<{ state: string }>;
389
+ this.#state = rows.length > 0 ? (JSON.parse(rows[0].state) as State) : this.initialState;
390
+ this.#stateLoaded = true;
391
+ }
392
+ return this.#state as State;
393
+ }
394
+
395
+ /** Replace the state: validate, persist, broadcast, notify. */
396
+ setState(state: State): void {
397
+ this.#setStateInternal(state, 'server');
398
+ }
399
+
400
+ /**
401
+ * Synchronous veto over every state change, the embedder's own and a
402
+ * connection's alike. Runs BEFORE anything persists; throw to refuse.
403
+ * A refused connection update earns the client a
404
+ * `cf_agent_state_error` frame.
405
+ */
406
+ validateStateChange(_next: State, _source: Connection | 'server'): void {}
407
+
408
+ /** The state changed and is already persisted and broadcast. */
409
+ onStateChanged(_state: State, _source: Connection | 'server'): void | Promise<void> {}
410
+
411
+ #setStateInternal(state: State, source: Connection | 'server'): void {
412
+ this.validateStateChange(state, source);
413
+ const json = JSON.stringify(state);
414
+ if (json === undefined) {
415
+ throw new Error('loom: state must be JSON-serializable, and undefined is not a state');
416
+ }
417
+ this.#ensureStateSchema();
418
+ this.#sql.exec(
419
+ `INSERT OR REPLACE INTO loom_state (id, state, updated_at) VALUES (1, ?, ?)`,
420
+ json,
421
+ Date.now(),
422
+ );
423
+ this.#state = state;
424
+ this.#stateLoaded = true;
425
+ this.broadcast(
426
+ JSON.stringify({ type: STATE_FRAME_TYPE, state }),
427
+ source === 'server' ? [] : [source.id],
428
+ );
429
+ // The change already persisted and broadcast, so an onStateChanged
430
+ // failure — sync or async — is the hook's problem, never a refusal.
431
+ try {
432
+ const hook = this.onStateChanged(state, source);
433
+ if (hook && typeof (hook as Promise<void>).then === 'function') {
434
+ this.ctx.waitUntil(
435
+ (hook as Promise<void>).catch((e) => {
436
+ console.error(`[loom] ${this.#className()} onStateChanged failed:`, e);
437
+ }),
438
+ );
439
+ }
440
+ } catch (e) {
441
+ console.error(`[loom] ${this.#className()} onStateChanged failed:`, e);
442
+ }
443
+ }
444
+
445
+ #sendStateOnConnect(connection: Connection): void {
446
+ const state = this.state;
447
+ if (state === undefined) return;
448
+ try {
449
+ connection.send(JSON.stringify({ type: STATE_FRAME_TYPE, state }));
450
+ } catch (e) {
451
+ console.warn(`[loom] ${this.#className()} could not send state on connect:`, e);
452
+ }
453
+ }
454
+
455
+ #ensureStateSchema(): void {
456
+ if (this.#stateSchemaReady) return;
457
+ this.#sql.exec(`CREATE TABLE IF NOT EXISTS loom_state (
458
+ id INTEGER PRIMARY KEY CHECK (id = 1),
459
+ state TEXT NOT NULL,
460
+ updated_at INTEGER NOT NULL
461
+ )`);
462
+ this.#stateSchemaReady = true;
463
+ }
464
+
465
+ get #sql(): { exec(query: string, ...bindings: Array<string | number | null>): Iterable<unknown> } {
466
+ return (this.ctx as unknown as ScheduleContext).storage.sql;
467
+ }
468
+
469
+ // ── Protocol frames ────────────────────────────────────────────────────
470
+
471
+ /** True when the message was a protocol frame and is now handled. */
472
+ async #consumeProtocolFrame(connection: Connection, message: WSMessage): Promise<boolean> {
473
+ if (typeof message !== 'string' || message[0] !== '{') return false;
474
+ let parsed: unknown;
475
+ try {
476
+ parsed = JSON.parse(message);
477
+ } catch {
478
+ return false;
479
+ }
480
+ if (isStateFrame(parsed)) {
481
+ try {
482
+ this.#setStateInternal(parsed.state as State, connection);
483
+ } catch (e) {
484
+ console.warn(`[loom] ${this.#className()} refused a state update from ${connection.id}:`, e);
485
+ try {
486
+ connection.send(JSON.stringify({ type: STATE_ERROR_FRAME_TYPE, error: 'State update rejected' }));
487
+ } catch { /* peer gone; the refusal has no one to reach */ }
488
+ }
489
+ return true;
490
+ }
491
+ if (isRpcRequestFrame(parsed)) {
492
+ await dispatchRpc(this, connection, parsed);
493
+ return true;
494
+ }
495
+ return false;
496
+ }
497
+
498
+ // ── The fabric floor, as accessors ─────────────────────────────────────
499
+
500
+ /** This actor's reason map over its ONE platform alarm. */
501
+ get timers(): Timers {
502
+ return fabricTimers(this, this.ctx as unknown as TimerContext);
503
+ }
504
+
505
+ /** This incarnation's generation. Zero until the first turn adopted it. */
506
+ get generation(): number {
507
+ return fabricGeneration(this.ctx);
508
+ }
509
+
510
+ /**
511
+ * The named durable retry outbox, its drain registered as a timer reason
512
+ * on first call. One instance per name; later calls return the first and
513
+ * ignore their policy argument.
514
+ *
515
+ * Create outboxes in the CONSTRUCTOR. A queued row survives an instance
516
+ * reset, but the dispatcher drops a fired reason no handler answers
517
+ * (rollback forward-compat) — an outbox first created inside a request
518
+ * path is not registered when the next incarnation's alarm fires, and
519
+ * its queued rows sit until some later `queue()` happens to re-arm.
520
+ */
521
+ outbox<M>(name: string, policy: OutboxPolicy<M>): Outbox<M> {
522
+ const existing = this.#outboxes.get(name);
523
+ if (existing) return existing as Outbox<M>;
524
+ const box = fabricOutbox<M>(this, this.ctx as unknown as OutboxContext, name, policy);
525
+ this.registerTimerReason(box.reason, box.handler());
526
+ this.#outboxes.set(name, box as Outbox<never>);
527
+ return box;
528
+ }
529
+
530
+ /** The named append-only event journal. One instance per name. */
531
+ journal<P>(name: string): Journal<P> {
532
+ const existing = this.#journals.get(name);
533
+ if (existing) return existing as Journal<P>;
534
+ const created = fabricJournal<P>(this.ctx as unknown as JournalContext, name);
535
+ this.#journals.set(name, created as Journal<never>);
536
+ return created;
537
+ }
538
+
539
+ /** Leased facets: disposal retires (storage wiped), `detach()` keeps it. */
540
+ get facets(): FacetPool {
541
+ return (this.#facets ??= facetPool(this.ctx as unknown as FacetPoolContext));
542
+ }
543
+
544
+ /**
545
+ * The process fabric over this actor's substrate. Declare the substrate
546
+ * by overriding {@link processHost}; the fabric is built once, on first
547
+ * use.
548
+ */
549
+ get processes(): ProcessFabric {
550
+ return (this.#processes ??= new ProcessFabric(this.processHost()));
551
+ }
552
+
553
+ /** The substrate {@link processes} runs on. Override to declare one. */
554
+ protected processHost(): ProcessHost {
555
+ throw new Error(
556
+ `loom: ${this.#className()} used this.processes without a substrate — override processHost()`,
557
+ );
558
+ }
559
+
560
+ /** A watermark memo: derive a cheap key, compare, rebuild only on change. */
561
+ derived<T, C = void>(
562
+ watermark: (context: C) => string | number,
563
+ build: (context: C, key: string | number) => T,
564
+ hooks?: DerivedHooks,
565
+ ): Derived<T, C> {
566
+ return fabricDerived(watermark, build, hooks);
567
+ }
568
+
569
+ /** The async memo; a watermark or build failure serves the last good value. */
570
+ derivedAsync<T, C = void>(
571
+ watermark: (context: C) => Promise<string | number>,
572
+ build: (context: C, key: string | number) => Promise<T>,
573
+ hooks?: DerivedAsyncHooks,
574
+ ): DerivedAsync<T, C> {
575
+ return fabricDerivedAsync(watermark, build, hooks);
576
+ }
577
+
578
+ /**
579
+ * Typed, validated per-connection state over the WebSocket attachment,
580
+ * hibernation-durable. partyserver owns the accept (tag connections via
581
+ * `getConnectionTags`); this reads, writes, and addresses by tag. To
582
+ * replace-on-reconnect, close the other holders of the identity tag in
583
+ * `onConnect`.
584
+ *
585
+ * Hibernation-only: the state rides the hibernatable socket attachment,
586
+ * and partyserver's non-hibernating connections neither wrap nor persist
587
+ * it — so a non-hibernating actor is refused here, not corrupted later.
588
+ */
589
+ connections<T>(schema: z.ZodType<T>): TypedConnections<T> {
590
+ if (!this.#hibernate) {
591
+ throw new Error(
592
+ `loom: ${this.#className()}.connections() needs hibernation — the typed state rides the `
593
+ + `hibernatable socket attachment; set static options = { hibernate: true }`,
594
+ );
595
+ }
596
+ const adapter: ConnectionsContext = {
597
+ acceptWebSocket: () => {
598
+ throw new Error(
599
+ 'loom: partyserver owns the accept — tag connections via getConnectionTags() and write state with write()',
600
+ );
601
+ },
602
+ getWebSockets: (tag?: string) => [...this.getConnections(tag)] as unknown as ConnectionSocket[],
603
+ getTags: (ws: ConnectionSocket) => [...(ws as unknown as Connection).tags],
604
+ };
605
+ const inner = fabricConnections(adapter, schema);
606
+ return {
607
+ get: (tag) => inner.get(tag) as Connection | null,
608
+ list: (tag) => inner.list(tag) as unknown as Connection[],
609
+ tags: (connection) => inner.tags(connection as unknown as ConnectionSocket),
610
+ read: (connection) => inner.read(connection as unknown as ConnectionSocket),
611
+ write: (connection, attachment) => inner.write(connection as unknown as ConnectionSocket, attachment),
612
+ };
613
+ }
614
+
615
+ /**
616
+ * A fenced-work journal whose recovery is pumped on the first turn of
617
+ * every incarnation — reconnects after a reset included. The host defines
618
+ * what a launch is and how to re-drive it; call once, in the constructor.
619
+ */
620
+ protected fenceWork<R extends FencedWorkRecord>(host: FencedWorkHost<R>): FencedWork<R> {
621
+ const work = new FencedWork<R>(this.ctx.storage as unknown as FencedWorkStorage, host);
622
+ fabricOnColdStart(this.ctx, () => work.recoverInterrupted());
623
+ return work;
624
+ }
625
+
626
+ /**
627
+ * Defer async reconciliation to the first turn of this incarnation —
628
+ * never the init gate. Safe to call from the constructor; that is the
629
+ * point.
630
+ */
631
+ protected deferToColdStart(task: () => Promise<unknown>): void {
632
+ fabricOnColdStart(this.ctx, task);
633
+ }
634
+
635
+ #className(): string {
636
+ return (Object.getPrototypeOf(this) as { constructor: { name: string } }).constructor.name;
637
+ }
638
+ }