@almadar/runtime 5.8.2 → 5.8.3

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