@almadar/runtime 6.9.2 → 6.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,1855 @@
1
- export { InMemoryPersistence, OrbitalServerRuntime, collectDeclaredConfigDefaults, createOrbitalServerRuntime } from './chunk-VUXJJPIQ.js';
1
+ import { EventBus, createUnifiedLoader, MockPersistenceAdapter, InMemoryPersistence, preprocessSchema, StateMachineManager, createContextFromBindings, validateEventPayload, formatPayloadValidationError, collectDeclaredConfigDefaults, EffectExecutor } from './chunk-R6Y4IJ7I.js';
2
+ export { InMemoryPersistence, collectDeclaredConfigDefaults } from './chunk-R6Y4IJ7I.js';
2
3
  import './chunk-PZ5AY32C.js';
4
+ import { createLogger } from '@almadar/logger';
5
+ import * as nodeModule from 'module';
6
+ import { evaluateGuard, evaluate } from '@almadar/evaluator';
7
+ import { isInlineTrait, isEntityCall } from '@almadar/core';
8
+
9
+ var _resolvedNodeRequire = null;
10
+ function nodeRequire(modulePath) {
11
+ if (!_resolvedNodeRequire) {
12
+ const evalRequire = (0, eval)('typeof require !== "undefined" ? require : null');
13
+ if (evalRequire) {
14
+ _resolvedNodeRequire = evalRequire;
15
+ } else {
16
+ const createReq = nodeModule.createRequire;
17
+ if (typeof createReq !== "function") {
18
+ throw new Error(
19
+ "[OrbitalServerRuntime] No synchronous require available. This branch is Node-only \u2014 invoking it from a browser indicates an isNodeEnv() guard regression upstream."
20
+ );
21
+ }
22
+ _resolvedNodeRequire = createReq(import.meta.url);
23
+ }
24
+ }
25
+ return _resolvedNodeRequire(modulePath);
26
+ }
27
+ var _nodeRequireExt = import.meta.url.endsWith(".ts") ? ".ts" : ".js";
28
+ var effectLog = createLogger("almadar:runtime:effects");
29
+ var busLog = createLogger("almadar:runtime:bus");
30
+ var renderLog = createLogger("almadar:runtime:render-ui");
31
+ var xOrbitalLog = createLogger("almadar:runtime:cross-orbital");
32
+ var persistLog = createLogger("almadar:runtime:persist");
33
+ var registerLog = createLogger("almadar:runtime:register");
34
+ var dynamicLog = createLogger("almadar:runtime:dynamic");
35
+ function isNodeEnv() {
36
+ return typeof process !== "undefined" && Boolean(process.versions?.node);
37
+ }
38
+ function needsPreprocessing(schema) {
39
+ for (const orbital of schema.orbitals) {
40
+ const uses = orbital.uses;
41
+ if (Array.isArray(uses) && uses.length > 0) {
42
+ return true;
43
+ }
44
+ const traits = orbital.traits ?? [];
45
+ for (const t of traits) {
46
+ if (!t || typeof t !== "object") continue;
47
+ const obj = t;
48
+ if (typeof obj.ref === "string" && obj.ref.includes(".") && !obj.stateMachine) {
49
+ return true;
50
+ }
51
+ }
52
+ }
53
+ return false;
54
+ }
55
+ var OrbitalServerRuntime = class {
56
+ orbitals = /* @__PURE__ */ new Map();
57
+ eventBus;
58
+ config;
59
+ persistence;
60
+ listenerCleanups = [];
61
+ tickBindings = [];
62
+ loader = null;
63
+ preprocessedCache = /* @__PURE__ */ new Map();
64
+ entitySharingMap = {};
65
+ eventNamespaceMap = {};
66
+ osHandlers = null;
67
+ localPersistence = null;
68
+ resolvedSchema = null;
69
+ constructor(config = {}) {
70
+ this.config = {
71
+ mode: "mock",
72
+ // Default to mock mode for preview
73
+ autoPreprocess: false,
74
+ namespaceEvents: true,
75
+ ...config
76
+ };
77
+ this.eventBus = new EventBus();
78
+ if (config.loaderConfig?.loader) {
79
+ this.loader = config.loaderConfig.loader;
80
+ } else if (config.loaderConfig?.stdLibPath) {
81
+ this.loader = createUnifiedLoader({
82
+ basePath: config.loaderConfig.basePath,
83
+ stdLibPath: config.loaderConfig.stdLibPath,
84
+ scopedPaths: config.loaderConfig.scopedPaths
85
+ });
86
+ }
87
+ if (this.config.mode === "mock" && !config.persistence) {
88
+ this.persistence = new MockPersistenceAdapter({
89
+ seed: config.mockSeed,
90
+ defaultSeedCount: config.mockSeedCount ?? 6,
91
+ debug: config.debug
92
+ });
93
+ if (config.debug) {
94
+ persistLog.debug("mock:init", { adapter: "MockPersistenceAdapter" });
95
+ }
96
+ } else {
97
+ this.persistence = config.persistence || new InMemoryPersistence();
98
+ }
99
+ if (config.localStorageRoot && isNodeEnv()) {
100
+ const { LocalPersistenceAdapter } = nodeRequire(`./LocalPersistenceAdapter${_nodeRequireExt}`);
101
+ this.localPersistence = new LocalPersistenceAdapter(config.localStorageRoot);
102
+ }
103
+ if (isNodeEnv()) {
104
+ const { createOsHandlers } = nodeRequire(`./createOsHandlers${_nodeRequireExt}`);
105
+ this.osHandlers = createOsHandlers({
106
+ emitEvent: (type, payload) => this.eventBus.emit(type, payload)
107
+ });
108
+ } else {
109
+ this.osHandlers = { handlers: {}, cleanup: () => {
110
+ } };
111
+ }
112
+ this.config.effectHandlers = {
113
+ ...this.osHandlers.handlers,
114
+ ...this.config.effectHandlers
115
+ };
116
+ }
117
+ /**
118
+ * Lazily construct a default loader when the caller didn't provide one
119
+ * but `register()` needs to preprocess. Looks for `@almadar/std` in the
120
+ * nearest `node_modules` so cross-orbital `std/behaviors/<name>` imports
121
+ * resolve to the tiered registry on disk.
122
+ *
123
+ * Node only — browsers should receive already-preprocessed schemas from
124
+ * their server.
125
+ */
126
+ async ensureLoader() {
127
+ if (this.loader) return;
128
+ if (typeof process === "undefined" || !process.versions?.node) {
129
+ return;
130
+ }
131
+ try {
132
+ const [{ fileURLToPath }, path, fs] = await Promise.all([
133
+ import('url'),
134
+ import('path'),
135
+ import('fs')
136
+ ]);
137
+ const mainEntryUrl = import.meta.resolve("@almadar/std");
138
+ const mainEntry = fileURLToPath(mainEntryUrl);
139
+ let stdLibPath = path.dirname(mainEntry);
140
+ while (stdLibPath !== path.dirname(stdLibPath)) {
141
+ if (fs.existsSync(path.join(stdLibPath, "package.json"))) {
142
+ const pkg = JSON.parse(
143
+ fs.readFileSync(path.join(stdLibPath, "package.json"), "utf-8")
144
+ );
145
+ if (pkg.name === "@almadar/std") break;
146
+ }
147
+ stdLibPath = path.dirname(stdLibPath);
148
+ }
149
+ const basePath = this.config.loaderConfig?.basePath ?? process.cwd();
150
+ this.loader = createUnifiedLoader({
151
+ basePath,
152
+ stdLibPath,
153
+ scopedPaths: this.config.loaderConfig?.scopedPaths
154
+ });
155
+ if (this.config.debug) {
156
+ registerLog.debug("loader:constructed", { basePath, stdLibPath });
157
+ }
158
+ } catch (err) {
159
+ if (this.config.debug) {
160
+ registerLog.warn("loader:construct-failed", { error: err instanceof Error ? err : String(err) });
161
+ }
162
+ }
163
+ }
164
+ // ==========================================================================
165
+ // Schema Registration
166
+ // ==========================================================================
167
+ /**
168
+ * Register an OrbitalSchema for execution.
169
+ *
170
+ * Auto-preprocesses the schema when it contains `uses` declarations or
171
+ * unresolved cross-orbital trait references (e.g. a trait with
172
+ * `ref: "Modal.traits.ModalRecordModal"` and no inline `stateMachine`).
173
+ * Without preprocessing, those refs arrive empty at the state machine and
174
+ * button clicks silently do nothing — see Phase 9.5.H.
175
+ *
176
+ * Preprocessing needs a loader. If `loaderConfig` is set, that loader is
177
+ * used. Otherwise, a default loader is constructed that points at
178
+ * `<cwd>` (for `basePath`) and the nearest `node_modules/@almadar/std` (for
179
+ * `stdLibPath`), which matches how every caller in this monorepo has the
180
+ * std registry on disk.
181
+ */
182
+ async register(schema) {
183
+ if (this.config.debug) {
184
+ registerLog.debug("register:schema", { name: schema.name });
185
+ }
186
+ if (needsPreprocessing(schema)) {
187
+ await this.ensureLoader();
188
+ if (this.loader) {
189
+ if (this.config.debug) {
190
+ registerLog.debug("register:auto-preprocessing", { name: schema.name });
191
+ }
192
+ const result = await preprocessSchema(schema, {
193
+ basePath: this.config.loaderConfig?.basePath || process.cwd(),
194
+ stdLibPath: this.config.loaderConfig?.stdLibPath,
195
+ scopedPaths: this.config.loaderConfig?.scopedPaths,
196
+ loader: this.loader,
197
+ namespaceEvents: this.config.namespaceEvents
198
+ });
199
+ if (!result.success) {
200
+ throw new Error(
201
+ `Schema preprocessing failed: ${result.errors.join("; ")}`
202
+ );
203
+ }
204
+ schema = result.data.schema;
205
+ this.entitySharingMap = {
206
+ ...this.entitySharingMap,
207
+ ...result.data.entitySharing
208
+ };
209
+ this.eventNamespaceMap = {
210
+ ...this.eventNamespaceMap,
211
+ ...result.data.eventNamespaces
212
+ };
213
+ } else if (this.config.debug) {
214
+ registerLog.warn("register:no-loader", { name: schema.name });
215
+ }
216
+ }
217
+ for (const orbital of schema.orbitals) {
218
+ await this.registerOrbitalAsync(orbital);
219
+ }
220
+ this.setupEventListeners();
221
+ this.setupTicks();
222
+ this.resolvedSchema = schema;
223
+ }
224
+ /**
225
+ * Register an OrbitalSchema synchronously (for backward compatibility).
226
+ * Note: This version doesn't wait for instance seeding to complete.
227
+ * Use async register() for guaranteed instance seeding.
228
+ */
229
+ registerSync(schema) {
230
+ if (this.config.debug) {
231
+ registerLog.debug("register:schema-sync", { name: schema.name });
232
+ }
233
+ for (const orbital of schema.orbitals) {
234
+ this.registerOrbital(orbital);
235
+ }
236
+ this.setupEventListeners();
237
+ this.setupTicks();
238
+ this.resolvedSchema = schema;
239
+ }
240
+ /**
241
+ * Returns the schema that this runtime is currently executing, post-
242
+ * preprocessing. Safe to expose from an HTTP `/api/schema` endpoint — every
243
+ * cross-orbital trait ref will have an inline `stateMachine` already, which
244
+ * is what the browser's `schema-to-ir` resolver needs to wire button clicks
245
+ * back to state transitions.
246
+ *
247
+ * Returns `null` if `register()` hasn't run yet.
248
+ */
249
+ getResolvedSchema() {
250
+ return this.resolvedSchema;
251
+ }
252
+ /**
253
+ * One-call entry point: read an `.orb` file from disk, parse it, preprocess
254
+ * cross-orbital imports, and register the result. Callers never touch raw
255
+ * `.orb` bytes — `register()` handles preprocessing internally.
256
+ *
257
+ * Node only. Browsers must receive already-resolved schemas from their
258
+ * server (see `getResolvedSchema()`).
259
+ */
260
+ async registerFromFile(path) {
261
+ if (typeof process === "undefined" || !process.versions?.node) {
262
+ throw new Error(
263
+ "registerFromFile is Node-only. Browsers should receive resolved schemas from their server."
264
+ );
265
+ }
266
+ const { readFile } = await import('fs/promises');
267
+ const raw = await readFile(path, "utf-8");
268
+ let schema;
269
+ try {
270
+ schema = JSON.parse(raw);
271
+ } catch (err) {
272
+ const msg = err instanceof Error ? err.message : String(err);
273
+ throw new Error(`registerFromFile: ${path} is not valid JSON: ${msg}`);
274
+ }
275
+ await this.register(schema);
276
+ }
277
+ /**
278
+ * Register an OrbitalSchema with preprocessing to resolve `uses` imports.
279
+ *
280
+ * This method:
281
+ * 1. Loads all external orbitals referenced in `uses` declarations
282
+ * 2. Expands entity/trait/page references to inline definitions
283
+ * 3. Builds entity sharing and event namespace maps
284
+ * 4. Caches the preprocessed result
285
+ * 5. Registers the resolved schema
286
+ *
287
+ * @param schema - Schema with potential `uses` declarations
288
+ * @param options - Optional preprocessing options
289
+ * @returns Preprocessing result with entity sharing info
290
+ *
291
+ * @example
292
+ * ```typescript
293
+ * const runtime = new OrbitalServerRuntime({
294
+ * loaderConfig: {
295
+ * basePath: '/schemas',
296
+ * stdLibPath: '/std',
297
+ * },
298
+ * });
299
+ *
300
+ * const result = await runtime.registerWithPreprocess(schema);
301
+ * if (result.success) {
302
+ * console.log('Registered with', Object.keys(result.entitySharing).length, 'orbitals');
303
+ * }
304
+ * ```
305
+ */
306
+ async registerWithPreprocess(schema, options) {
307
+ if (!this.loader && !this.config.loaderConfig) {
308
+ return {
309
+ success: false,
310
+ errors: ["Loader not configured. Set loaderConfig in OrbitalServerRuntimeConfig."]
311
+ };
312
+ }
313
+ if (!this.loader && this.config.loaderConfig) {
314
+ this.loader = this.config.loaderConfig.loader ?? createUnifiedLoader({
315
+ basePath: this.config.loaderConfig.basePath,
316
+ stdLibPath: this.config.loaderConfig.stdLibPath,
317
+ scopedPaths: this.config.loaderConfig.scopedPaths
318
+ });
319
+ }
320
+ const cacheKey = `${schema.name}:${schema.version || "1.0.0"}`;
321
+ const cached = this.preprocessedCache.get(cacheKey);
322
+ if (cached) {
323
+ if (this.config.debug) {
324
+ registerLog.debug("preprocess:cache-hit", { name: schema.name });
325
+ }
326
+ this.register(cached.schema);
327
+ this.entitySharingMap = { ...this.entitySharingMap, ...cached.entitySharing };
328
+ this.eventNamespaceMap = { ...this.eventNamespaceMap, ...cached.eventNamespaces };
329
+ return {
330
+ success: true,
331
+ entitySharing: cached.entitySharing,
332
+ eventNamespaces: cached.eventNamespaces,
333
+ warnings: cached.warnings
334
+ };
335
+ }
336
+ if (this.config.debug) {
337
+ registerLog.debug("preprocess:start", { name: schema.name });
338
+ }
339
+ const result = await preprocessSchema(schema, {
340
+ basePath: this.config.loaderConfig?.basePath || ".",
341
+ stdLibPath: this.config.loaderConfig?.stdLibPath,
342
+ scopedPaths: this.config.loaderConfig?.scopedPaths,
343
+ loader: this.loader,
344
+ namespaceEvents: this.config.namespaceEvents
345
+ });
346
+ if (!result.success) {
347
+ return {
348
+ success: false,
349
+ errors: result.errors
350
+ };
351
+ }
352
+ this.preprocessedCache.set(cacheKey, result.data);
353
+ this.entitySharingMap = { ...this.entitySharingMap, ...result.data.entitySharing };
354
+ this.eventNamespaceMap = { ...this.eventNamespaceMap, ...result.data.eventNamespaces };
355
+ this.register(result.data.schema);
356
+ return {
357
+ success: true,
358
+ entitySharing: result.data.entitySharing,
359
+ eventNamespaces: result.data.eventNamespaces,
360
+ warnings: result.data.warnings
361
+ };
362
+ }
363
+ /**
364
+ * Get entity sharing information for registered orbitals.
365
+ * Useful for determining entity isolation and collection names.
366
+ */
367
+ getEntitySharing() {
368
+ return { ...this.entitySharingMap };
369
+ }
370
+ /**
371
+ * Get event namespace mapping for registered orbitals.
372
+ * Useful for debugging cross-orbital event routing.
373
+ */
374
+ getEventNamespaces() {
375
+ return { ...this.eventNamespaceMap };
376
+ }
377
+ /**
378
+ * Clear the preprocessing cache.
379
+ */
380
+ clearPreprocessCache() {
381
+ this.preprocessedCache.clear();
382
+ }
383
+ /**
384
+ * Register a single orbital
385
+ */
386
+ async registerOrbitalAsync(orbital) {
387
+ const configByTrait = /* @__PURE__ */ new Map();
388
+ const unwrapped = (orbital.traits || []).map((t) => {
389
+ if (t && typeof t === "object" && "ref" in t && "_resolved" in t) {
390
+ const wrapper = t;
391
+ const inner = wrapper._resolved;
392
+ if (wrapper.config && inner?.name) {
393
+ configByTrait.set(inner.name, wrapper.config);
394
+ }
395
+ return inner;
396
+ }
397
+ return t;
398
+ });
399
+ const inlineTraits = unwrapped.filter(isInlineTrait);
400
+ const traitDefs = inlineTraits.map((t) => {
401
+ const sm = t.stateMachine;
402
+ const states = sm?.states || [];
403
+ const transitions = sm?.transitions || [];
404
+ return {
405
+ name: t.name,
406
+ states,
407
+ transitions,
408
+ listens: t.listens
409
+ };
410
+ });
411
+ const manager = new StateMachineManager(traitDefs, {
412
+ contextExtensions: this.config.contextExtensions
413
+ });
414
+ for (const [traitName, traitConfig] of configByTrait) {
415
+ manager.setTraitConfig(traitName, traitConfig);
416
+ }
417
+ const entityRef = orbital.entity;
418
+ let entity;
419
+ if (typeof entityRef === "string") {
420
+ entity = { name: entityRef, fields: [] };
421
+ } else if (isEntityCall(entityRef)) {
422
+ const fallbackName = entityRef.name ?? entityRef.extends.replace(/\.entity$/, "");
423
+ entity = {
424
+ name: fallbackName,
425
+ fields: entityRef.fields ?? [],
426
+ ...entityRef.persistence ? { persistence: entityRef.persistence } : {},
427
+ ...entityRef.collection ? { collection: entityRef.collection } : {}
428
+ };
429
+ } else {
430
+ entity = entityRef;
431
+ }
432
+ this.orbitals.set(orbital.name, {
433
+ schema: orbital,
434
+ entity,
435
+ traits: inlineTraits,
436
+ configByTrait,
437
+ manager,
438
+ entityData: /* @__PURE__ */ new Map(),
439
+ traitFieldStates: /* @__PURE__ */ new Map()
440
+ });
441
+ if (entity?.name && entity.instances && Array.isArray(entity.instances)) {
442
+ const instances = entity.instances;
443
+ if (instances.length > 0) {
444
+ persistLog.debug("seed:start", { entity: entity.name, count: instances.length });
445
+ const results = await Promise.all(
446
+ instances.map(async (instance) => {
447
+ try {
448
+ const result = await this.persistence.create(entity.name, instance);
449
+ persistLog.debug("seed:instance", { entity: entity.name, id: instance.id ?? "no-id" });
450
+ return result;
451
+ } catch (err) {
452
+ persistLog.error("seed:instance-error", {
453
+ entity: entity.name,
454
+ id: instance.id,
455
+ error: err instanceof Error ? err : String(err)
456
+ });
457
+ return null;
458
+ }
459
+ })
460
+ );
461
+ const successCount = results.filter((r) => r !== null).length;
462
+ persistLog.debug("seed:done", { entity: entity.name, success: successCount, total: instances.length });
463
+ }
464
+ } else if (this.config.mode === "mock" && this.persistence instanceof MockPersistenceAdapter) {
465
+ if (this.config.debug) {
466
+ persistLog.debug("mock:generate", { entity: entity?.name });
467
+ }
468
+ if (entity?.name && entity.fields) {
469
+ const fields = entity.fields.filter(
470
+ (f) => typeof f.name === "string" && f.name.length > 0
471
+ );
472
+ this.persistence.registerEntity({ name: entity.name, fields });
473
+ if (this.config.debug) {
474
+ persistLog.debug("mock:seeded", { entity: entity.name, count: this.persistence.count(entity.name) });
475
+ }
476
+ }
477
+ }
478
+ const auxiliaryEntities = orbital.auxiliaryEntities;
479
+ if (auxiliaryEntities !== void 0 && auxiliaryEntities.length > 0 && this.config.mode === "mock" && this.persistence instanceof MockPersistenceAdapter) {
480
+ for (const auxRef of auxiliaryEntities) {
481
+ if (typeof auxRef === "string" || isEntityCall(auxRef)) continue;
482
+ const auxEntity = auxRef;
483
+ if (!auxEntity.name || !auxEntity.fields) continue;
484
+ const auxFields = auxEntity.fields.filter(
485
+ (f) => typeof f.name === "string" && f.name.length > 0
486
+ );
487
+ this.persistence.registerEntity({ name: auxEntity.name, fields: auxFields });
488
+ if (this.config.debug) {
489
+ persistLog.debug("mock:seeded-auxiliary", {
490
+ entity: auxEntity.name,
491
+ count: this.persistence.count(auxEntity.name)
492
+ });
493
+ }
494
+ }
495
+ }
496
+ if (this.config.debug) {
497
+ registerLog.debug("register:orbital", {
498
+ name: orbital.name,
499
+ traitCount: (orbital.traits || []).length
500
+ });
501
+ }
502
+ }
503
+ /**
504
+ * Register a single orbital (sync wrapper for backward compatibility)
505
+ */
506
+ registerOrbital(orbital) {
507
+ this.registerOrbitalAsync(orbital).catch((err) => {
508
+ registerLog.error("register:failed", {
509
+ name: orbital.name,
510
+ error: err instanceof Error ? err : String(err)
511
+ });
512
+ });
513
+ }
514
+ /**
515
+ * Set up event listeners for cross-orbital communication
516
+ */
517
+ setupEventListeners() {
518
+ for (const cleanup of this.listenerCleanups) {
519
+ cleanup();
520
+ }
521
+ this.listenerCleanups = [];
522
+ for (const [orbitalName, registered] of this.orbitals) {
523
+ for (const trait of registered.traits) {
524
+ if (!trait.listens) continue;
525
+ for (const listener of trait.listens) {
526
+ const { bareEvent, matcher } = parseListenSource(listener, orbitalName);
527
+ const cleanup = this.eventBus.on(bareEvent, async (event) => {
528
+ if (!matcher(event.source)) return;
529
+ if (this.config.debug) {
530
+ xOrbitalLog.debug("listen:received", () => ({
531
+ receiverOrbital: orbitalName,
532
+ receiverTrait: trait.name,
533
+ event: listener.event,
534
+ sourceOrbital: event.source?.orbital ?? "?",
535
+ sourceTrait: event.source?.trait ?? "?"
536
+ }));
537
+ }
538
+ let mappedPayload = event.payload;
539
+ if (listener.payloadMapping && event.payload) {
540
+ mappedPayload = {};
541
+ for (const [key, expr] of Object.entries(
542
+ listener.payloadMapping
543
+ )) {
544
+ if (typeof expr === "string" && expr.startsWith("@payload.")) {
545
+ const field = expr.slice("@payload.".length);
546
+ mappedPayload[key] = event.payload[field];
547
+ } else {
548
+ mappedPayload[key] = expr;
549
+ }
550
+ }
551
+ }
552
+ const raw = event.payload;
553
+ const mapped = mappedPayload;
554
+ const pickId = (field) => mapped?.[field] ?? raw?.[field];
555
+ const forwardedEntityId = pickId("entityId") ?? pickId("orbitalName");
556
+ await this.processOrbitalEvent(orbitalName, {
557
+ event: listener.triggers,
558
+ payload: mappedPayload,
559
+ entityId: forwardedEntityId
560
+ });
561
+ });
562
+ this.listenerCleanups.push(cleanup);
563
+ }
564
+ }
565
+ }
566
+ }
567
+ /**
568
+ * Set up scheduled ticks for all traits
569
+ */
570
+ setupTicks() {
571
+ this.cleanupTicks();
572
+ for (const [orbitalName, registered] of this.orbitals) {
573
+ for (const trait of registered.traits || []) {
574
+ if (!trait.ticks || trait.ticks.length === 0) continue;
575
+ for (const tick of trait.ticks) {
576
+ this.registerTick(orbitalName, trait.name, tick, registered);
577
+ }
578
+ }
579
+ }
580
+ if (this.config.debug && this.tickBindings.length > 0) {
581
+ registerLog.debug("register:ticks", { count: this.tickBindings.length });
582
+ }
583
+ }
584
+ /**
585
+ * Register a single tick
586
+ */
587
+ registerTick(orbitalName, traitName, tick, registered) {
588
+ let intervalMs;
589
+ if (typeof tick.interval === "number") {
590
+ intervalMs = tick.interval;
591
+ } else if (typeof tick.interval === "string") {
592
+ intervalMs = this.parseIntervalString(tick.interval);
593
+ } else {
594
+ intervalMs = 1e3;
595
+ }
596
+ if (this.config.debug) {
597
+ registerLog.debug("register:tick", {
598
+ orbital: orbitalName,
599
+ trait: traitName,
600
+ tick: tick.name,
601
+ intervalMs
602
+ });
603
+ }
604
+ const timerId = setInterval(async () => {
605
+ await this.executeTick(orbitalName, traitName, tick, registered);
606
+ }, intervalMs);
607
+ this.tickBindings.push({
608
+ orbitalName,
609
+ traitName,
610
+ tick,
611
+ timerId
612
+ });
613
+ }
614
+ /**
615
+ * Parse interval string to milliseconds
616
+ * Supports: '5s', '1m', '1h', '30000' (ms)
617
+ */
618
+ parseIntervalString(interval) {
619
+ const match = interval.match(/^(\d+)(ms|s|m|h)?$/);
620
+ if (!match) {
621
+ registerLog.warn("register:tick-invalid-interval", { interval, defaultMs: 1e3 });
622
+ return 1e3;
623
+ }
624
+ const value = parseInt(match[1], 10);
625
+ const unit = match[2] || "ms";
626
+ switch (unit) {
627
+ case "ms":
628
+ return value;
629
+ case "s":
630
+ return value * 1e3;
631
+ case "m":
632
+ return value * 60 * 1e3;
633
+ case "h":
634
+ return value * 60 * 60 * 1e3;
635
+ default:
636
+ return value;
637
+ }
638
+ }
639
+ /**
640
+ * Execute a tick for all applicable entities
641
+ */
642
+ async executeTick(orbitalName, traitName, tick, registered) {
643
+ const entityType = registered.entity.name;
644
+ const emittedEvents = [];
645
+ try {
646
+ let entities = await this.persistence.list(entityType);
647
+ if (tick.appliesTo && tick.appliesTo.length > 0) {
648
+ const appliesToSet = new Set(tick.appliesTo);
649
+ entities = entities.filter((e) => appliesToSet.has(e.id));
650
+ }
651
+ if (this.config.debug && entities.length > 0) {
652
+ effectLog.debug("tick:processing", () => ({
653
+ orbital: orbitalName,
654
+ trait: traitName,
655
+ tick: tick.name,
656
+ entityCount: entities.length
657
+ }));
658
+ }
659
+ for (const entity of entities) {
660
+ if (tick.guard) {
661
+ try {
662
+ const ctx = createContextFromBindings({
663
+ entity,
664
+ payload: {},
665
+ state: registered.manager.getState(traitName)?.currentState || "unknown"
666
+ }, false, this.config.contextExtensions);
667
+ const guardPasses = evaluateGuard(
668
+ tick.guard,
669
+ ctx
670
+ );
671
+ if (!guardPasses) {
672
+ if (this.config.debug) {
673
+ effectLog.debug("tick:guard-failed", () => ({
674
+ tick: tick.name,
675
+ entityId: typeof entity.id === "string" ? entity.id : void 0
676
+ }));
677
+ }
678
+ continue;
679
+ }
680
+ } catch (error) {
681
+ effectLog.error("tick:guard-error", {
682
+ tick: tick.name,
683
+ entityId: typeof entity.id === "string" ? entity.id : void 0,
684
+ error: error instanceof Error ? error : String(error)
685
+ });
686
+ continue;
687
+ }
688
+ }
689
+ if (tick.effects && tick.effects.length > 0) {
690
+ const fetchedData = {};
691
+ const clientEffects = [];
692
+ const tickEffectResults = [];
693
+ await this.executeEffects(
694
+ registered,
695
+ traitName,
696
+ tick.effects,
697
+ {},
698
+ // No payload for ticks
699
+ entity,
700
+ entity.id,
701
+ emittedEvents,
702
+ fetchedData,
703
+ clientEffects,
704
+ tickEffectResults
705
+ );
706
+ if (this.config.debug) {
707
+ effectLog.debug("tick:effects-executed", () => ({
708
+ tick: tick.name,
709
+ entityId: typeof entity.id === "string" ? entity.id : void 0
710
+ }));
711
+ }
712
+ }
713
+ }
714
+ } catch (error) {
715
+ effectLog.error("tick:execute-error", {
716
+ tick: tick.name,
717
+ error: error instanceof Error ? error : String(error)
718
+ });
719
+ }
720
+ }
721
+ /**
722
+ * Clean up all active ticks
723
+ */
724
+ cleanupTicks() {
725
+ for (const binding of this.tickBindings) {
726
+ clearInterval(binding.timerId);
727
+ }
728
+ this.tickBindings = [];
729
+ }
730
+ /**
731
+ * Unregister all orbitals and clean up
732
+ */
733
+ unregisterAll() {
734
+ this.cleanupTicks();
735
+ for (const cleanup of this.listenerCleanups) {
736
+ cleanup();
737
+ }
738
+ this.listenerCleanups = [];
739
+ this.orbitals.clear();
740
+ this.eventBus.clear();
741
+ if (this.persistence instanceof MockPersistenceAdapter) {
742
+ this.persistence.clearAll();
743
+ }
744
+ if (this.osHandlers) {
745
+ this.osHandlers.cleanup();
746
+ this.osHandlers = null;
747
+ }
748
+ }
749
+ /**
750
+ * Reset the mock persistence store to a clean-slate re-seed without
751
+ * unregistering orbitals. Exposed for verifier tools that want to
752
+ * start each test with deterministic seeded rows, not the residue of
753
+ * the previous walk's persist-creates. No-op when the persistence
754
+ * layer is not MockPersistenceAdapter.
755
+ */
756
+ resetMockPersistence() {
757
+ if (!(this.persistence instanceof MockPersistenceAdapter)) return;
758
+ busLog.debug("mock:reset:enter", {
759
+ orbitalCount: this.orbitals.size,
760
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
761
+ });
762
+ this.persistence.clearAll();
763
+ for (const registered of this.orbitals.values()) {
764
+ const entity = registered.entity;
765
+ if (entity?.name && entity.fields) {
766
+ const fields = entity.fields.filter(
767
+ (f) => typeof f.name === "string" && f.name.length > 0
768
+ );
769
+ this.persistence.registerEntity({ name: entity.name, fields });
770
+ }
771
+ }
772
+ }
773
+ // ==========================================================================
774
+ // Event Processing
775
+ // ==========================================================================
776
+ /**
777
+ * Process an event for an orbital
778
+ */
779
+ async processOrbitalEvent(orbitalName, request) {
780
+ const registered = this.orbitals.get(orbitalName);
781
+ if (!registered) {
782
+ return {
783
+ success: false,
784
+ transitioned: false,
785
+ states: {},
786
+ emittedEvents: [],
787
+ error: `Orbital not found: ${orbitalName}`
788
+ };
789
+ }
790
+ const payloadRow = request.payload?.["row"];
791
+ const payloadRowAsPayload = payloadRow !== null && typeof payloadRow === "object" && !Array.isArray(payloadRow) ? payloadRow : void 0;
792
+ const payloadRowId = payloadRowAsPayload?.["id"];
793
+ renderLog.debug("processOrbitalEvent:enter", {
794
+ orbital: orbitalName,
795
+ event: request.event,
796
+ hasPayloadRow: payloadRowAsPayload !== void 0,
797
+ payloadRowId: typeof payloadRowId === "string" || typeof payloadRowId === "number" ? payloadRowId : void 0,
798
+ entityId: request.entityId
799
+ });
800
+ busLog.debug("bus:incoming", () => ({
801
+ orbital: orbitalName,
802
+ event: request.event,
803
+ payload: JSON.stringify(request.payload ?? null),
804
+ entityId: request.entityId,
805
+ traitStates: JSON.stringify(
806
+ Array.from(registered.manager.getAllStates().entries()).map(([traitName, state]) => ({
807
+ traitName,
808
+ currentState: state.currentState
809
+ }))
810
+ )
811
+ }));
812
+ xOrbitalLog.info("processOrbitalEvent:enter", () => ({
813
+ orbital: orbitalName,
814
+ event: request.event,
815
+ traitsInOrbital: registered.traits.map((t) => t.name).join(","),
816
+ payloadActiveTraits: JSON.stringify(
817
+ request.payload?.["_activeTraits"] ?? null
818
+ )
819
+ }));
820
+ const { event, payload, entityId, user } = request;
821
+ const validationFailures = [];
822
+ for (const trait of registered.traits) {
823
+ const eventSchema = trait.stateMachine?.events?.find((e) => e.key === event);
824
+ if (eventSchema?.payloadSchema && eventSchema.payloadSchema.length > 0) {
825
+ validationFailures.push(
826
+ ...validateEventPayload(event, payload, eventSchema.payloadSchema)
827
+ );
828
+ }
829
+ }
830
+ if (validationFailures.length > 0) {
831
+ return {
832
+ success: false,
833
+ transitioned: false,
834
+ states: {},
835
+ emittedEvents: [],
836
+ error: formatPayloadValidationError(validationFailures)
837
+ };
838
+ }
839
+ const emittedEvents = [];
840
+ const fetchedData = {};
841
+ const clientEffects = [];
842
+ const clientEffectsByTrait = [];
843
+ const effectResults = [];
844
+ const activeTraits = payload?._activeTraits;
845
+ const cleanPayload = payload ? { ...payload } : void 0;
846
+ if (cleanPayload) {
847
+ delete cleanPayload._activeTraits;
848
+ }
849
+ let entityData = {};
850
+ if (entityId) {
851
+ const stored = await this.persistence.getById(
852
+ registered.entity.name,
853
+ entityId
854
+ );
855
+ if (stored) {
856
+ entityData = stored;
857
+ }
858
+ }
859
+ const entityByTrait = {};
860
+ for (const [name, fields] of registered.traitFieldStates) {
861
+ if (fields && Object.keys(fields).length > 0) {
862
+ entityByTrait[name] = fields;
863
+ }
864
+ }
865
+ const results = registered.manager.sendEvent(
866
+ event,
867
+ cleanPayload,
868
+ entityData,
869
+ entityByTrait
870
+ );
871
+ const filteredResults = activeTraits && activeTraits.length > 0 ? results.filter(({ traitName }) => activeTraits.includes(traitName)) : results;
872
+ if (this.config.debug && activeTraits) {
873
+ busLog.debug("dispatch:filter-traits", () => ({
874
+ total: results.length,
875
+ active: filteredResults.length,
876
+ activeTraits: activeTraits.join(",")
877
+ }));
878
+ }
879
+ for (const { traitName, result } of filteredResults) {
880
+ if (result.effects.length > 0) {
881
+ await this.executeEffects(
882
+ registered,
883
+ traitName,
884
+ result.effects,
885
+ cleanPayload,
886
+ entityData,
887
+ entityId,
888
+ emittedEvents,
889
+ fetchedData,
890
+ clientEffects,
891
+ effectResults,
892
+ user,
893
+ clientEffectsByTrait
894
+ );
895
+ }
896
+ }
897
+ const states = {};
898
+ for (const [name, state] of registered.manager.getAllStates()) {
899
+ states[name] = state.currentState;
900
+ }
901
+ const response = {
902
+ success: true,
903
+ transitioned: results.length > 0,
904
+ states,
905
+ emittedEvents
906
+ };
907
+ if (clientEffects.length > 0) {
908
+ response.clientEffects = clientEffects;
909
+ }
910
+ if (clientEffectsByTrait.length > 0) {
911
+ response.clientEffectsByTrait = clientEffectsByTrait;
912
+ }
913
+ if (effectResults.length > 0) {
914
+ response.effectResults = effectResults;
915
+ }
916
+ return response;
917
+ }
918
+ /**
919
+ * Execute effects from a transition
920
+ */
921
+ async executeEffects(registered, traitName, effects, payload, entityData, entityId, emittedEvents, fetchedData, clientEffects, effectResults, user, clientEffectsByTrait) {
922
+ const entityType = registered.entity.name;
923
+ const pushClientEffect = (effect) => {
924
+ clientEffects.push(effect);
925
+ clientEffectsByTrait?.push({ traitName, effect });
926
+ };
927
+ let bindingsRef = null;
928
+ let contextRef = null;
929
+ const handlers = {
930
+ emit: (event, eventPayload, source) => {
931
+ if (this.config.debug) {
932
+ busLog.debug("emit:dispatch", () => ({
933
+ event,
934
+ payloadJson: JSON.stringify(eventPayload ?? null),
935
+ sourceOrbital: source?.orbital,
936
+ sourceTrait: source?.trait
937
+ }));
938
+ }
939
+ const stamp = source ?? {
940
+ orbital: registered.schema.name,
941
+ trait: traitName
942
+ };
943
+ this.eventBus.emit(event, eventPayload, stamp);
944
+ emittedEvents.push({ event, payload: eventPayload, source: stamp });
945
+ effectLog.debug("emit:push", {
946
+ event,
947
+ cumulativeEmittedCount: emittedEvents.length,
948
+ sourceTrait: stamp.trait,
949
+ sourceOrbital: stamp.orbital
950
+ });
951
+ xOrbitalLog.info("emit:server", {
952
+ event,
953
+ sourceOrbital: stamp.orbital,
954
+ sourceTrait: stamp.trait,
955
+ dispatchOrbital: registered.schema.name
956
+ });
957
+ },
958
+ set: async (targetId, field, value) => {
959
+ let fieldState = registered.traitFieldStates.get(traitName);
960
+ if (!fieldState) {
961
+ fieldState = {};
962
+ registered.traitFieldStates.set(traitName, fieldState);
963
+ }
964
+ fieldState[field] = value;
965
+ effectResults.push({
966
+ effect: "set",
967
+ entityType,
968
+ data: { id: targetId || entityId || "", field, value },
969
+ success: true
970
+ });
971
+ },
972
+ persist: async (action, targetEntityType, data) => {
973
+ if (action === "batch") {
974
+ const operations = data?.operations;
975
+ if (!Array.isArray(operations) || operations.length === 0) {
976
+ effectResults.push({
977
+ effect: "persist",
978
+ action: "batch",
979
+ success: false,
980
+ error: "Batch requires a non-empty operations array"
981
+ });
982
+ return;
983
+ }
984
+ const batchResults = [];
985
+ const completed = [];
986
+ let batchFailed = false;
987
+ let batchError = "";
988
+ for (const op of operations) {
989
+ if (!Array.isArray(op) || op.length < 2) {
990
+ batchFailed = true;
991
+ batchError = `Invalid batch operation format: ${JSON.stringify(op)}`;
992
+ break;
993
+ }
994
+ const [opAction, opEntityType, ...opRest] = op;
995
+ try {
996
+ switch (opAction) {
997
+ case "create": {
998
+ const createData = opRest[0] || {};
999
+ const { id: newId } = await this.persistence.create(opEntityType, createData);
1000
+ batchResults.push({ action: "create", entityType: opEntityType, id: newId, ...createData });
1001
+ completed.push({ action: "create", entityType: opEntityType, id: newId });
1002
+ break;
1003
+ }
1004
+ case "update": {
1005
+ const updateId = opRest[0];
1006
+ const updateData = opRest[1] || {};
1007
+ await this.persistence.update(opEntityType, updateId, updateData);
1008
+ const updated = await this.persistence.getById(opEntityType, updateId);
1009
+ batchResults.push({ action: "update", entityType: opEntityType, id: updateId, ...updated || updateData });
1010
+ completed.push({ action: "update", entityType: opEntityType, id: updateId });
1011
+ break;
1012
+ }
1013
+ case "delete": {
1014
+ const deleteId = opRest[0];
1015
+ await this.persistence.delete(opEntityType, deleteId);
1016
+ batchResults.push({ action: "delete", entityType: opEntityType, id: deleteId, deleted: true });
1017
+ completed.push({ action: "delete", entityType: opEntityType, id: deleteId });
1018
+ break;
1019
+ }
1020
+ default:
1021
+ batchFailed = true;
1022
+ batchError = `Unknown batch operation action: ${opAction}`;
1023
+ break;
1024
+ }
1025
+ } catch (err) {
1026
+ batchFailed = true;
1027
+ batchError = `Batch operation [${opAction}, ${opEntityType}] failed: ${err instanceof Error ? err.message : String(err)}`;
1028
+ break;
1029
+ }
1030
+ if (batchFailed) break;
1031
+ }
1032
+ effectResults.push({
1033
+ effect: "persist",
1034
+ action: "batch",
1035
+ data: {
1036
+ operations: batchResults,
1037
+ completedCount: completed.length,
1038
+ totalCount: operations.length
1039
+ },
1040
+ success: !batchFailed,
1041
+ ...batchFailed ? { error: batchError } : {}
1042
+ });
1043
+ return;
1044
+ }
1045
+ const type = targetEntityType || entityType;
1046
+ let resultData;
1047
+ const sizeBefore = (await this.persistence.list(type)).length;
1048
+ try {
1049
+ if (action === "create" || action === "update") {
1050
+ this.validateRelationCardinality(type, data || {});
1051
+ }
1052
+ switch (action) {
1053
+ case "create": {
1054
+ const { id } = await this.persistence.create(type, data || {});
1055
+ resultData = { id, ...data || {} };
1056
+ break;
1057
+ }
1058
+ case "update":
1059
+ if (data?.id || entityId) {
1060
+ const updateId = data?.id || entityId;
1061
+ await this.persistence.update(type, updateId, data || {});
1062
+ const updated = await this.persistence.getById(type, updateId);
1063
+ resultData = updated || { id: updateId, ...data || {} };
1064
+ }
1065
+ break;
1066
+ case "delete": {
1067
+ const directId = typeof data === "string" ? data : void 0;
1068
+ const nestedId = typeof data === "object" && data !== null ? data.id : void 0;
1069
+ const deleteId = directId ?? nestedId ?? entityId;
1070
+ if (deleteId) {
1071
+ await this.enforceOnDeleteRules(type, deleteId);
1072
+ await this.persistence.delete(type, deleteId);
1073
+ resultData = { id: deleteId, deleted: true };
1074
+ }
1075
+ break;
1076
+ }
1077
+ }
1078
+ const sizeAfter = (await this.persistence.list(type)).length;
1079
+ effectLog.debug("persist:store-mutate", {
1080
+ action,
1081
+ entityType: type,
1082
+ resultId: resultData?.id,
1083
+ sizeBefore,
1084
+ sizeAfter,
1085
+ delta: sizeAfter - sizeBefore
1086
+ });
1087
+ effectResults.push({
1088
+ effect: "persist",
1089
+ action,
1090
+ entityType: type,
1091
+ data: resultData,
1092
+ success: true
1093
+ });
1094
+ } catch (err) {
1095
+ effectLog.error("persist:store-mutate-error", {
1096
+ action,
1097
+ entityType: type,
1098
+ error: err instanceof Error ? err.message : String(err)
1099
+ });
1100
+ effectResults.push({
1101
+ effect: "persist",
1102
+ action,
1103
+ entityType: type,
1104
+ success: false,
1105
+ error: err instanceof Error ? err.message : String(err)
1106
+ });
1107
+ }
1108
+ },
1109
+ callService: async (service, action, params) => {
1110
+ try {
1111
+ let result = null;
1112
+ if (this.config.effectHandlers?.callService) {
1113
+ result = await this.config.effectHandlers.callService(
1114
+ service,
1115
+ action,
1116
+ params
1117
+ );
1118
+ } else if (this.config.mode === "mock") {
1119
+ const mockId = `mock_${service}_${action}_${Math.random().toString(36).slice(2, 10)}`;
1120
+ const paramsEcho = {};
1121
+ if (params) {
1122
+ for (const [k, v] of Object.entries(params)) {
1123
+ if (v !== void 0 && (typeof v === "string" || typeof v === "number" || typeof v === "boolean" || v === null || v instanceof Date)) {
1124
+ paramsEcho[k] = v;
1125
+ }
1126
+ }
1127
+ }
1128
+ result = {
1129
+ id: mockId,
1130
+ clientSecret: `secret_${mockId}`,
1131
+ success: true,
1132
+ status: "succeeded",
1133
+ ...paramsEcho
1134
+ };
1135
+ } else {
1136
+ effectLog.warn("call-service:not-configured", { service, action });
1137
+ }
1138
+ effectResults.push({
1139
+ effect: "call-service",
1140
+ action: `${service}.${action}`,
1141
+ data: result,
1142
+ success: true
1143
+ });
1144
+ return result;
1145
+ } catch (err) {
1146
+ effectResults.push({
1147
+ effect: "call-service",
1148
+ action: `${service}.${action}`,
1149
+ success: false,
1150
+ error: err instanceof Error ? err.message : String(err)
1151
+ });
1152
+ return null;
1153
+ }
1154
+ },
1155
+ fetch: async (fetchEntityType, options) => {
1156
+ try {
1157
+ xOrbitalLog.info("fetch:enter", () => ({
1158
+ entityType: fetchEntityType,
1159
+ hasOptions: options !== void 0 && options !== null,
1160
+ optionsKeys: options ? Object.keys(options).join(",") : "",
1161
+ filterType: typeof options?.filter,
1162
+ filterIsArray: Array.isArray(options?.filter),
1163
+ filterJson: JSON.stringify(options?.filter ?? null).slice(0, 300),
1164
+ payloadJson: JSON.stringify(bindingsRef?.payload ?? null).slice(0, 300)
1165
+ }));
1166
+ let result = null;
1167
+ let total = 0;
1168
+ if (options?.id) {
1169
+ const entity = await this.persistence.getById(fetchEntityType, options.id);
1170
+ if (entity) {
1171
+ if (options?.include && options.include.length > 0) {
1172
+ await this.populateRelations([entity], fetchEntityType, options.include);
1173
+ }
1174
+ fetchedData[fetchEntityType] = [entity];
1175
+ result = entity;
1176
+ total = 1;
1177
+ }
1178
+ } else {
1179
+ let entities = await this.persistence.list(fetchEntityType);
1180
+ if (options?.filter !== void 0 && options.filter !== null) {
1181
+ const predicate = options.filter;
1182
+ entities = entities.filter((entity) => {
1183
+ const ctx = createContextFromBindings(
1184
+ { entity, payload: bindingsRef?.payload, current: entity },
1185
+ false
1186
+ );
1187
+ try {
1188
+ return Boolean(evaluate(predicate, ctx));
1189
+ } catch (err) {
1190
+ effectLog.error("fetch:filter-eval-error", {
1191
+ entityType: fetchEntityType,
1192
+ error: err instanceof Error ? err : String(err)
1193
+ });
1194
+ return false;
1195
+ }
1196
+ });
1197
+ }
1198
+ total = entities.length;
1199
+ if (options?.offset && options.offset > 0) {
1200
+ entities = entities.slice(options.offset);
1201
+ }
1202
+ if (options?.limit && options.limit > 0) {
1203
+ entities = entities.slice(0, options.limit);
1204
+ }
1205
+ if (options?.include && options.include.length > 0) {
1206
+ await this.populateRelations(entities, fetchEntityType, options.include);
1207
+ }
1208
+ fetchedData[fetchEntityType] = entities;
1209
+ result = entities;
1210
+ }
1211
+ return result === null ? null : { rows: result, total };
1212
+ } catch (error) {
1213
+ effectLog.error("fetch:error", {
1214
+ entityType: fetchEntityType,
1215
+ error: error instanceof Error ? error : String(error)
1216
+ });
1217
+ return null;
1218
+ }
1219
+ },
1220
+ // Resource operators: ref, deref, swap, watch, atomic
1221
+ ref: async (refEntityType, options) => {
1222
+ try {
1223
+ return await handlers.fetch(refEntityType, options);
1224
+ } catch (error) {
1225
+ effectLog.error("ref:error", {
1226
+ entityType: refEntityType,
1227
+ error: error instanceof Error ? error : String(error)
1228
+ });
1229
+ return null;
1230
+ }
1231
+ },
1232
+ deref: async (derefEntityType, options) => {
1233
+ try {
1234
+ let result = null;
1235
+ let total = 0;
1236
+ if (options?.id) {
1237
+ const entity = await this.persistence.getById(derefEntityType, options.id);
1238
+ if (entity) {
1239
+ fetchedData[derefEntityType] = [entity];
1240
+ result = entity;
1241
+ total = 1;
1242
+ }
1243
+ } else {
1244
+ const entities = await this.persistence.list(derefEntityType);
1245
+ fetchedData[derefEntityType] = entities;
1246
+ result = entities;
1247
+ total = entities.length;
1248
+ }
1249
+ effectResults.push({
1250
+ effect: "deref",
1251
+ entityType: derefEntityType,
1252
+ success: true
1253
+ });
1254
+ return result === null ? null : { rows: result, total };
1255
+ } catch (error) {
1256
+ effectResults.push({
1257
+ effect: "deref",
1258
+ entityType: derefEntityType,
1259
+ success: false,
1260
+ error: error instanceof Error ? error.message : String(error)
1261
+ });
1262
+ return null;
1263
+ }
1264
+ },
1265
+ swap: async (swapEntityType, swapEntityId, transform) => {
1266
+ try {
1267
+ const current = await this.persistence.getById(swapEntityType, swapEntityId);
1268
+ if (!current) {
1269
+ effectResults.push({
1270
+ effect: "swap",
1271
+ entityType: swapEntityType,
1272
+ success: false,
1273
+ error: `Entity ${swapEntityType}/${swapEntityId} not found`
1274
+ });
1275
+ return null;
1276
+ }
1277
+ const ctx = createContextFromBindings({
1278
+ current,
1279
+ entity: entityData,
1280
+ payload
1281
+ }, false, this.config.contextExtensions);
1282
+ let newData;
1283
+ if (Array.isArray(transform)) {
1284
+ const result = evaluate(
1285
+ transform,
1286
+ ctx
1287
+ );
1288
+ if (result && typeof result === "object" && !Array.isArray(result)) {
1289
+ newData = result;
1290
+ } else {
1291
+ newData = current;
1292
+ }
1293
+ } else if (typeof transform === "object" && transform !== null) {
1294
+ newData = { ...current, ...transform };
1295
+ } else {
1296
+ effectResults.push({
1297
+ effect: "swap",
1298
+ entityType: swapEntityType,
1299
+ success: false,
1300
+ error: "swap! transform must be an S-expression or object"
1301
+ });
1302
+ return null;
1303
+ }
1304
+ await this.persistence.update(swapEntityType, swapEntityId, newData);
1305
+ effectResults.push({
1306
+ effect: "swap",
1307
+ entityType: swapEntityType,
1308
+ data: { id: swapEntityId, ...newData },
1309
+ success: true
1310
+ });
1311
+ return newData;
1312
+ } catch (error) {
1313
+ effectResults.push({
1314
+ effect: "swap",
1315
+ entityType: swapEntityType,
1316
+ success: false,
1317
+ error: error instanceof Error ? error.message : String(error)
1318
+ });
1319
+ return null;
1320
+ }
1321
+ },
1322
+ watch: (_watchEntityType, _watchOptions) => {
1323
+ if (this.config.debug) {
1324
+ effectLog.debug("watch:noop-server", { entityType: _watchEntityType });
1325
+ }
1326
+ },
1327
+ atomic: async (atomicEffects) => {
1328
+ let atomicFailed = false;
1329
+ let atomicError = "";
1330
+ const atomicExecutor = new EffectExecutor({
1331
+ handlers,
1332
+ bindings: bindingsRef ?? {},
1333
+ context: contextRef ?? { traitName, orbitalName: registered.schema.name, state: "unknown", transition: "unknown" },
1334
+ debug: this.config.debug,
1335
+ contextExtensions: this.config.contextExtensions
1336
+ });
1337
+ for (const innerEffect of atomicEffects) {
1338
+ if (atomicFailed) break;
1339
+ try {
1340
+ await atomicExecutor.execute(innerEffect);
1341
+ } catch (err) {
1342
+ atomicFailed = true;
1343
+ atomicError = err instanceof Error ? err.message : String(err);
1344
+ }
1345
+ }
1346
+ if (atomicFailed) {
1347
+ effectResults.push({
1348
+ effect: "atomic",
1349
+ success: false,
1350
+ error: `Atomic block failed: ${atomicError}`
1351
+ });
1352
+ } else {
1353
+ effectResults.push({
1354
+ effect: "atomic",
1355
+ success: true,
1356
+ data: { innerCount: atomicEffects.length }
1357
+ });
1358
+ }
1359
+ },
1360
+ // Client-side effects - collect for forwarding to client
1361
+ renderUI: (slot, pattern, props, priority) => {
1362
+ const patternNode = pattern !== null && typeof pattern === "object" && !Array.isArray(pattern) ? pattern : null;
1363
+ const patternEntity = patternNode?.entity;
1364
+ const entityRow = patternEntity !== null && typeof patternEntity === "object" && !Array.isArray(patternEntity) ? patternEntity : null;
1365
+ const patternTypeRaw = patternNode?.["type"];
1366
+ renderLog.debug("renderUI:push", {
1367
+ trait: traitName,
1368
+ slot,
1369
+ patternType: typeof patternTypeRaw === "string" ? patternTypeRaw : void 0,
1370
+ entityRowId: typeof entityRow?.id === "string" ? entityRow.id : void 0,
1371
+ entityIsObject: entityRow !== null
1372
+ });
1373
+ pushClientEffect(["render-ui", slot, pattern, props, priority]);
1374
+ },
1375
+ navigate: (path, params) => {
1376
+ pushClientEffect(["navigate", path, params]);
1377
+ },
1378
+ notify: (message, type) => {
1379
+ if (this.config.debug) {
1380
+ effectLog.info("notify", { type, message });
1381
+ }
1382
+ pushClientEffect(["notify", message, { type }]);
1383
+ },
1384
+ log: (message, level) => {
1385
+ if (level === "error") {
1386
+ dynamicLog.error(message);
1387
+ } else if (level === "warn") {
1388
+ dynamicLog.warn(message);
1389
+ } else {
1390
+ dynamicLog.debug(message);
1391
+ }
1392
+ },
1393
+ // Allow custom handlers to override
1394
+ ...this.config.effectHandlers
1395
+ };
1396
+ const state = registered.manager.getState(traitName);
1397
+ const bindings = {
1398
+ entity: entityData,
1399
+ payload,
1400
+ state: state?.currentState || "unknown",
1401
+ user
1402
+ // @user bindings from Firebase auth
1403
+ };
1404
+ const traitDef = registered.traits.find((t) => t.name === traitName);
1405
+ const declaredDefaults = collectDeclaredConfigDefaults(traitDef);
1406
+ const callSiteOverride = registered.configByTrait.get(traitName);
1407
+ if (declaredDefaults || callSiteOverride) {
1408
+ bindings.config = { ...declaredDefaults ?? {}, ...callSiteOverride ?? {} };
1409
+ }
1410
+ const traitFieldState = registered.traitFieldStates.get(traitName);
1411
+ if (traitFieldState) {
1412
+ bindings.entity = traitFieldState;
1413
+ }
1414
+ if (entityType) {
1415
+ bindings[entityType] = bindings.entity ?? entityData;
1416
+ }
1417
+ bindingsRef = bindings;
1418
+ const context = {
1419
+ traitName,
1420
+ orbitalName: registered.schema.name,
1421
+ state: state?.currentState || "unknown",
1422
+ transition: "unknown",
1423
+ entityId
1424
+ };
1425
+ contextRef = context;
1426
+ const executor = new EffectExecutor({
1427
+ handlers,
1428
+ bindings,
1429
+ context,
1430
+ debug: this.config.debug,
1431
+ contextExtensions: this.config.contextExtensions
1432
+ });
1433
+ await executor.executeAll(effects);
1434
+ }
1435
+ // ==========================================================================
1436
+ // Relation Population
1437
+ // ==========================================================================
1438
+ /**
1439
+ * Populate relation fields on entities
1440
+ *
1441
+ * For each field in `include`, find the relation field configuration and
1442
+ * fetch the related entity, attaching it to the parent entity.
1443
+ *
1444
+ * @param entities - Entities to populate
1445
+ * @param entityType - Entity type name
1446
+ * @param include - Relation field names to populate
1447
+ */
1448
+ /**
1449
+ * Validate that relation field values match their declared cardinality.
1450
+ * Called before create/update to ensure data integrity.
1451
+ */
1452
+ validateRelationCardinality(entityType, data) {
1453
+ for (const [, registered] of this.orbitals) {
1454
+ if (registered.entity.name !== entityType) continue;
1455
+ for (const field of registered.entity.fields ?? []) {
1456
+ if (field.type !== "relation") continue;
1457
+ if (field.name === void 0) continue;
1458
+ const fieldName = field.name;
1459
+ const value = data[fieldName];
1460
+ if (value === void 0 || value === null) continue;
1461
+ const cardinality = field.relation?.cardinality || "one";
1462
+ if (cardinality === "one" || cardinality === "many-to-one") {
1463
+ if (Array.isArray(value)) {
1464
+ throw new Error(
1465
+ `Cardinality violation: ${entityType}.${fieldName} has cardinality '${cardinality}' but received an array. Expected a single string ID.`
1466
+ );
1467
+ }
1468
+ } else if (cardinality === "many" || cardinality === "many-to-many" || cardinality === "one-to-many") {
1469
+ if (typeof value === "string") {
1470
+ data[fieldName] = [value];
1471
+ } else if (Array.isArray(value)) {
1472
+ const nonStrings = value.filter((v) => typeof v !== "string");
1473
+ if (nonStrings.length > 0) {
1474
+ throw new Error(
1475
+ `Cardinality violation: ${entityType}.${fieldName} has cardinality '${cardinality}' but array contains non-string values.`
1476
+ );
1477
+ }
1478
+ }
1479
+ }
1480
+ }
1481
+ break;
1482
+ }
1483
+ }
1484
+ /**
1485
+ * Enforce onDelete rules for relation fields pointing to the entity being deleted.
1486
+ * Scans all registered entities for relation fields targeting the given entity type,
1487
+ * finds records referencing the ID being deleted, and applies cascade/nullify/restrict.
1488
+ */
1489
+ async enforceOnDeleteRules(entityType, deletedId) {
1490
+ for (const [, registered] of this.orbitals) {
1491
+ const entity = registered.entity;
1492
+ const fields = entity.fields ?? [];
1493
+ for (const field of fields) {
1494
+ if (field.type !== "relation") continue;
1495
+ if (field.relation?.entity !== entityType) continue;
1496
+ if (field.name === void 0) continue;
1497
+ const fieldName = field.name;
1498
+ const onDelete = field.relation.onDelete || "restrict";
1499
+ const referringEntityType = entity.name;
1500
+ const allRecords = await this.persistence.list(referringEntityType);
1501
+ const affectedRecords = allRecords.filter((record) => {
1502
+ const fkValue = record[fieldName];
1503
+ if (typeof fkValue === "string") return fkValue === deletedId;
1504
+ if (Array.isArray(fkValue)) return fkValue.includes(deletedId);
1505
+ return false;
1506
+ });
1507
+ if (affectedRecords.length === 0) continue;
1508
+ switch (onDelete) {
1509
+ case "restrict":
1510
+ throw new Error(
1511
+ `Cannot delete ${entityType} ${deletedId}: ${affectedRecords.length} ${referringEntityType} record(s) reference it via ${field.name}. Rule: restrict.`
1512
+ );
1513
+ case "cascade":
1514
+ for (const record of affectedRecords) {
1515
+ const recordId = record.id;
1516
+ if (recordId) {
1517
+ await this.persistence.delete(referringEntityType, recordId);
1518
+ }
1519
+ }
1520
+ if (this.config.debug) {
1521
+ persistLog.debug("cascade-delete", {
1522
+ count: affectedRecords.length,
1523
+ entityType: referringEntityType
1524
+ });
1525
+ }
1526
+ break;
1527
+ case "nullify":
1528
+ for (const record of affectedRecords) {
1529
+ const recordId = record.id;
1530
+ if (recordId && field.name !== void 0) {
1531
+ const fieldName2 = field.name;
1532
+ const update = {};
1533
+ const fkValue = record[fieldName2];
1534
+ if (Array.isArray(fkValue)) {
1535
+ update[fieldName2] = fkValue.filter((id) => id !== deletedId);
1536
+ } else {
1537
+ update[fieldName2] = null;
1538
+ }
1539
+ await this.persistence.update(referringEntityType, recordId, update);
1540
+ }
1541
+ }
1542
+ if (this.config.debug) {
1543
+ persistLog.debug("nullify", {
1544
+ field: field.name,
1545
+ count: affectedRecords.length,
1546
+ entityType: referringEntityType
1547
+ });
1548
+ }
1549
+ break;
1550
+ }
1551
+ }
1552
+ }
1553
+ }
1554
+ async populateRelations(entities, entityType, include, depth = 0, visited = /* @__PURE__ */ new Set()) {
1555
+ const maxDepth = 2;
1556
+ if (depth >= maxDepth || visited.has(entityType)) {
1557
+ if (this.config.debug) {
1558
+ persistLog.debug("populate:skip", {
1559
+ entityType,
1560
+ depth,
1561
+ visited: visited.has(entityType)
1562
+ });
1563
+ }
1564
+ return;
1565
+ }
1566
+ visited.add(entityType);
1567
+ let entityFields;
1568
+ for (const [, registered] of this.orbitals) {
1569
+ if (registered.entity.name === entityType) {
1570
+ entityFields = registered.entity.fields.filter(
1571
+ (f) => typeof f.name === "string" && f.name.length > 0
1572
+ );
1573
+ break;
1574
+ }
1575
+ }
1576
+ if (!entityFields) {
1577
+ if (this.config.debug) {
1578
+ persistLog.warn("populate:no-entity-def", { entityType });
1579
+ }
1580
+ return;
1581
+ }
1582
+ for (const includeField of include) {
1583
+ const relationField = entityFields.find((f) => {
1584
+ if (f.type !== "relation") return false;
1585
+ return f.name === includeField || f.name === `${includeField}Id` || f.name.replace(/Id$/, "") === includeField;
1586
+ });
1587
+ if (!relationField?.relation?.entity) {
1588
+ if (this.config.debug) {
1589
+ persistLog.warn("populate:no-relation-field", { includeField, entityType });
1590
+ }
1591
+ continue;
1592
+ }
1593
+ const foreignKeyField = relationField.name;
1594
+ const relatedEntityType = relationField.relation.entity;
1595
+ const cardinality = relationField.relation.cardinality || "one";
1596
+ const foreignKeyIds = /* @__PURE__ */ new Set();
1597
+ for (const entity of entities) {
1598
+ const fkValue = entity[foreignKeyField];
1599
+ if (fkValue && typeof fkValue === "string") {
1600
+ foreignKeyIds.add(fkValue);
1601
+ } else if (Array.isArray(fkValue)) {
1602
+ for (const id of fkValue) {
1603
+ if (id && typeof id === "string") {
1604
+ foreignKeyIds.add(id);
1605
+ }
1606
+ }
1607
+ }
1608
+ }
1609
+ if (foreignKeyIds.size === 0) continue;
1610
+ const relatedEntities = /* @__PURE__ */ new Map();
1611
+ for (const fkId of foreignKeyIds) {
1612
+ try {
1613
+ const related = await this.persistence.getById(relatedEntityType, fkId);
1614
+ if (related) {
1615
+ relatedEntities.set(fkId, related);
1616
+ }
1617
+ } catch (error) {
1618
+ if (this.config.debug) {
1619
+ persistLog.error("populate:fetch-related-error", {
1620
+ entityType: relatedEntityType,
1621
+ error: error instanceof Error ? error : String(error)
1622
+ });
1623
+ }
1624
+ }
1625
+ }
1626
+ const populatedFieldName = includeField.endsWith("Id") ? includeField.slice(0, -2) : includeField;
1627
+ const isSelfRef = relatedEntityType === entityType;
1628
+ const hydrateClone = (id) => {
1629
+ const related = relatedEntities.get(id);
1630
+ if (!related) return void 0;
1631
+ const copy = { ...related };
1632
+ if (isSelfRef) copy[foreignKeyField] = [];
1633
+ return copy;
1634
+ };
1635
+ for (const entity of entities) {
1636
+ const fkValue = entity[foreignKeyField];
1637
+ if (cardinality === "one" || cardinality === "many-to-one") {
1638
+ if (typeof fkValue === "string" && relatedEntities.has(fkValue)) {
1639
+ Object.defineProperty(entity, populatedFieldName, {
1640
+ value: hydrateClone(fkValue),
1641
+ writable: true,
1642
+ enumerable: true,
1643
+ configurable: true
1644
+ });
1645
+ }
1646
+ } else {
1647
+ if (Array.isArray(fkValue)) {
1648
+ const fkIds = fkValue.filter((id) => typeof id === "string");
1649
+ Object.defineProperty(entity, populatedFieldName, {
1650
+ value: fkIds.map(hydrateClone).filter(Boolean),
1651
+ writable: true,
1652
+ enumerable: true,
1653
+ configurable: true
1654
+ });
1655
+ } else if (typeof fkValue === "string" && relatedEntities.has(fkValue)) {
1656
+ Object.defineProperty(entity, populatedFieldName, {
1657
+ value: [hydrateClone(fkValue)],
1658
+ writable: true,
1659
+ enumerable: true,
1660
+ configurable: true
1661
+ });
1662
+ }
1663
+ }
1664
+ }
1665
+ if (this.config.debug) {
1666
+ persistLog.debug("populate:done", {
1667
+ field: populatedFieldName,
1668
+ count: entities.length,
1669
+ entityType
1670
+ });
1671
+ }
1672
+ }
1673
+ }
1674
+ // ==========================================================================
1675
+ // Express Router
1676
+ // ==========================================================================
1677
+ /**
1678
+ * Create Express router for orbital API endpoints
1679
+ *
1680
+ * All data access goes through trait events with guards.
1681
+ * No direct CRUD routes - use events with `fetch` effects.
1682
+ *
1683
+ * Routes:
1684
+ * - GET / - List registered orbitals
1685
+ * - GET /:orbital - Get orbital info and current states
1686
+ * - POST /:orbital/events - Send event to orbital (includes data from `fetch` effects)
1687
+ */
1688
+ router() {
1689
+ if (!isNodeEnv()) {
1690
+ throw new Error(
1691
+ "OrbitalServerRuntime.router() is Node-only (uses Express). For in-browser use, mount <BrowserPlayground> from @almadar/ui instead."
1692
+ );
1693
+ }
1694
+ const { Router } = nodeRequire("express");
1695
+ const router = Router();
1696
+ router.get("/", (_req, res) => {
1697
+ const orbitals = Array.from(this.orbitals.entries()).map(
1698
+ ([name, reg]) => ({
1699
+ name,
1700
+ entity: reg.entity?.name,
1701
+ traits: (reg.traits || []).map((t) => t.name)
1702
+ })
1703
+ );
1704
+ res.json({ success: true, orbitals });
1705
+ });
1706
+ router.get("/:orbital", (req, res) => {
1707
+ const orbitalName = req.params.orbital;
1708
+ const registered = this.orbitals.get(orbitalName);
1709
+ if (!registered) {
1710
+ res.status(404).json({ success: false, error: "Orbital not found" });
1711
+ return;
1712
+ }
1713
+ const states = {};
1714
+ for (const [name, state] of registered.manager.getAllStates()) {
1715
+ states[name] = state.currentState;
1716
+ }
1717
+ res.json({
1718
+ success: true,
1719
+ orbital: {
1720
+ name: orbitalName,
1721
+ entity: registered.entity,
1722
+ traits: registered.traits.map((t) => ({
1723
+ name: t.name,
1724
+ currentState: states[t.name],
1725
+ states: (t.stateMachine?.states || []).map((s) => s.name),
1726
+ events: [...new Set((t.stateMachine?.transitions || []).map((tr) => tr.event))]
1727
+ }))
1728
+ }
1729
+ });
1730
+ });
1731
+ router.post(
1732
+ "/:orbital/events",
1733
+ async (req, res, next) => {
1734
+ try {
1735
+ const orbitalName = req.params.orbital;
1736
+ const firebaseUser = req.firebaseUser;
1737
+ const user = firebaseUser ? {
1738
+ ...firebaseUser,
1739
+ displayName: firebaseUser.name ?? firebaseUser.displayName
1740
+ } : void 0;
1741
+ const result = await this.processOrbitalEvent(orbitalName, {
1742
+ ...req.body,
1743
+ user
1744
+ });
1745
+ res.json(result);
1746
+ } catch (error) {
1747
+ next(error);
1748
+ }
1749
+ }
1750
+ );
1751
+ return router;
1752
+ }
1753
+ // ==========================================================================
1754
+ // Direct API (for programmatic use)
1755
+ // ==========================================================================
1756
+ /**
1757
+ * Get the event bus for manual event emission
1758
+ */
1759
+ getEventBus() {
1760
+ return this.eventBus;
1761
+ }
1762
+ /**
1763
+ * Get state for a specific orbital/trait
1764
+ */
1765
+ getState(orbitalName, traitName) {
1766
+ const registered = this.orbitals.get(orbitalName);
1767
+ if (!registered) return void 0;
1768
+ if (traitName) {
1769
+ return registered.manager.getState(traitName);
1770
+ }
1771
+ const states = {};
1772
+ for (const [name, state] of registered.manager.getAllStates()) {
1773
+ states[name] = state;
1774
+ }
1775
+ return states;
1776
+ }
1777
+ /**
1778
+ * List registered orbitals
1779
+ */
1780
+ listOrbitals() {
1781
+ return Array.from(this.orbitals.keys());
1782
+ }
1783
+ /**
1784
+ * Check if an orbital is registered
1785
+ */
1786
+ hasOrbital(name) {
1787
+ return this.orbitals.has(name);
1788
+ }
1789
+ /**
1790
+ * Get information about active ticks
1791
+ */
1792
+ getActiveTicks() {
1793
+ return this.tickBindings.map((binding) => ({
1794
+ orbital: binding.orbitalName,
1795
+ trait: binding.traitName,
1796
+ tick: binding.tick.name,
1797
+ interval: binding.tick.interval,
1798
+ hasGuard: !!binding.tick.guard
1799
+ }));
1800
+ }
1801
+ };
1802
+ function createOrbitalServerRuntime(config) {
1803
+ return new OrbitalServerRuntime(config);
1804
+ }
1805
+ function parseListenSource(listener, listenerOrbital) {
1806
+ const explicit = listener.source;
1807
+ if (explicit && typeof explicit === "object") {
1808
+ return {
1809
+ bareEvent: listener.event,
1810
+ matcher: buildMatcher(explicit, listenerOrbital)
1811
+ };
1812
+ }
1813
+ const key = listener.event;
1814
+ const parts = key.split(".");
1815
+ if (parts.length === 1) {
1816
+ return { bareEvent: key, matcher: () => true };
1817
+ }
1818
+ if (parts.length === 2) {
1819
+ const [sourceOrStar, eventName] = parts;
1820
+ if (sourceOrStar === "*") {
1821
+ return { bareEvent: eventName, matcher: () => true };
1822
+ }
1823
+ return {
1824
+ bareEvent: eventName,
1825
+ matcher: buildMatcher(
1826
+ { kind: "trait", trait: sourceOrStar },
1827
+ listenerOrbital
1828
+ )
1829
+ };
1830
+ }
1831
+ if (parts.length >= 3) {
1832
+ const eventName = parts[parts.length - 1];
1833
+ const trait = parts[parts.length - 2];
1834
+ const orbital = parts.slice(0, parts.length - 2).join(".");
1835
+ return {
1836
+ bareEvent: eventName,
1837
+ matcher: buildMatcher({ kind: "orbital", orbital, trait }, listenerOrbital)
1838
+ };
1839
+ }
1840
+ return { bareEvent: key, matcher: () => true };
1841
+ }
1842
+ function buildMatcher(src, listenerOrbital) {
1843
+ if (src.kind === "any") return () => true;
1844
+ if (src.kind === "trait") {
1845
+ const wantedTrait2 = src.trait;
1846
+ return (source) => !!source && source.orbital === listenerOrbital && source.trait === wantedTrait2;
1847
+ }
1848
+ const wantedOrbital = src.orbital;
1849
+ const wantedTrait = src.trait;
1850
+ return (source) => !!source && source.orbital === wantedOrbital && source.trait === wantedTrait;
1851
+ }
1852
+
1853
+ export { OrbitalServerRuntime, createOrbitalServerRuntime };
3
1854
  //# sourceMappingURL=OrbitalServerRuntime.js.map
4
1855
  //# sourceMappingURL=OrbitalServerRuntime.js.map