@semiont/core 0.5.10 → 0.5.11
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/index.d.ts +56 -25
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +2816 -3
- package/dist/testing.js +577 -8
- package/dist/testing.js.map +1 -1
- package/package.json +2 -2
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import * as
|
|
2
|
-
import { Subject } from 'rxjs';
|
|
1
|
+
import * as fc2 from 'fast-check';
|
|
2
|
+
import { Subject, BehaviorSubject } from 'rxjs';
|
|
3
3
|
|
|
4
4
|
// src/state-unit-axioms.ts
|
|
5
5
|
function disposeProbe() {
|
|
@@ -32,7 +32,7 @@ function assertStateUnitAxioms(spec) {
|
|
|
32
32
|
const fresh = () => normalize(spec.setup());
|
|
33
33
|
const run = (axiom, prop) => {
|
|
34
34
|
try {
|
|
35
|
-
|
|
35
|
+
fc2.assert(prop, { numRuns });
|
|
36
36
|
} catch (e) {
|
|
37
37
|
throw new Error(`${axiom}: ${e instanceof Error ? e.message : String(e)}`);
|
|
38
38
|
}
|
|
@@ -66,7 +66,7 @@ function assertStateUnitAxioms(spec) {
|
|
|
66
66
|
});
|
|
67
67
|
teardown();
|
|
68
68
|
}
|
|
69
|
-
run("A5",
|
|
69
|
+
run("A5", fc2.property(fc2.integer({ min: 1, max: 20 }), (n) => {
|
|
70
70
|
const { unit, passedIn, teardown } = fresh();
|
|
71
71
|
for (let i = 0; i < n; i++) unit.dispose();
|
|
72
72
|
passedIn.forEach((probe, i) => {
|
|
@@ -78,7 +78,7 @@ function assertStateUnitAxioms(spec) {
|
|
|
78
78
|
}));
|
|
79
79
|
if (spec.surfaces) {
|
|
80
80
|
const surfaces = spec.surfaces;
|
|
81
|
-
run("A6",
|
|
81
|
+
run("A6", fc2.property(fc2.integer({ min: 1, max: 10 }), (k) => {
|
|
82
82
|
const { unit, teardown } = fresh();
|
|
83
83
|
const completed = [];
|
|
84
84
|
const subs = surfaces(unit).flatMap(
|
|
@@ -99,7 +99,7 @@ function assertStateUnitAxioms(spec) {
|
|
|
99
99
|
}
|
|
100
100
|
if (spec.invocations) {
|
|
101
101
|
const invocations = spec.invocations;
|
|
102
|
-
run("A5b",
|
|
102
|
+
run("A5b", fc2.property(fc2.array(fc2.nat(), { maxLength: 12 }), (seq) => {
|
|
103
103
|
const { unit, teardown } = fresh();
|
|
104
104
|
const callers = invocations(unit);
|
|
105
105
|
unit.dispose();
|
|
@@ -113,7 +113,7 @@ function assertStateUnitAxioms(spec) {
|
|
|
113
113
|
if (spec.surfaces && spec.invocations) {
|
|
114
114
|
const surfaces = spec.surfaces;
|
|
115
115
|
const invocations = spec.invocations;
|
|
116
|
-
run("X3-runtime",
|
|
116
|
+
run("X3-runtime", fc2.property(fc2.array(fc2.nat(), { minLength: 1, maxLength: 8 }), (seq) => {
|
|
117
117
|
const a = fresh();
|
|
118
118
|
const b = fresh();
|
|
119
119
|
const bSurfaces = surfaces(b.unit);
|
|
@@ -171,6 +171,575 @@ function assertStateUnitAxioms(spec) {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
// src/branded-types.ts
|
|
175
|
+
function baseUrl(value) {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/bus-operations.ts
|
|
180
|
+
var BUS_OPERATIONS = {
|
|
181
|
+
// ── BIND ────────────────────────────────────────────────────────
|
|
182
|
+
"bind:update-body": { result: "bind:body-updated", failure: "bind:body-update-failed" },
|
|
183
|
+
// ── BROWSE (reads) ──────────────────────────────────────────────
|
|
184
|
+
"browse:resource-requested": { result: "browse:resource-result", failure: "browse:resource-failed" },
|
|
185
|
+
"browse:resources-requested": { result: "browse:resources-result", failure: "browse:resources-failed" },
|
|
186
|
+
"browse:annotation-requested": { result: "browse:annotation-result", failure: "browse:annotation-failed" },
|
|
187
|
+
"browse:annotations-requested": { result: "browse:annotations-result", failure: "browse:annotations-failed" },
|
|
188
|
+
"browse:annotation-history-requested": { result: "browse:annotation-history-result", failure: "browse:annotation-history-failed" },
|
|
189
|
+
"browse:events-requested": { result: "browse:events-result", failure: "browse:events-failed" },
|
|
190
|
+
"browse:referenced-by-requested": { result: "browse:referenced-by-result", failure: "browse:referenced-by-failed" },
|
|
191
|
+
"browse:entity-types-requested": { result: "browse:entity-types-result", failure: "browse:entity-types-failed" },
|
|
192
|
+
"browse:tag-schemas-requested": { result: "browse:tag-schemas-result", failure: "browse:tag-schemas-failed" },
|
|
193
|
+
"browse:agents-requested": { result: "browse:agents-result", failure: "browse:agents-failed" },
|
|
194
|
+
"browse:directory-requested": { result: "browse:directory-result", failure: "browse:directory-failed" },
|
|
195
|
+
// dormant — backend handler complete, no client caller yet (annotation-detail capability)
|
|
196
|
+
"browse:annotation-context-requested": { result: "browse:annotation-context-result", failure: "browse:annotation-context-failed" },
|
|
197
|
+
// ── FRAME (KB schema writes) ────────────────────────────────────
|
|
198
|
+
"frame:add-entity-type": { result: "frame:entity-type-add-ok", failure: "frame:entity-type-add-failed" },
|
|
199
|
+
"frame:add-tag-schema": { result: "frame:tag-schema-add-ok", failure: "frame:tag-schema-add-failed" },
|
|
200
|
+
// ── GATHER ──────────────────────────────────────────────────────
|
|
201
|
+
// streaming: take-1 result + failure plus an intermediate progress channel
|
|
202
|
+
"gather:requested": { result: "gather:complete", failure: "gather:failed", progress: "gather:annotation-progress" },
|
|
203
|
+
"gather:resource-requested": { result: "gather:resource-complete", failure: "gather:resource-failed" },
|
|
204
|
+
// dormant — backend handler complete, no client caller yet (annotation summary)
|
|
205
|
+
"gather:summary-requested": { result: "gather:summary-result", failure: "gather:summary-failed" },
|
|
206
|
+
// ── JOB ─────────────────────────────────────────────────────────
|
|
207
|
+
"job:create": { result: "job:created", failure: "job:create-failed" },
|
|
208
|
+
"job:status-requested": { result: "job:status-result", failure: "job:status-failed" },
|
|
209
|
+
"job:cancel-requested": { result: "job:cancel-ok", failure: "job:cancel-failed" },
|
|
210
|
+
// worker-side: the worker claims a queued job (not an SDK call)
|
|
211
|
+
"job:claim": { result: "job:claimed", failure: "job:claim-failed" },
|
|
212
|
+
// ── MARK ────────────────────────────────────────────────────────
|
|
213
|
+
"mark:create-request": { result: "mark:create-ok", failure: "mark:create-failed" },
|
|
214
|
+
"mark:delete": { result: "mark:delete-ok", failure: "mark:delete-failed" },
|
|
215
|
+
"mark:archive": { result: "mark:archive-ok", failure: "mark:archive-failed" },
|
|
216
|
+
"mark:unarchive": { result: "mark:unarchive-ok", failure: "mark:unarchive-failed" },
|
|
217
|
+
"mark:update-entity-types": { result: "mark:update-entity-types-ok", failure: "mark:update-entity-types-failed" },
|
|
218
|
+
// ── MATCH ───────────────────────────────────────────────────────
|
|
219
|
+
// take-1 dressed as an Observable in the SDK; no progress channel
|
|
220
|
+
"match:search-requested": { result: "match:search-results", failure: "match:search-failed" },
|
|
221
|
+
// ── YIELD ───────────────────────────────────────────────────────
|
|
222
|
+
// live in-process (resource-operations.ts emits + awaits via race()); the
|
|
223
|
+
// client also .on()-subscribes -ok for cache invalidation
|
|
224
|
+
"yield:create": { result: "yield:create-ok", failure: "yield:create-failed" },
|
|
225
|
+
// dormant — handler in stower exists, no request emitter; client pre-subscribes -ok
|
|
226
|
+
"yield:update": { result: "yield:update-ok", failure: "yield:update-failed" },
|
|
227
|
+
"yield:clone-create": { result: "yield:clone-created", failure: "yield:clone-create-failed" },
|
|
228
|
+
"yield:clone-resource-requested": { result: "yield:clone-resource-result", failure: "yield:clone-resource-failed" },
|
|
229
|
+
"yield:clone-token-requested": { result: "yield:clone-token-generated", failure: "yield:clone-token-failed" }
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// src/bridged-channels.ts
|
|
233
|
+
var BRIDGED_BROADCASTS = [
|
|
234
|
+
"job:report-progress",
|
|
235
|
+
"job:complete",
|
|
236
|
+
"job:fail",
|
|
237
|
+
"frame:entity-type-added",
|
|
238
|
+
"frame:tag-schema-added",
|
|
239
|
+
"beckon:focus",
|
|
240
|
+
"beckon:sparkle",
|
|
241
|
+
"bus:resume-gap"
|
|
242
|
+
];
|
|
243
|
+
var REGISTRY_REPLIES = Object.keys(BUS_OPERATIONS).flatMap(
|
|
244
|
+
(key) => {
|
|
245
|
+
const op = BUS_OPERATIONS[key];
|
|
246
|
+
return "progress" in op ? [op.result, op.failure, op.progress] : [op.result, op.failure];
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
var BRIDGED_CHANNELS = [
|
|
250
|
+
...REGISTRY_REPLIES,
|
|
251
|
+
...BRIDGED_BROADCASTS
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
// src/bus-log.ts
|
|
255
|
+
var NODE_BUS_LOG = typeof process !== "undefined" && !!process.env?.SEMIONT_BUS_LOG;
|
|
256
|
+
var IS_NODE = typeof process !== "undefined" && !!process.versions?.node;
|
|
257
|
+
function busLogEnabled() {
|
|
258
|
+
const g = globalThis;
|
|
259
|
+
if (g.__SEMIONT_BUS_LOG__) return true;
|
|
260
|
+
return NODE_BUS_LOG;
|
|
261
|
+
}
|
|
262
|
+
function busLog(op, channel, payload, scope) {
|
|
263
|
+
if (!busLogEnabled()) return;
|
|
264
|
+
const cidRaw = payload?.correlationId;
|
|
265
|
+
const cid = typeof cidRaw === "string" ? cidRaw.slice(0, 8) : void 0;
|
|
266
|
+
const tag = `[bus ${op}] ${channel}` + ("") + (cid ? ` cid=${cid}` : "") + ("");
|
|
267
|
+
console.debug(tag, payload);
|
|
268
|
+
}
|
|
269
|
+
function warnUnobservedRepliesEnabled() {
|
|
270
|
+
return IS_NODE;
|
|
271
|
+
}
|
|
272
|
+
var unobservedReplyWarned = /* @__PURE__ */ new Set();
|
|
273
|
+
function warnIfUnobservedReply(channel, payload, observerCount) {
|
|
274
|
+
if (observerCount > 0) return;
|
|
275
|
+
const cidRaw = payload?.correlationId;
|
|
276
|
+
if (typeof cidRaw !== "string" || cidRaw.length === 0) return;
|
|
277
|
+
if (BRIDGED_CHANNELS.includes(channel)) return;
|
|
278
|
+
if (unobservedReplyWarned.has(channel)) return;
|
|
279
|
+
unobservedReplyWarned.add(channel);
|
|
280
|
+
console.warn(
|
|
281
|
+
`[bus DROP] ${channel} cid=${cidRaw.slice(0, 8)} emitted with 0 subscribers and not in BRIDGED_CHANNELS \u2014 a correlation reply with no forwarder is dropped, so the awaiting client times out (no error). Bridge it by declaring its operation in BUS_OPERATIONS (packages/core/src/bus-operations.ts) \u2014 or, if it is a non-reply broadcast, add it to BRIDGED_BROADCASTS (packages/core/src/bridged-channels.ts) \u2014 so transports subscribe to it.`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// src/event-bus.ts
|
|
286
|
+
var EventBus = class {
|
|
287
|
+
subjects;
|
|
288
|
+
isDestroyed;
|
|
289
|
+
constructor() {
|
|
290
|
+
this.subjects = /* @__PURE__ */ new Map();
|
|
291
|
+
this.isDestroyed = false;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Get the RxJS Subject for an event
|
|
295
|
+
*
|
|
296
|
+
* Returns a typed Subject that can be used with all RxJS operators.
|
|
297
|
+
* Subjects are created lazily on first access.
|
|
298
|
+
*
|
|
299
|
+
* @param eventName - The event name
|
|
300
|
+
* @returns The RxJS Subject for this event
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```typescript
|
|
304
|
+
* // Emit
|
|
305
|
+
* eventBus.get('beckon:hover').next({ annotationId: 'ann-1' });
|
|
306
|
+
*
|
|
307
|
+
* // Subscribe
|
|
308
|
+
* const sub = eventBus.get('beckon:hover').subscribe(handleHover);
|
|
309
|
+
*
|
|
310
|
+
* // With operators
|
|
311
|
+
* eventBus.get('beckon:hover')
|
|
312
|
+
* .pipe(debounceTime(100), distinctUntilChanged())
|
|
313
|
+
* .subscribe(handleHover);
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
get(eventName) {
|
|
317
|
+
if (this.isDestroyed) {
|
|
318
|
+
throw new Error(`Cannot access event '${String(eventName)}' on destroyed bus`);
|
|
319
|
+
}
|
|
320
|
+
if (!this.subjects.has(eventName)) {
|
|
321
|
+
const subject = new Subject();
|
|
322
|
+
const wantBusLog = busLogEnabled();
|
|
323
|
+
const wantDropCheck = warnUnobservedRepliesEnabled();
|
|
324
|
+
if (wantBusLog || wantDropCheck) {
|
|
325
|
+
const wrapped = subject;
|
|
326
|
+
const originalNext = subject.next.bind(subject);
|
|
327
|
+
subject.next = (value) => {
|
|
328
|
+
if (wantBusLog) busLog("EMIT", String(eventName), value);
|
|
329
|
+
if (wantDropCheck) warnIfUnobservedReply(String(eventName), value, wrapped.observers.length);
|
|
330
|
+
originalNext(value);
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
this.subjects.set(eventName, subject);
|
|
334
|
+
}
|
|
335
|
+
return this.subjects.get(eventName);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Get the RxJS Subject for a domain event type (PersistedEventType).
|
|
339
|
+
*
|
|
340
|
+
* Domain event channels carry `StoredEvent`. This method avoids the need
|
|
341
|
+
* for `as keyof EventMap` casts when subscribing to domain event channels
|
|
342
|
+
* using runtime `PersistedEventType` strings.
|
|
343
|
+
*/
|
|
344
|
+
getDomainEvent(eventType) {
|
|
345
|
+
return this.get(eventType);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Destroy the event bus and complete all subjects
|
|
349
|
+
*
|
|
350
|
+
* After calling destroy(), no new events can be emitted or subscribed to.
|
|
351
|
+
* All active subscriptions will be completed.
|
|
352
|
+
*/
|
|
353
|
+
destroy() {
|
|
354
|
+
if (this.isDestroyed) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
for (const subject of this.subjects.values()) {
|
|
358
|
+
subject.complete();
|
|
359
|
+
}
|
|
360
|
+
this.subjects.clear();
|
|
361
|
+
this.isDestroyed = true;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Check if the event bus has been destroyed
|
|
365
|
+
*/
|
|
366
|
+
get destroyed() {
|
|
367
|
+
return this.isDestroyed;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Create a resource-scoped event bus
|
|
371
|
+
*
|
|
372
|
+
* Events emitted or subscribed through the scoped bus are isolated to that resource.
|
|
373
|
+
* Internally, events are namespaced but the API remains identical to the parent bus.
|
|
374
|
+
*
|
|
375
|
+
* @param resourceId - Resource identifier to scope events to
|
|
376
|
+
* @returns A scoped event bus for this resource
|
|
377
|
+
*
|
|
378
|
+
* @example
|
|
379
|
+
* ```typescript
|
|
380
|
+
* const eventBus = new EventBus();
|
|
381
|
+
* const resource1 = eventBus.scope('resource-1');
|
|
382
|
+
* const resource2 = eventBus.scope('resource-2');
|
|
383
|
+
*
|
|
384
|
+
* // These are isolated - only resource1 subscribers will fire
|
|
385
|
+
* resource1.get('detection:progress').next({ status: 'started' });
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
scope(resourceId) {
|
|
389
|
+
return new ScopedEventBus(this, resourceId);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
var ScopedEventBus = class _ScopedEventBus {
|
|
393
|
+
constructor(parent, scopePrefix) {
|
|
394
|
+
this.parent = parent;
|
|
395
|
+
this.scopePrefix = scopePrefix;
|
|
396
|
+
}
|
|
397
|
+
parent;
|
|
398
|
+
scopePrefix;
|
|
399
|
+
/**
|
|
400
|
+
* Get the RxJS Subject for a scoped event
|
|
401
|
+
*
|
|
402
|
+
* Returns the same type as the parent bus, but events are isolated to this scope.
|
|
403
|
+
* Internally uses namespaced keys but preserves type safety.
|
|
404
|
+
*
|
|
405
|
+
* @param event - The event name
|
|
406
|
+
* @returns The RxJS Subject for this scoped event
|
|
407
|
+
*/
|
|
408
|
+
get(event) {
|
|
409
|
+
const scopedKey = `${this.scopePrefix}:${event}`;
|
|
410
|
+
const parentSubjects = this.parent.subjects;
|
|
411
|
+
if (!parentSubjects.has(scopedKey)) {
|
|
412
|
+
parentSubjects.set(scopedKey, new Subject());
|
|
413
|
+
}
|
|
414
|
+
return parentSubjects.get(scopedKey);
|
|
415
|
+
}
|
|
416
|
+
/** Get the RxJS Subject for a domain event type on this scoped bus. */
|
|
417
|
+
getDomainEvent(eventType) {
|
|
418
|
+
return this.get(eventType);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Create a nested scope
|
|
422
|
+
*
|
|
423
|
+
* Allows hierarchical scoping like `resource-1:subsystem-a`
|
|
424
|
+
*
|
|
425
|
+
* @param subScope - Additional scope level
|
|
426
|
+
* @returns A nested scoped event bus
|
|
427
|
+
*/
|
|
428
|
+
scope(subScope) {
|
|
429
|
+
return new _ScopedEventBus(this.parent, `${this.scopePrefix}:${subScope}`);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// src/faulty-transport.ts
|
|
434
|
+
function isOperation(channel) {
|
|
435
|
+
return channel in BUS_OPERATIONS;
|
|
436
|
+
}
|
|
437
|
+
function retryKeyOf(channel, payload) {
|
|
438
|
+
const entries = Object.entries(payload).filter(([k]) => k !== "correlationId" && k !== "_trace" && k !== "_userId").sort(([a], [b]) => a < b ? -1 : 1);
|
|
439
|
+
return `${channel} ${JSON.stringify(entries)}`;
|
|
440
|
+
}
|
|
441
|
+
var FaultyTransport = class {
|
|
442
|
+
baseUrl = baseUrl("faulty://simulator");
|
|
443
|
+
state$ = new BehaviorSubject("open");
|
|
444
|
+
errorsSubject = new Subject();
|
|
445
|
+
errors$ = this.errorsSubject.asObservable();
|
|
446
|
+
/** Every request-channel emit, in order — the L2 accounting surface. */
|
|
447
|
+
requestLog = [];
|
|
448
|
+
bus = new EventBus();
|
|
449
|
+
schedule;
|
|
450
|
+
scopeModel;
|
|
451
|
+
makeResponse;
|
|
452
|
+
requestCount = 0;
|
|
453
|
+
activeScope = null;
|
|
454
|
+
scopeRefs = 0;
|
|
455
|
+
timers = /* @__PURE__ */ new Set();
|
|
456
|
+
disposed = false;
|
|
457
|
+
constructor(cfg = {}) {
|
|
458
|
+
this.schedule = cfg.schedule ?? [];
|
|
459
|
+
this.scopeModel = cfg.scopeModel ?? "single-slot-throw";
|
|
460
|
+
this.makeResponse = cfg.makeResponse ?? (() => ({}));
|
|
461
|
+
}
|
|
462
|
+
// ── Bus primitives ──────────────────────────────────────────────────────
|
|
463
|
+
async emit(channel, payload, resourceScope) {
|
|
464
|
+
if (this.disposed) return;
|
|
465
|
+
const name = channel;
|
|
466
|
+
if (!isOperation(name)) {
|
|
467
|
+
const target = resourceScope === void 0 ? this.bus.get(channel) : this.bus.scope(resourceScope).get(channel);
|
|
468
|
+
target.next(payload);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const record = payload;
|
|
472
|
+
const action = this.schedule.length === 0 ? { kind: "deliver" } : this.schedule[this.requestCount % this.schedule.length];
|
|
473
|
+
this.requestCount += 1;
|
|
474
|
+
this.requestLog.push({
|
|
475
|
+
channel: name,
|
|
476
|
+
action,
|
|
477
|
+
correlationId: typeof record.correlationId === "string" ? record.correlationId : void 0,
|
|
478
|
+
retryKey: retryKeyOf(name, record)
|
|
479
|
+
});
|
|
480
|
+
if (action.kind === "reject-emit") {
|
|
481
|
+
throw new Error(`FaultyTransport: emit rejected by schedule (reject-emit) on ${name}`);
|
|
482
|
+
}
|
|
483
|
+
this.bus.get(channel).next(payload);
|
|
484
|
+
const reply = () => {
|
|
485
|
+
if (this.disposed) return;
|
|
486
|
+
const response = this.makeResponse(name, record);
|
|
487
|
+
const replyPayload = response === void 0 ? { correlationId: record.correlationId } : { correlationId: record.correlationId, response };
|
|
488
|
+
const resultChannel = BUS_OPERATIONS[name].result;
|
|
489
|
+
this.bus.get(resultChannel).next(replyPayload);
|
|
490
|
+
};
|
|
491
|
+
switch (action.kind) {
|
|
492
|
+
case "deliver":
|
|
493
|
+
queueMicrotask(reply);
|
|
494
|
+
break;
|
|
495
|
+
case "duplicate-reply":
|
|
496
|
+
queueMicrotask(reply);
|
|
497
|
+
queueMicrotask(reply);
|
|
498
|
+
break;
|
|
499
|
+
case "delay": {
|
|
500
|
+
const t = setTimeout(() => {
|
|
501
|
+
this.timers.delete(t);
|
|
502
|
+
reply();
|
|
503
|
+
}, action.ms);
|
|
504
|
+
this.timers.add(t);
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
on(channel, handler) {
|
|
510
|
+
const sub = this.bus.get(channel).subscribe(handler);
|
|
511
|
+
return () => sub.unsubscribe();
|
|
512
|
+
}
|
|
513
|
+
stream(channel) {
|
|
514
|
+
return this.bus.get(channel);
|
|
515
|
+
}
|
|
516
|
+
subscribeToResource(rid) {
|
|
517
|
+
const scope = rid;
|
|
518
|
+
if (this.scopeModel === "single-slot-throw" && this.activeScope !== null && this.activeScope !== scope) {
|
|
519
|
+
throw new Error(
|
|
520
|
+
`FaultyTransport: scope slot busy (${this.activeScope}); unsubscribe before subscribing to ${scope} (scopeModel=single-slot-throw)`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
this.activeScope = this.activeScope ?? scope;
|
|
524
|
+
this.scopeRefs += 1;
|
|
525
|
+
let released = false;
|
|
526
|
+
return () => {
|
|
527
|
+
if (released) return;
|
|
528
|
+
released = true;
|
|
529
|
+
this.scopeRefs -= 1;
|
|
530
|
+
if (this.scopeRefs === 0) this.activeScope = null;
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
bridgeInto(bus) {
|
|
534
|
+
for (const channel of BRIDGED_CHANNELS) {
|
|
535
|
+
this.bus.get(channel).subscribe((payload) => {
|
|
536
|
+
bus.get(channel).next(payload);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
541
|
+
dispose() {
|
|
542
|
+
if (this.disposed) return;
|
|
543
|
+
this.disposed = true;
|
|
544
|
+
for (const t of this.timers) clearTimeout(t);
|
|
545
|
+
this.timers.clear();
|
|
546
|
+
this.state$.next("closed");
|
|
547
|
+
this.state$.complete();
|
|
548
|
+
this.errorsSubject.complete();
|
|
549
|
+
this.bus.destroy();
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
function arbFaultAction() {
|
|
553
|
+
return fc2.oneof(
|
|
554
|
+
fc2.constant({ kind: "deliver" }),
|
|
555
|
+
fc2.constant({ kind: "drop-reply" }),
|
|
556
|
+
fc2.integer({ min: 0, max: 5 }).map((ms) => ({ kind: "delay", ms })),
|
|
557
|
+
fc2.constant({ kind: "duplicate-reply" }),
|
|
558
|
+
fc2.constant({ kind: "reject-emit" })
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
function arbFaultSchedule(maxLength = 8) {
|
|
562
|
+
return fc2.array(arbFaultAction(), { minLength: 1, maxLength });
|
|
563
|
+
}
|
|
564
|
+
function describeSchedule(schedule) {
|
|
565
|
+
return schedule.map((a) => a.kind === "delay" ? `delay(${a.ms})` : a.kind).join(",");
|
|
566
|
+
}
|
|
567
|
+
function sleep(ms) {
|
|
568
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
569
|
+
}
|
|
570
|
+
async function runLabeled(fallback, slot, assertion) {
|
|
571
|
+
try {
|
|
572
|
+
await assertion();
|
|
573
|
+
} catch (e) {
|
|
574
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
575
|
+
if (slot.violation) throw new Error(`${slot.violation.message}
|
|
576
|
+
${detail}`);
|
|
577
|
+
throw new Error(`${fallback}: ${detail}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function capturing(slot, block) {
|
|
581
|
+
try {
|
|
582
|
+
block();
|
|
583
|
+
} catch (e) {
|
|
584
|
+
slot.violation = e instanceof Error ? e : new Error(String(e));
|
|
585
|
+
throw e;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function assertLivenessAxioms(spec) {
|
|
589
|
+
const retryBudget = spec.retryBudget ?? 1;
|
|
590
|
+
const slack = spec.slackFactor ?? 4;
|
|
591
|
+
const scheduleArb = spec.scheduleArb ?? arbFaultSchedule();
|
|
592
|
+
const scopeArb = spec.scopeModel === "both" ? fc2.constantFrom("single-slot-throw", "multi") : fc2.constant(spec.scopeModel ?? "single-slot-throw");
|
|
593
|
+
const slot = { violation: null };
|
|
594
|
+
await runLabeled(
|
|
595
|
+
"liveness",
|
|
596
|
+
slot,
|
|
597
|
+
() => fc2.assert(
|
|
598
|
+
fc2.asyncProperty(scheduleArb, scopeArb, async (schedule, scopeModel) => {
|
|
599
|
+
const delayTotal = schedule.reduce((n, a) => n + (a.kind === "delay" ? a.ms : 0), 0);
|
|
600
|
+
const bound = (spec.timeoutMs * (1 + retryBudget) + delayTotal) * slack;
|
|
601
|
+
const transport = new FaultyTransport({
|
|
602
|
+
schedule,
|
|
603
|
+
scopeModel,
|
|
604
|
+
...spec.makeResponse ? { makeResponse: spec.makeResponse } : {}
|
|
605
|
+
});
|
|
606
|
+
const scenario = await spec.setup(transport);
|
|
607
|
+
const outputs = scenario.outputs;
|
|
608
|
+
const settlements = scenario.settlements ?? [];
|
|
609
|
+
const notified = outputs.map(() => false);
|
|
610
|
+
const settled = settlements.map(() => false);
|
|
611
|
+
let doneResolve = () => {
|
|
612
|
+
};
|
|
613
|
+
const allDone = new Promise((resolve) => {
|
|
614
|
+
doneResolve = resolve;
|
|
615
|
+
});
|
|
616
|
+
const check = () => {
|
|
617
|
+
if (notified.every(Boolean) && settled.every(Boolean)) doneResolve();
|
|
618
|
+
};
|
|
619
|
+
const subs = outputs.map(
|
|
620
|
+
(o, i) => o.subscribe({
|
|
621
|
+
next: () => {
|
|
622
|
+
notified[i] = true;
|
|
623
|
+
check();
|
|
624
|
+
},
|
|
625
|
+
error: () => {
|
|
626
|
+
notified[i] = true;
|
|
627
|
+
check();
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
);
|
|
631
|
+
settlements.forEach((p, i) => {
|
|
632
|
+
p.then(
|
|
633
|
+
() => {
|
|
634
|
+
settled[i] = true;
|
|
635
|
+
check();
|
|
636
|
+
},
|
|
637
|
+
() => {
|
|
638
|
+
settled[i] = true;
|
|
639
|
+
check();
|
|
640
|
+
}
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
check();
|
|
644
|
+
await Promise.race([allDone, sleep(bound)]);
|
|
645
|
+
try {
|
|
646
|
+
capturing(slot, () => {
|
|
647
|
+
settled.forEach((seen, i) => {
|
|
648
|
+
if (!seen) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
`L2: settlement #${i} did not settle within ${bound}ms under schedule \u27E8${describeSchedule(schedule)}\u27E9 \u2014 the forbidden fourth state`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
const issues = /* @__PURE__ */ new Map();
|
|
655
|
+
for (const entry of transport.requestLog) {
|
|
656
|
+
const prior = issues.get(entry.retryKey) ?? { count: 0, lastFaulted: false };
|
|
657
|
+
issues.set(entry.retryKey, {
|
|
658
|
+
count: prior.count + 1,
|
|
659
|
+
lastFaulted: entry.action.kind === "drop-reply" || entry.action.kind === "reject-emit"
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
for (const [key, { count, lastFaulted }] of issues) {
|
|
663
|
+
if (count > 1 + retryBudget) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`L2: request \u27E8${key}\u27E9 issued ${count} times \u2014 exceeds the retry budget (1 + ${retryBudget}) under schedule \u27E8${describeSchedule(schedule)}\u27E9`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (lastFaulted && count <= retryBudget && outputs.length > 0 && !notified.some(Boolean)) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`L2: faulted request \u27E8${key}\u27E9 was neither re-issued nor surfaced under schedule \u27E8${describeSchedule(schedule)}\u27E9 \u2014 rejection swallowed`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
notified.forEach((seen, i) => {
|
|
675
|
+
if (!seen) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
`L1: output #${i} received no next/error within ${bound}ms under schedule \u27E8${describeSchedule(schedule)}\u27E9 (scope=${scopeModel}) \u2014 silently pending`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
} finally {
|
|
683
|
+
subs.forEach((s) => s.unsubscribe());
|
|
684
|
+
scenario.teardown?.();
|
|
685
|
+
transport.dispose();
|
|
686
|
+
}
|
|
687
|
+
}),
|
|
688
|
+
{ numRuns: spec.numRuns ?? 25 }
|
|
689
|
+
)
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
function arbDeliveryOps(maxOps = 12) {
|
|
693
|
+
return fc2.array(fc2.constantFrom("write", "transition"), { minLength: 1, maxLength: maxOps }).map((ops) => ops.includes("write") ? ops : [...ops, "write"]);
|
|
694
|
+
}
|
|
695
|
+
async function assertExactlyOnceDelivery(spec) {
|
|
696
|
+
const opsArb = spec.opsArb ?? arbDeliveryOps(spec.maxOps ?? 12);
|
|
697
|
+
const slot = { violation: null };
|
|
698
|
+
await runLabeled(
|
|
699
|
+
"L3",
|
|
700
|
+
slot,
|
|
701
|
+
() => fc2.assert(
|
|
702
|
+
fc2.asyncProperty(opsArb, async (ops) => {
|
|
703
|
+
const subject = spec.setup();
|
|
704
|
+
const delivered = [];
|
|
705
|
+
const sub = subject.output$.subscribe((id) => delivered.push(id));
|
|
706
|
+
const written = [];
|
|
707
|
+
try {
|
|
708
|
+
for (let i = 0; i < ops.length; i++) {
|
|
709
|
+
if (ops[i] === "write") {
|
|
710
|
+
const id = `e${i}`;
|
|
711
|
+
written.push(id);
|
|
712
|
+
subject.write(id);
|
|
713
|
+
} else {
|
|
714
|
+
await subject.transition();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
await (subject.settle?.() ?? sleep(0));
|
|
718
|
+
capturing(slot, () => {
|
|
719
|
+
for (const id of written) {
|
|
720
|
+
const n = delivered.filter((d) => d === id).length;
|
|
721
|
+
if (n === 0) {
|
|
722
|
+
throw new Error(
|
|
723
|
+
`L3: event \u27E8${id}\u27E9 delivered 0 times under \u27E8${ops.join(",")}\u27E9 \u2014 lost across a transition (retire by drain, never by abort)`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
if (n > 1) {
|
|
727
|
+
throw new Error(
|
|
728
|
+
`L3: event \u27E8${id}\u27E9 delivered ${n} times under \u27E8${ops.join(",")}\u27E9 \u2014 duplicate delivery`
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
} finally {
|
|
734
|
+
sub.unsubscribe();
|
|
735
|
+
subject.teardown?.();
|
|
736
|
+
}
|
|
737
|
+
}),
|
|
738
|
+
{ numRuns: spec.numRuns ?? 50 }
|
|
739
|
+
)
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export { FaultyTransport, arbDeliveryOps, arbFaultAction, arbFaultSchedule, assertExactlyOnceDelivery, assertLivenessAxioms, assertStateUnitAxioms, disposeProbe, retryKeyOf };
|
|
175
744
|
//# sourceMappingURL=testing.js.map
|
|
176
745
|
//# sourceMappingURL=testing.js.map
|