@almadar/runtime 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { Router } from 'express';
2
- import { I as IEventBus, i as RuntimeEvent, h as EventListener, U as Unsubscribe, E as EffectHandlers, g as Effect, T as TraitState } from './types-9hbY6RWC.js';
2
+ import { I as IEventBus, i as RuntimeEvent, h as EventListener, U as Unsubscribe, E as EffectHandlers, g as Effect, T as TraitState } from './types-E5o2Jqe6.js';
3
3
  import { OrbitalSchema, Orbital, Trait } from '@almadar/core';
4
4
 
5
5
  /**
@@ -429,6 +429,11 @@ interface RuntimeOrbital {
429
429
  fields?: Array<{
430
430
  name: string;
431
431
  type: string;
432
+ relation?: {
433
+ entity?: string;
434
+ cardinality?: string;
435
+ onDelete?: string;
436
+ };
432
437
  }>;
433
438
  /** Pre-defined entity instances to seed on registration */
434
439
  instances?: Array<Record<string, unknown>>;
@@ -597,6 +602,7 @@ declare class OrbitalServerRuntime {
597
602
  private preprocessedCache;
598
603
  private entitySharingMap;
599
604
  private eventNamespaceMap;
605
+ private osHandlers;
600
606
  constructor(config?: OrbitalServerRuntimeConfig);
601
607
  /**
602
608
  * Register an OrbitalSchema for execution.
@@ -720,6 +726,17 @@ declare class OrbitalServerRuntime {
720
726
  * @param entityType - Entity type name
721
727
  * @param include - Relation field names to populate
722
728
  */
729
+ /**
730
+ * Validate that relation field values match their declared cardinality.
731
+ * Called before create/update to ensure data integrity.
732
+ */
733
+ private validateRelationCardinality;
734
+ /**
735
+ * Enforce onDelete rules for relation fields pointing to the entity being deleted.
736
+ * Scans all registered entities for relation fields targeting the given entity type,
737
+ * finds records referencing the ID being deleted, and applies cascade/nullify/restrict.
738
+ */
739
+ private enforceOnDeleteRules;
723
740
  private populateRelations;
724
741
  /**
725
742
  * Create Express router for orbital API endpoints
@@ -1,4 +1,4 @@
1
1
  import 'express';
2
- export { n as EffectResult, L as LoaderConfig, O as OrbitalEventRequest, c as OrbitalEventResponse, o as OrbitalServerRuntime, d as OrbitalServerRuntimeConfig, P as PersistenceAdapter, R as RuntimeOrbital, h as RuntimeOrbitalSchema, i as RuntimeTrait, q as RuntimeTraitTick, r as createOrbitalServerRuntime } from './OrbitalServerRuntime-CNyOL8XD.js';
3
- import './types-9hbY6RWC.js';
2
+ export { n as EffectResult, L as LoaderConfig, O as OrbitalEventRequest, c as OrbitalEventResponse, o as OrbitalServerRuntime, d as OrbitalServerRuntimeConfig, P as PersistenceAdapter, R as RuntimeOrbital, h as RuntimeOrbitalSchema, i as RuntimeTrait, q as RuntimeTraitTick, r as createOrbitalServerRuntime } from './OrbitalServerRuntime-YLqyxdjz.js';
3
+ import './types-E5o2Jqe6.js';
4
4
  import '@almadar/core';
@@ -1,7 +1,10 @@
1
- import { EventBus, createUnifiedLoader, preprocessSchema, StateMachineManager, createContextFromBindings, EffectExecutor } from './chunk-UBXKXCSM.js';
1
+ import { EventBus, createUnifiedLoader, preprocessSchema, StateMachineManager, createContextFromBindings, EffectExecutor } from './chunk-54P2HYHQ.js';
2
2
  import { Router } from 'express';
3
3
  import { evaluateGuard } from '@almadar/evaluator';
4
4
  import { faker } from '@faker-js/faker';
5
+ import * as fs from 'fs';
6
+ import * as net from 'net';
7
+ import { execSync } from 'child_process';
5
8
 
6
9
  var MockPersistenceAdapter = class {
7
10
  stores = /* @__PURE__ */ new Map();
@@ -263,6 +266,258 @@ var MockPersistenceAdapter = class {
263
266
  return store.size;
264
267
  }
265
268
  };
