fix(rete): inject clock into EventLog; use tsc for DTS; fix cycle.test.ts private access; add Playwright worker limit

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 18:25:49 -06:00
commit ca6a3df500
No known key found for this signature in database
5 changed files with 71 additions and 63 deletions

View file

@ -13,7 +13,7 @@
} }
}, },
"scripts": { "scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean", "build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json --emitDeclarationOnly --noEmitOnError",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {

View file

@ -33,87 +33,77 @@ describe("RecursionLimitExceededError", () => {
}); });
describe("Session.fireRules() recursion limit", () => { describe("Session.fireRules() recursion limit", () => {
it("throws RecursionLimitExceededError when recursion depth exceeds limit", () => { it("throws RecursionLimitExceededError when recursion depth exceeds limit", () => {
const session = new Session({ autoFire: false, recursionLimit: 3 }); const session = new Session({ autoFire: false, recursionLimit: 3 });
expect(() => { expect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 4;
(session as any)._fireDepth = 4;
session.fireRules({ recursionLimit: 3 }); session.fireRules({ recursionLimit: 3 });
}).toThrow(RecursionLimitExceededError); }).toThrow(RecursionLimitExceededError);
}); });
it("throws when depth equals limit (>=)", () => { it("throws when depth equals limit (>=)", () => {
const session = new Session({ autoFire: false, recursionLimit: 3 }); const session = new Session({ autoFire: false, recursionLimit: 3 });
expect(() => { expect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 3;
(session as any)._fireDepth = 3;
session.fireRules({ recursionLimit: 3 }); session.fireRules({ recursionLimit: 3 });
}).toThrow(RecursionLimitExceededError); }).toThrow(RecursionLimitExceededError);
}); });
it("does not throw when depth is below limit", () => { it("does not throw when depth is below limit", () => {
const session = new Session({ autoFire: false, recursionLimit: 64 }); const session = new Session({ autoFire: false, recursionLimit: 64 });
expect(() => { expect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 0;
(session as any)._fireDepth = 0;
session.fireRules({ recursionLimit: 64 }); session.fireRules({ recursionLimit: 64 });
}).not.toThrow(); }).not.toThrow();
}); });
it("uses session default recursionLimit when opts omitted", () => { it("uses session default recursionLimit when opts omitted", () => {
const session = new Session({ autoFire: false, recursionLimit: 3 }); const session = new Session({ autoFire: false, recursionLimit: 3 });
expect(() => { expect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 5;
(session as any)._fireDepth = 5;
session.fireRules(); session.fireRules();
}).toThrow(RecursionLimitExceededError); }).toThrow(RecursionLimitExceededError);
}); });
it("resets recursion depth to 0 between independent fireRules() calls", () => { it("resets recursion depth to 0 between independent fireRules() calls", () => {
const session = new Session({ autoFire: false, recursionLimit: 3 }); const session = new Session({ autoFire: false, recursionLimit: 3 });
expect(() => session.fireRules()).not.toThrow(); expect(() => session.fireRules()).not.toThrow();
expect(() => session.fireRules()).not.toThrow(); expect(() => session.fireRules()).not.toThrow();
expect(() => session.fireRules()).not.toThrow(); expect(() => session.fireRules()).not.toThrow();
// eslint-disable-next-line @typescript-eslint/no-explicit-any expect(session._fireDepth).toBe(0);
expect((session as any)._fireDepth).toBe(0);
}); });
it("decrements depth even if fireRules body were to throw (finally)", () => { it("decrements depth even if fireRules body were to throw (finally)", () => {
const session = new Session({ autoFire: false, recursionLimit: 3 }); const session = new Session({ autoFire: false, recursionLimit: 3 });
// Force recursion guard to throw; internal counter must not be left elevated // Force recursion guard to throw; internal counter must not be left elevated
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 5;
(session as any)._fireDepth = 5; expect(() => session.fireRules({ recursionLimit: 3 })).toThrow(
expect(() => session.fireRules({ recursionLimit: 3 })).toThrow( RecursionLimitExceededError,
RecursionLimitExceededError, );
); // After throw, depth should still be 5 (guard throws before increment)
// After throw, depth should still be 5 (guard throws before increment) expect(session._fireDepth).toBe(5);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((session as any)._fireDepth).toBe(5);
}); });
it("recursionLimit: 0 means no limit — does not throw", () => { it("recursionLimit: 0 means no limit — does not throw", () => {
const session = new Session({ autoFire: false, recursionLimit: 0 }); const session = new Session({ autoFire: false, recursionLimit: 0 });
expect(() => { expect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 1000;
(session as any)._fireDepth = 1000;
session.fireRules({ recursionLimit: 0 }); session.fireRules({ recursionLimit: 0 });
}).not.toThrow(); }).not.toThrow();
}); });
it("recursionLimit: 0 at session level also means no limit", () => { it("recursionLimit: 0 at session level also means no limit", () => {
const session = new Session({ autoFire: false, recursionLimit: 0 }); const session = new Session({ autoFire: false, recursionLimit: 0 });
expect(() => { expect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 1000;
(session as any)._fireDepth = 1000;
session.fireRules(); session.fireRules();
}).not.toThrow(); }).not.toThrow();
}); });
it("thrown error carries the actual depth value", () => { it("thrown error carries the actual depth value", () => {
const session = new Session({ autoFire: false, recursionLimit: 3 }); const session = new Session({ autoFire: false, recursionLimit: 3 });
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 7;
(session as any)._fireDepth = 7;
session.fireRules({ recursionLimit: 3 }); session.fireRules({ recursionLimit: 3 });
expect.fail("should have thrown"); expect.fail("should have thrown");
} catch (err) { } catch (err) {
@ -122,12 +112,11 @@ describe("Session.fireRules() recursion limit", () => {
} }
}); });
it("opts.recursionLimit overrides the session default", () => { it("opts.recursionLimit overrides the session default", () => {
const session = new Session({ autoFire: false, recursionLimit: 64 }); const session = new Session({ autoFire: false, recursionLimit: 64 });
// Session default is 64 — would not throw. But opts override to 2. // Session default is 64 — would not throw. But opts override to 2.
expect(() => { expect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any session._fireDepth = 2;
(session as any)._fireDepth = 2;
session.fireRules({ recursionLimit: 2 }); session.fireRules({ recursionLimit: 2 });
}).toThrow(RecursionLimitExceededError); }).toThrow(RecursionLimitExceededError);
}); });

