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": {
"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"
},
"dependencies": {

View file

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

View file

@ -48,6 +48,16 @@ export interface LogEvent {
export class EventLog {
readonly #events: LogEvent[] = [];
#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. */
get length(): number {
@ -67,7 +77,7 @@ export class EventLog {
appendInsert(id: EntityId, attr: AttrKey, value: FactValue): void {
this.#events.push({
seq: ++this.#seq,
ts: Date.now(),
ts: this.#clock(),
kind: "insert",
id,
attr,
@ -82,7 +92,7 @@ export class EventLog {
appendRetract(id: EntityId, attr: AttrKey, value: FactValue): void {
this.#events.push({
seq: ++this.#seq,
ts: Date.now(),
ts: this.#clock(),
kind: "retract",
id,
attr,
@ -94,7 +104,7 @@ export class EventLog {
appendFire(ruleName: string): void {
this.#events.push({
seq: ++this.#seq,
ts: Date.now(),
ts: this.#clock(),
kind: "fire",
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,
},
reporter: [["html", { open: "never" }], ["list"]],
workers: 1, // prevent parallel workers from sharing the single dev server
});