269
+ function globToRegex(glob) {
270
+ let regex = "";
271
+ let i = 0;
272
+ while (i < glob.length) {
273
+ const c = glob[i];
274
+ if (c === "*") {
275
+ if (glob[i + 1] === "*") {
276
+ regex += ".*";
277
+ i += 2;
278
+ if (glob[i] === "/") i++;
279
+ continue;
280
+ }
281
+ regex += "[^/]*";
282
+ } else if (c === "?") {
283
+ regex += "[^/]";
284
+ } else if (c === ".") {
285
+ regex += "\\.";
286
+ } else if (c === "/" || c === "-" || c === "_") {
287
+ regex += c;
288
+ } else if (/[{}()[\]^$+|\\]/.test(c)) {
289
+ regex += "\\" + c;
290
+ } else {
291
+ regex += c;
292
+ }
293
+ i++;
294
+ }
295
+ return new RegExp("^" + regex + "$");
296
+ }
297
+ function parseCronField(field, min, max) {
298
+ const values = /* @__PURE__ */ new Set();
299
+ for (const part of field.split(",")) {
300
+ if (part === "*") {
301
+ for (let i = min; i <= max; i++) values.add(i);
302
+ } else if (part.includes("/")) {
303
+ const [range, stepStr] = part.split("/");
304
+ const step = parseInt(stepStr, 10);
305
+ const start = range === "*" ? min : parseInt(range, 10);
306
+ for (let i = start; i <= max; i += step) values.add(i);
307
+ } else if (part.includes("-")) {
308
+ const [lo, hi] = part.split("-").map(Number);
309
+ for (let i = lo; i <= hi; i++) values.add(i);
310
+ } else {
311
+ values.add(parseInt(part, 10));
312
+ }
313
+ }
314
+ return values;
315
+ }
316
+ function parseCron(expression) {
317
+ const parts = expression.trim().split(/\s+/);
318
+ if (parts.length !== 5) {
319
+ throw new Error(`Invalid cron expression (expected 5 fields): ${expression}`);
320
+ }
321
+ return {
322
+ minute: parseCronField(parts[0], 0, 59),
323
+ hour: parseCronField(parts[1], 0, 23),
324
+ day: parseCronField(parts[2], 1, 31),
325
+ month: parseCronField(parts[3], 1, 12),
326
+ weekday: parseCronField(parts[4], 0, 6)
327
+ };
328
+ }
329
+ function cronMatches(fields, date) {
330
+ return fields.minute.has(date.getMinutes()) && fields.hour.has(date.getHours()) && fields.day.has(date.getDate()) && fields.month.has(date.getMonth() + 1) && fields.weekday.has(date.getDay());
331
+ }
332
+ function createOsHandlers(ctx) {
333
+ const cwd = ctx.cwd ?? process.cwd();
334
+ const watchers = [];
335
+ const intervals = [];
336
+ const signalHandlers = [];
337
+ let httpWatchActive = false;
338
+ const debounceConfig = /* @__PURE__ */ new Map();
339
+ const debounceTimers = /* @__PURE__ */ new Map();
340
+ function debouncedEmit(eventType, payload) {
341
+ const ms = debounceConfig.get(eventType);
342
+ if (ms !== void 0 && ms > 0) {
343
+ const existing = debounceTimers.get(eventType);
344
+ if (existing) clearTimeout(existing);
345
+ debounceTimers.set(
346
+ eventType,
347
+ setTimeout(() => {
348
+ debounceTimers.delete(eventType);
349
+ ctx.emitEvent(eventType, payload);
350
+ }, ms)
351
+ );
352
+ } else {
353
+ ctx.emitEvent(eventType, payload);
354
+ }
355
+ }
356
+ const handlers = {
357
+ osWatchFiles: (glob, options) => {
358
+ const recursive = options.recursive !== false;
359
+ const pattern = globToRegex(glob);
360
+ try {
361
+ const watcher = fs.watch(cwd, { recursive }, (_event, filename) => {
362
+ if (filename && pattern.test(filename)) {
363
+ debouncedEmit("OS_FILE_MODIFIED", {
364
+ file: filename,
365
+ glob,
366
+ cwd
367
+ });
368
+ }
369
+ });
370
+ watchers.push(watcher);
371
+ } catch (err) {
372
+ console.warn("[os/watch-files] Failed to start watcher:", err);
373
+ }
374
+ },
375
+ osWatchProcess: (name, subcommand) => {
376
+ const searchTerm = subcommand ? `${name} ${subcommand}` : name;
377
+ let wasRunning = false;
378
+ const interval = setInterval(() => {
379
+ let isRunning = false;
380
+ try {
381
+ const result = execSync(`pgrep -f "${searchTerm}" 2>/dev/null`, {
382
+ encoding: "utf-8",
383
+ stdio: ["pipe", "pipe", "pipe"]
384
+ });
385
+ isRunning = result.trim().length > 0;
386
+ } catch {
387
+ isRunning = false;
388
+ }
389
+ if (isRunning && !wasRunning) {
390
+ debouncedEmit("OS_PROCESS_STARTED", { process: name, subcommand: subcommand ?? null });
391
+ } else if (!isRunning && wasRunning) {
392
+ debouncedEmit("OS_PROCESS_EXITED", { process: name, subcommand: subcommand ?? null });
393
+ }
394
+ wasRunning = isRunning;
395
+ }, 2e3);
396
+ intervals.push(interval);
397
+ },
398
+ osWatchPort: (port, protocol) => {
399
+ if (protocol !== "tcp") {
400
+ console.warn(`[os/watch-port] Only TCP is supported, got: ${protocol}`);
401
+ return;
402
+ }
403
+ let wasOpen = false;
404
+ const interval = setInterval(() => {
405
+ const socket = new net.Socket();
406
+ socket.setTimeout(1e3);
407
+ socket.on("connect", () => {
408
+ socket.destroy();
409
+ if (!wasOpen) {
410
+ wasOpen = true;
411
+ debouncedEmit("OS_PORT_OPENED", { port, protocol });
412
+ }
413
+ });
414
+ socket.on("error", () => {
415
+ socket.destroy();
416
+ if (wasOpen) {
417
+ wasOpen = false;
418
+ debouncedEmit("OS_PORT_CLOSED", { port, protocol });
419
+ }
420
+ });
421
+ socket.on("timeout", () => {
422
+ socket.destroy();
423
+ if (wasOpen) {
424
+ wasOpen = false;
425
+ debouncedEmit("OS_PORT_CLOSED", { port, protocol });
426
+ }
427
+ });
428
+ socket.connect(port, "127.0.0.1");
429
+ }, 3e3);
430
+ intervals.push(interval);
431
+ },
432
+ osWatchHttp: (urlPattern, method) => {
433
+ if (!httpWatchActive) {
434
+ httpWatchActive = true;
435
+ console.warn(
436
+ `[os/watch-http] HTTP interception is only supported in compiled mode. Pattern: ${urlPattern}${method ? `, method: ${method}` : ""}`
437
+ );
438
+ }
439
+ },
440
+ osWatchCron: (expression) => {
441
+ let fields;
442
+ try {
443
+ fields = parseCron(expression);
444
+ } catch (err) {
445
+ console.warn("[os/watch-cron] Invalid expression:", err);
446
+ return;
447
+ }
448
+ let lastFired = -1;
449
+ const interval = setInterval(() => {
450
+ const now = /* @__PURE__ */ new Date();
451
+ const minuteKey = now.getFullYear() * 1e8 + now.getMonth() * 1e6 + now.getDate() * 1e4 + now.getHours() * 100 + now.getMinutes();
452
+ if (minuteKey !== lastFired && cronMatches(fields, now)) {
453
+ lastFired = minuteKey;
454
+ debouncedEmit("OS_CRON_FIRE", {
455
+ expression,
456
+ firedAt: now.toISOString()
457
+ });
458
+ }
459
+ }, 1e3);
460
+ intervals.push(interval);
461
+ },
462
+ osWatchSignal: (signal) => {
463
+ const sig = signal.toUpperCase();
464
+ const handler = () => {
465
+ debouncedEmit(`OS_SIGNAL_${sig}`, { signal: sig });
466
+ };
467
+ try {
468
+ process.on(sig, handler);
469
+ signalHandlers.push({ signal: sig, handler });
470
+ } catch (err) {
471
+ console.warn(`[os/watch-signal] Cannot listen for ${sig}:`, err);
472
+ }
473
+ },
474
+ osWatchEnv: (variable) => {
475
+ let lastValue = process.env[variable];
476
+ const interval = setInterval(() => {
477
+ const current = process.env[variable];
478
+ if (current !== lastValue) {
479
+ const previous = lastValue;
480
+ lastValue = current;
481
+ debouncedEmit("OS_ENV_CHANGED", {
482
+ variable,
483
+ value: current ?? null,
484
+ previous: previous ?? null
485
+ });
486
+ }
487
+ }, 1e3);
488
+ intervals.push(interval);
489
+ },
490
+ osDebounce: (ms, eventType) => {
491
+ debounceConfig.set(eventType, ms);
492
+ }
493
+ };
494
+ function cleanup() {
495
+ for (const w of watchers) {
496
+ try {
497
+ w.close();
498
+ } catch {
499
+ }
500
+ }
501
+ watchers.length = 0;
502
+ for (const i of intervals) {
503
+ clearInterval(i);
504
+ }
505
+ intervals.length = 0;
506
+ for (const { signal, handler } of signalHandlers) {
507
+ try {
508
+ process.removeListener(signal, handler);
509
+ } catch {
510
+ }
511
+ }
512
+ signalHandlers.length = 0;
513
+ httpWatchActive = false;
514
+ for (const timer of debounceTimers.values()) {
515
+ clearTimeout(timer);
516
+ }
517
+ debounceTimers.clear();
518
+ }
519
+ return { handlers, cleanup };
520
+ }
266
521
 