View file

@ -48,6 +48,16 @@ export interface LogEvent {
export class EventLog { export class EventLog {
readonly #events: LogEvent[] = []; readonly #events: LogEvent[] = [];
#seq = 0; #seq = 0;
readonly #clock: () => number;
/**
* @param clock — Optional wall-clock function. Defaults to `Date.now`.
* Inject a deterministic clock (e.g. `() => 0`) in tests and replay
* contexts where timestamp values must be stable across runs.
*/
constructor(clock: () => number = Date.now) {
this.#clock = clock;
}
/** Number of events logged. */ /** Number of events logged. */
get length(): number { get length(): number {
@ -67,7 +77,7 @@ export class EventLog {
appendInsert(id: EntityId, attr: AttrKey, value: FactValue): void { appendInsert(id: EntityId, attr: AttrKey, value: FactValue): void {
this.#events.push({ this.#events.push({
seq: ++this.#seq, seq: ++this.#seq,
ts: Date.now(), ts: this.#clock(),
kind: "insert", kind: "insert",
id, id,
attr, attr,
@ -82,7 +92,7 @@ export class EventLog {
appendRetract(id: EntityId, attr: AttrKey, value: FactValue): void { appendRetract(id: EntityId, attr: AttrKey, value: FactValue): void {
this.#events.push({ this.#events.push({
seq: ++this.#seq, seq: ++this.#seq,
ts: Date.now(), ts: this.#clock(),
kind: "retract", kind: "retract",
id, id,
attr, attr,
@ -94,7 +104,7 @@ export class EventLog {
appendFire(ruleName: string): void { appendFire(ruleName: string): void {
this.#events.push({ this.#events.push({
seq: ++this.#seq, seq: ++this.#seq,
ts: Date.now(), ts: this.#clock(),
kind: "fire", kind: "fire",
ruleName, ruleName,
}); });

View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"incremental": false,
"declarationMap": false
}
}

View file

@ -15,4 +15,5 @@ export default defineConfig({
timeout: 30000, timeout: 30000,
}, },
reporter: [["html", { open: "never" }], ["list"]], reporter: [["html", { open: "never" }], ["list"]],
workers: 1, // prevent parallel workers from sharing the single dev server
}); });