@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.
- package/dist/{OrbitalServerRuntime-CNyOL8XD.d.ts → OrbitalServerRuntime-YLqyxdjz.d.ts} +18 -1
- package/dist/OrbitalServerRuntime.d.ts +2 -2
- package/dist/OrbitalServerRuntime.js +388 -5
- package/dist/OrbitalServerRuntime.js.map +1 -1
- package/dist/ServerBridge.d.ts +1 -1
- package/dist/{chunk-UBXKXCSM.js → chunk-54P2HYHQ.js} +70 -3
- package/dist/chunk-54P2HYHQ.js.map +1 -0
- package/dist/index.d.ts +27 -4
- package/dist/index.js +1 -1
- package/dist/{types-9hbY6RWC.d.ts → types-E5o2Jqe6.d.ts} +16 -0
- package/package.json +6 -1
- package/dist/chunk-UBXKXCSM.js.map +0 -1
|
@@ -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-
|
|
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-
|
|
3
|
-
import './types-
|
|
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-
|
|
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
|
-
|
|
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 (
|
|
1165
|
-
if (
|
|
1543
|
+
if (cardinality === "one" || cardinality === "many-to-one") {
|
|
1544
|
+
if (typeof fkValue === "string" && relatedEntities.has(fkValue)) {
|
|
1166
1545
|
entity[populatedFieldName] = relatedEntities.get(fkValue);
|
|
1167
|
-
}
|
|
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
|
}
|