267
522
  // src/OrbitalServerRuntime.ts
268
523
  var InMemoryPersistence = class {
@@ -305,6 +560,7 @@ var OrbitalServerRuntime = class {
305
560
  preprocessedCache = /* @__PURE__ */ new Map();
306
561
  entitySharingMap = {};
307
562
  eventNamespaceMap = {};
563
+ osHandlers = null;
308
564
  constructor(config = {}) {
309
565
  this.config = {
310
566
  mode: "mock",
@@ -333,6 +589,13 @@ var OrbitalServerRuntime = class {
333
589
  } else {
334
590
  this.persistence = config.persistence || new InMemoryPersistence();
335
591
  }
592
+ this.osHandlers = createOsHandlers({
593
+ emitEvent: (type, payload) => this.eventBus.emit(type, payload)
594
+ });
595
+ this.config.effectHandlers = {
596
+ ...this.osHandlers.handlers,
597
+ ...this.config.effectHandlers
598
+ };
336
599
  }
337
600
  // ==========================================================================
338
601
  // Schema Registration
@@ -760,6 +1023,10 @@ var OrbitalServerRuntime = class {
760
1023
  this.listenerCleanups = [];
761
1024
  this.orbitals.clear();
762
1025
  this.eventBus.clear();
1026
+ if (this.osHandlers) {
1027
+ this.osHandlers.cleanup();
1028
+ this.osHandlers = null;
1029
+ }
763
1030
  }
764
1031
  // ==========================================================================
765
1032
  // Event Processing
@@ -952,6 +1219,9 @@ var OrbitalServerRuntime = class {
952
1219
  const type = targetEntityType || entityType;
953
1220
  let resultData;
954
1221
  try {
1222
+ if (action === "create" || action === "update") {
1223
+ this.validateRelationCardinality(type, data || {});
1224
+ }
955
1225
  switch (action) {
956
1226
  case "create": {
957
1227
  const { id } = await this.persistence.create(type, data || {});
@@ -969,6 +1239,7 @@ var OrbitalServerRuntime = class {
969
1239
  case "delete":
970
1240
  if (data?.id || entityId) {
971
1241
  const deleteId = data?.id || entityId;
1242
+ await this.enforceOnDeleteRules(type, deleteId);
972
1243
  await this.persistence.delete(type, deleteId);
973
1244
  resultData = { id: deleteId, deleted: true };
974
1245
  }
@@ -1109,7 +1380,109 @@ var OrbitalServerRuntime = class {
1109
1380
  * @param entityType - Entity type name
1110
1381
  * @param include - Relation field names to populate
1111
1382
  */
1112
- async populateRelations(entities, entityType, include) {
1383
+ /**
1384
+ * Validate that relation field values match their declared cardinality.
1385
+ * Called before create/update to ensure data integrity.
1386
+ */
1387
+ validateRelationCardinality(entityType, data) {
1388
+ for (const [, registered] of this.orbitals) {
1389
+ if (registered.schema.entity.name !== entityType) continue;
1390
+ for (const field of registered.schema.entity.fields ?? []) {
1391
+ if (field.type !== "relation") continue;
1392
+ const value = data[field.name];
1393
+ if (value === void 0 || value === null) continue;
1394
+ const cardinality = field.relation?.cardinality || "one";
1395
+ if (cardinality === "one" || cardinality === "many-to-one") {
1396
+ if (Array.isArray(value)) {
1397
+ throw new Error(
1398
+ `Cardinality violation: ${entityType}.${field.name} has cardinality '${cardinality}' but received an array. Expected a single string ID.`
1399
+ );
1400
+ }
1401
+ } else if (cardinality === "many" || cardinality === "many-to-many" || cardinality === "one-to-many") {
1402
+ if (typeof value === "string") {
1403
+ data[field.name] = [value];
1404
+ } else if (Array.isArray(value)) {
1405
+ const nonStrings = value.filter((v) => typeof v !== "string");
1406
+ if (nonStrings.length > 0) {
1407
+ throw new Error(
1408
+ `Cardinality violation: ${entityType}.${field.name} has cardinality '${cardinality}' but array contains non-string values.`
1409
+ );
1410
+ }
1411
+ }
1412
+ }
1413
+ }
1414
+ break;
1415
+ }
1416
+ }
1417
+ /**
1418
+ * Enforce onDelete rules for relation fields pointing to the entity being deleted.
1419
+ * Scans all registered entities for relation fields targeting the given entity type,
1420
+ * finds records referencing the ID being deleted, and applies cascade/nullify/restrict.
1421
+ */
1422
+ async enforceOnDeleteRules(entityType, deletedId) {
1423
+ for (const [, registered] of this.orbitals) {
1424
+ const schema = registered.schema;
1425
+ const fields = schema.entity.fields ?? [];
1426
+ for (const field of fields) {
1427
+ if (field.type !== "relation") continue;
1428
+ if (field.relation?.entity !== entityType) continue;
1429
+ const onDelete = field.relation.onDelete || "restrict";
1430
+ const referringEntityType = schema.entity.name;
1431
+ const allRecords = await this.persistence.list(referringEntityType);
1432
+ const affectedRecords = allRecords.filter((record) => {
1433
+ const fkValue = record[field.name];
1434
+ if (typeof fkValue === "string") return fkValue === deletedId;
1435
+ if (Array.isArray(fkValue)) return fkValue.includes(deletedId);
1436
+ return false;
1437
+ });
1438
+ if (affectedRecords.length === 0) continue;
1439
+ switch (onDelete) {
1440
+ case "restrict":
1441
+ throw new Error(
1442
+ `Cannot delete ${entityType} ${deletedId}: ${affectedRecords.length} ${referringEntityType} record(s) reference it via ${field.name}. Rule: restrict.`
1443
+ );
1444
+ case "cascade":
1445
+ for (const record of affectedRecords) {
1446
+ const recordId = record.id;
1447
+ if (recordId) {
1448
+ await this.persistence.delete(referringEntityType, recordId);
1449
+ }
1450
+ }
1451
+ if (this.config.debug) {
1452
+ console.log(`[OrbitalRuntime] Cascade deleted ${affectedRecords.length} ${referringEntityType} records`);
1453
+ }
1454
+ break;
1455
+ case "nullify":
1456
+ for (const record of affectedRecords) {
1457
+ const recordId = record.id;
1458
+ if (recordId) {
1459
+ const update = {};
1460
+ const fkValue = record[field.name];
1461
+ if (Array.isArray(fkValue)) {
1462
+ update[field.name] = fkValue.filter((id) => id !== deletedId);
1463
+ } else {
1464
+ update[field.name] = null;
1465
+ }
1466
+ await this.persistence.update(referringEntityType, recordId, update);
1467
+ }
1468
+ }
1469
+ if (this.config.debug) {
1470
+ console.log(`[OrbitalRuntime] Nullified ${field.name} on ${affectedRecords.length} ${referringEntityType} records`);
1471
+ }
1472
+ break;
1473
+ }
1474
+ }
1475
+ }
1476
+ }
1477
+ async populateRelations(entities, entityType, include, depth = 0, visited = /* @__PURE__ */ new Set()) {
1478
+ const maxDepth = 2;
1479
+ if (depth >= maxDepth || visited.has(entityType)) {
1480
+ if (this.config.debug) {
1481
+ console.log(`[OrbitalRuntime] Skipping populateRelations for ${entityType}: depth=${depth}, visited=${visited.has(entityType)}`);
1482
+ }
1483
+ return;
1484
+ }
1485
+ visited.add(entityType);
1113
1486
  let entityFields;
1114
1487
  for (const [, registered] of this.orbitals) {
1115
1488
  if (registered.schema.entity.name === entityType) {
@@ -1142,6 +1515,12 @@ var OrbitalServerRuntime = class {
1142
1515
  const fkValue = entity[foreignKeyField];
1143
1516
  if (fkValue && typeof fkValue === "string") {
1144
1517
  foreignKeyIds.add(fkValue);
1518
+ } else if (Array.isArray(fkValue)) {
1519
+ for (const id of fkValue) {
1520
+ if (id && typeof id === "string") {
1521
+ foreignKeyIds.add(id);
1522
+ }
1523
+ }
1145
1524
  }
1146
1525
  }
1147
1526
  if (foreignKeyIds.size === 0) continue;
@@ -1161,10 +1540,14 @@ var OrbitalServerRuntime = class {
1161
1540
  const populatedFieldName = includeField.endsWith("Id") ? includeField.slice(0, -2) : includeField;
1162
1541
  for (const entity of entities) {
1163
1542
  const fkValue = entity[foreignKeyField];
1164
- if (fkValue && relatedEntities.has(fkValue)) {
1165
- if (cardinality === "one") {
1543
+ if (cardinality === "one" || cardinality === "many-to-one") {
1544
+ if (typeof fkValue === "string" && relatedEntities.has(fkValue)) {
1166
1545
  entity[populatedFieldName] = relatedEntities.get(fkValue);
1167
- } else {
1546
+ }
1547
+ } else {
1548
+ if (Array.isArray(fkValue)) {
1549
+ entity[populatedFieldName] = fkValue.filter((id) => typeof id === "string").map((id) => relatedEntities.get(id)).filter(Boolean);
1550
+ } else if (typeof fkValue === "string" && relatedEntities.has(fkValue)) {
1168
1551
  entity[populatedFieldName] = [relatedEntities.get(fkValue)];
1169
1552
  }
1170
1553
  }