@starci/jest-preset 2.3.7 → 2.4.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/CHANGELOG.md +5 -0
- package/README.md +3 -3
- package/{builders.cjs → dist/builders.d.ts} +2 -7
- package/dist/builders.js +15 -0
- package/dist/cache.d.ts +48 -0
- package/dist/cache.js +68 -0
- package/dist/clock.d.ts +20 -0
- package/dist/clock.js +32 -0
- package/dist/entity-manager.d.ts +114 -0
- package/dist/entity-manager.js +138 -0
- package/dist/event-bus.d.ts +52 -0
- package/dist/event-bus.js +106 -0
- package/dist/ids.d.ts +21 -0
- package/dist/ids.js +42 -0
- package/dist/inbox.d.ts +65 -0
- package/dist/inbox.js +173 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +289 -0
- package/dist/lcov.d.ts +16 -0
- package/dist/lcov.js +25 -0
- package/dist/lock.d.ts +37 -0
- package/dist/lock.js +57 -0
- package/dist/matchers.d.ts +27 -0
- package/dist/matchers.js +36 -0
- package/dist/merge.d.ts +14 -0
- package/dist/merge.js +55 -0
- package/dist/mock.d.ts +21 -0
- package/dist/mock.js +52 -0
- package/dist/queue.d.ts +30 -0
- package/dist/queue.js +50 -0
- package/dist/sonar-exclusions.d.ts +7 -0
- package/dist/sonar-exclusions.js +8 -0
- package/dist/unit-metadata.d.ts +23 -0
- package/dist/unit-metadata.js +71 -0
- package/dist/world-runner.d.ts +42 -0
- package/dist/world-runner.js +142 -0
- package/package.json +42 -32
- package/builders.d.ts +0 -2
- package/cache.cjs +0 -67
- package/cache.d.ts +0 -31
- package/clock.cjs +0 -34
- package/clock.d.ts +0 -13
- package/entity-manager.cjs +0 -146
- package/entity-manager.d.ts +0 -103
- package/event-bus.cjs +0 -100
- package/event-bus.d.ts +0 -54
- package/ids.cjs +0 -41
- package/ids.d.ts +0 -13
- package/inbox.cjs +0 -162
- package/inbox.d.ts +0 -58
- package/index.cjs +0 -295
- package/index.d.ts +0 -38
- package/lcov.cjs +0 -25
- package/lock.cjs +0 -56
- package/lock.d.ts +0 -26
- package/matchers.cjs +0 -45
- package/matchers.d.ts +0 -17
- package/merge.cjs +0 -45
- package/mock.cjs +0 -53
- package/mock.d.ts +0 -12
- package/queue.cjs +0 -51
- package/queue.d.ts +0 -26
- package/sonar-exclusions.cjs +0 -8
- package/unit-metadata.cjs +0 -63
- package/world-runner.cjs +0 -142
- /package/{unit-metadata.generated.cjs → dist/unit-metadata.generated.js} +0 -0
package/clock.cjs
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
"use strict"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `FakeClock` -- the test double for the injected `Clock` port (`platform/clock`).
|
|
5
|
-
*
|
|
6
|
-
* Business code asks its `Clock` port for the time (`clock.now()`) instead of reading `Date.now()` or
|
|
7
|
-
* `new Date()` ambiently (`no-ambient-clock`, R79). A spec then drives time explicitly: it starts the
|
|
8
|
-
* clock at a chosen instant, advances it by a duration, or sets it to a new instant, all without a real
|
|
9
|
-
* sleep and without a race against the wall clock.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
class FakeClock {
|
|
13
|
-
/** @param {Date | number | string} [at] - The instant the clock starts at; defaults to now, once, at construction. */
|
|
14
|
-
constructor(at) {
|
|
15
|
-
this._now = at === undefined ? new Date() : new Date(at)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** The current instant, matching the `Clock` port's `now(): Date`. */
|
|
19
|
-
now() {
|
|
20
|
-
return new Date(this._now.getTime())
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Moves the clock to `at`. */
|
|
24
|
-
set(at) {
|
|
25
|
-
this._now = new Date(at)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Moves the clock forward by `ms` milliseconds (negative moves it back). */
|
|
29
|
-
advance(ms) {
|
|
30
|
-
this._now = new Date(this._now.getTime() + ms)
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
module.exports = { FakeClock }
|
package/clock.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/** The test double for the injected `Clock` port: `{ now(): Date }`, plus spec-only time control. */
|
|
2
|
-
export declare class FakeClock {
|
|
3
|
-
constructor(at?: Date | number | string)
|
|
4
|
-
|
|
5
|
-
/** The current instant. Matches the `Clock` port's `now(): Date`, so `FakeClock` satisfies it structurally. */
|
|
6
|
-
now(): Date
|
|
7
|
-
|
|
8
|
-
/** Moves the clock to `at`. */
|
|
9
|
-
set(at: Date | number | string): void
|
|
10
|
-
|
|
11
|
-
/** Moves the clock forward by `ms` milliseconds (negative moves it back). */
|
|
12
|
-
advance(ms: number): void
|
|
13
|
-
}
|
package/entity-manager.cjs
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
"use strict"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `mockEntityManager()` and `fakeTransaction()` -- the one typed double of a typeorm `EntityManager`.
|
|
5
|
-
*
|
|
6
|
-
* `mockEntityManager(stubs)` takes the answers a spec describes as `{ method: [Entity, result] }` (`query` takes
|
|
7
|
-
* `[sql, rows]`; several answers of one method are a list of pairs). Every method is a `jest.fn()`, so
|
|
8
|
-
* `expect(em.save).toHaveBeenCalledWith(...)` works, and a method the subject calls that the spec did NOT stub throws
|
|
9
|
-
* `mockEntityManager: em.<method>() was called but this spec did not stub it`. The absence of a stub is therefore the
|
|
10
|
-
* proof that the subject made no such call. A method stubbed for another entity (or another sql text) throws too and names
|
|
11
|
-
* both. Answers of one method and one entity are handed out in order and the last one repeats. There is no typeorm import
|
|
12
|
-
* here: the types come from `entity-manager.d.ts` (type-only), the runtime is a Proxy.
|
|
13
|
-
*
|
|
14
|
-
* `create(Entity, plain)` is pure construction and works without a stub. `transaction` needs `fakeTransaction`.
|
|
15
|
-
*
|
|
16
|
-
* `fakeTransaction(em)` makes `em.transaction(work)` (and `em.transaction(isolation, work)`) run `work` with a scoped view
|
|
17
|
-
* of the same double and record how it ended (`commit` when `work` resolves, `rollback` when it throws and the error is
|
|
18
|
-
* rethrown) and which writes it made, so a spec can assert that a rolled-back transaction committed nothing.
|
|
19
|
-
*/
|
|
20
|
-
const { createMock, jestFn } = require("./mock.cjs")
|
|
21
|
-
|
|
22
|
-
/** The manager views handed to a transaction body; `recordingEventBus` and `recordingQueueOutbox` ask whether a manager is one of them. */
|
|
23
|
-
const TRANSACTION_MANAGERS = new WeakSet()
|
|
24
|
-
|
|
25
|
-
/** Methods that change data; a write made inside a transaction is recorded on it. */
|
|
26
|
-
const WRITE_METHODS = new Set([
|
|
27
|
-
"save", "insert", "update", "delete", "remove", "softRemove", "softDelete", "recover", "restore", "upsert",
|
|
28
|
-
"increment", "decrement", "clear",
|
|
29
|
-
])
|
|
30
|
-
const WRITE_SQL = /^\s*(insert|update|delete|upsert|merge|truncate)\b/i
|
|
31
|
-
|
|
32
|
-
const describeTarget = (target) => (typeof target === "function" ? target.name || "<anonymous class>" : JSON.stringify(String(target)).slice(0, 80))
|
|
33
|
-
|
|
34
|
-
const isPair = (value) => Array.isArray(value) && value.length === 2 && (typeof value[0] === "function" || typeof value[0] === "string")
|
|
35
|
-
|
|
36
|
-
/** `{ method: [target, result] | [[target, result], ...] }` as `Map<method, Array<{ target, result, used }>>`. */
|
|
37
|
-
function tableOf(stubs) {
|
|
38
|
-
const table = new Map()
|
|
39
|
-
for (const [method, value] of Object.entries(stubs)) {
|
|
40
|
-
const pairs = isPair(value) ? [value] : value
|
|
41
|
-
if (!Array.isArray(pairs) || !pairs.every(isPair)) {
|
|
42
|
-
throw new Error(`mockEntityManager: the stub of em.${method}() must be [Entity, result] (em.query: [sql, rows]) or a list of such pairs`)
|
|
43
|
-
}
|
|
44
|
-
table.set(method, pairs.map(([target, result]) => ({ target, result, used: false })))
|
|
45
|
-
}
|
|
46
|
-
return table
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The kit over a function factory (`makeFn(implementation)`); `mockEntityManager` and `fakeTransaction` below bind it to
|
|
51
|
-
* `jest.fn`. The seam lets the package spec run the kit without a jest runtime.
|
|
52
|
-
*/
|
|
53
|
-
function createEntityManagerKit(makeFn) {
|
|
54
|
-
function answerOf(method, entries) {
|
|
55
|
-
if (entries === undefined) {
|
|
56
|
-
return () => {
|
|
57
|
-
throw new Error(
|
|
58
|
-
`mockEntityManager: em.${method}() was called but this spec did not stub it. Stub it with mockEntityManager({ ${method}: [Entity, result] }); an unstubbed call means the subject reached the database where the spec says it must not.`,
|
|
59
|
-
)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return async (...args) => {
|
|
63
|
-
const matches = entries.filter((entry) => entry.target === args[0])
|
|
64
|
-
if (matches.length === 0) {
|
|
65
|
-
throw new Error(
|
|
66
|
-
`mockEntityManager: em.${method}(${describeTarget(args[0])}) was called but this spec stubbed em.${method} only for ${entries.map((entry) => describeTarget(entry.target)).join(", ")}`,
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
const entry = matches.find((candidate) => !candidate.used) ?? matches[matches.length - 1]
|
|
70
|
-
entry.used = true
|
|
71
|
-
return entry.result
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** @param {Record<string, unknown>} [stubs] */
|
|
76
|
-
function mockEntityManager(stubs = {}) {
|
|
77
|
-
const table = tableOf(stubs)
|
|
78
|
-
const create = makeFn((target, plain) => Object.assign(new target(), plain))
|
|
79
|
-
return createMock((method) => makeFn(answerOf(method, table.get(method))))({ create })
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** @param {object} [em] - The double to attach the transaction to; a fresh `mockEntityManager()` by default. */
|
|
83
|
-
function fakeTransaction(em = mockEntityManager()) {
|
|
84
|
-
const runs = []
|
|
85
|
-
const scopedFor = (run) => {
|
|
86
|
-
const view = new Proxy(em, {
|
|
87
|
-
get(target, property, receiver) {
|
|
88
|
-
if (property === "queryRunner") return { get isTransactionActive() { return run.outcome === "pending" } }
|
|
89
|
-
const value = Reflect.get(target, property, receiver)
|
|
90
|
-
if (property === "transaction") return (...args) => args[args.length - 1](view)
|
|
91
|
-
if (typeof property !== "string" || typeof value !== "function") return value
|
|
92
|
-
if (!WRITE_METHODS.has(property) && property !== "query") return value
|
|
93
|
-
return (...args) => {
|
|
94
|
-
if (property !== "query" || WRITE_SQL.test(String(args[0]))) run.writes.push({ method: property, args })
|
|
95
|
-
return value(...args)
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
})
|
|
99
|
-
TRANSACTION_MANAGERS.add(view)
|
|
100
|
-
return view
|
|
101
|
-
}
|
|
102
|
-
em.transaction = makeFn(async (...args) => {
|
|
103
|
-
const work = args[args.length - 1]
|
|
104
|
-
if (typeof work !== "function") throw new Error("fakeTransaction: em.transaction() needs a callback as its last argument")
|
|
105
|
-
const run = { outcome: "pending", writes: [] }
|
|
106
|
-
runs.push(run)
|
|
107
|
-
try {
|
|
108
|
-
const result = await work(scopedFor(run))
|
|
109
|
-
run.outcome = "commit"
|
|
110
|
-
return result
|
|
111
|
-
} catch (error) {
|
|
112
|
-
run.outcome = "rollback"
|
|
113
|
-
throw error
|
|
114
|
-
}
|
|
115
|
-
})
|
|
116
|
-
const writesOf = (outcome) => runs.filter((run) => run.outcome === outcome).flatMap((run) => run.writes)
|
|
117
|
-
return {
|
|
118
|
-
em,
|
|
119
|
-
/** How each transaction ended, in call order: "commit" or "rollback". */
|
|
120
|
-
get outcomes() {
|
|
121
|
-
return runs.map((run) => run.outcome)
|
|
122
|
-
},
|
|
123
|
-
get commits() {
|
|
124
|
-
return runs.filter((run) => run.outcome === "commit").length
|
|
125
|
-
},
|
|
126
|
-
get rollbacks() {
|
|
127
|
-
return runs.filter((run) => run.outcome === "rollback").length
|
|
128
|
-
},
|
|
129
|
-
/** The writes (`{ method, args }`) that ended in a commit, in call order. */
|
|
130
|
-
get committedWrites() {
|
|
131
|
-
return writesOf("commit")
|
|
132
|
-
},
|
|
133
|
-
/** The writes that a rollback discarded: made by a body that then threw. */
|
|
134
|
-
get rolledBackWrites() {
|
|
135
|
-
return writesOf("rollback")
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return { mockEntityManager, fakeTransaction }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Whether `manager` is the scoped view a `fakeTransaction` body received. */
|
|
144
|
-
const isTransactionManager = (manager) => typeof manager === "object" && manager !== null && TRANSACTION_MANAGERS.has(manager)
|
|
145
|
-
|
|
146
|
-
module.exports = { ...createEntityManagerKit(jestFn), createEntityManagerKit, isTransactionManager }
|
package/entity-manager.d.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/// <reference types="jest" />
|
|
2
|
-
import type { DeleteResult, EntityManager, InsertResult, UpdateResult } from "typeorm"
|
|
3
|
-
import type { MockOf } from "./mock"
|
|
4
|
-
|
|
5
|
-
/** A typed double of `EntityManager`: assignable wherever an `EntityManager` is required, every method a jest mock. */
|
|
6
|
-
export type MockEntityManager = MockOf<EntityManager>
|
|
7
|
-
|
|
8
|
-
/** An entity class. */
|
|
9
|
-
export type EntityClass<E extends object = object> = new (...args: never[]) => E
|
|
10
|
-
|
|
11
|
-
type Row<C extends EntityClass> = InstanceType<C>
|
|
12
|
-
type Write<T> = Partial<Omit<T, "raw">> & { readonly raw?: unknown }
|
|
13
|
-
|
|
14
|
-
/** The result type of each entity-taking `EntityManager` method, for the entity class `C`. */
|
|
15
|
-
export interface EntityAnswers<C extends EntityClass> {
|
|
16
|
-
findOne: Row<C> | null
|
|
17
|
-
findOneBy: Row<C> | null
|
|
18
|
-
findOneOrFail: Row<C>
|
|
19
|
-
findOneByOrFail: Row<C>
|
|
20
|
-
find: ReadonlyArray<Row<C>>
|
|
21
|
-
findBy: ReadonlyArray<Row<C>>
|
|
22
|
-
findAndCount: readonly [ReadonlyArray<Row<C>>, number]
|
|
23
|
-
findAndCountBy: readonly [ReadonlyArray<Row<C>>, number]
|
|
24
|
-
count: number
|
|
25
|
-
countBy: number
|
|
26
|
-
exists: boolean
|
|
27
|
-
existsBy: boolean
|
|
28
|
-
save: Row<C> | ReadonlyArray<Row<C>>
|
|
29
|
-
remove: Row<C> | ReadonlyArray<Row<C>>
|
|
30
|
-
softRemove: Row<C> | ReadonlyArray<Row<C>>
|
|
31
|
-
recover: Row<C> | ReadonlyArray<Row<C>>
|
|
32
|
-
insert: Write<InsertResult>
|
|
33
|
-
update: Write<UpdateResult>
|
|
34
|
-
upsert: Write<InsertResult>
|
|
35
|
-
delete: Write<DeleteResult>
|
|
36
|
-
softDelete: Write<UpdateResult>
|
|
37
|
-
restore: Write<UpdateResult>
|
|
38
|
-
increment: Write<UpdateResult>
|
|
39
|
-
decrement: Write<UpdateResult>
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** The methods a spec may stub as `[Entity, result]`. */
|
|
43
|
-
export type EntityMethod = keyof EntityAnswers<EntityClass>
|
|
44
|
-
|
|
45
|
-
type Pair<M extends EntityMethod, C extends EntityClass> = readonly [entity: C, result: EntityAnswers<C>[M]]
|
|
46
|
-
type Untyped = readonly [unknown, unknown]
|
|
47
|
-
|
|
48
|
-
/** One stub: `[Entity, result]` (`query`: `[sql, rows]`), or a list of such pairs answered in order. */
|
|
49
|
-
export type StubOf<M extends string, V> = M extends "query"
|
|
50
|
-
? readonly [sql: string, rows: unknown] | ReadonlyArray<readonly [sql: string, rows: unknown]>
|
|
51
|
-
: M extends EntityMethod
|
|
52
|
-
? V extends readonly [infer C extends EntityClass, unknown]
|
|
53
|
-
? Pair<M, C>
|
|
54
|
-
: V extends ReadonlyArray<readonly [infer C extends EntityClass, unknown]>
|
|
55
|
-
? ReadonlyArray<Pair<M, C>>
|
|
56
|
-
: never
|
|
57
|
-
: never
|
|
58
|
-
|
|
59
|
-
/** The stubs of a spec, checked entry by entry against the entity each one names. */
|
|
60
|
-
export type StubsOf<S> = { readonly [M in keyof S & string]: StubOf<M, S[M]> }
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* A strict `EntityManager` double. Describe what the subject should see, `{ findOne: [OrderEntity, order] }`; any method
|
|
64
|
-
* the subject calls that the spec did not stub throws an error naming the method, and a wrong entity or sql text throws
|
|
65
|
-
* too. Results are typed against the entity: `findOne: [OrderEntity, 5]` is a compile error.
|
|
66
|
-
*/
|
|
67
|
-
export declare function mockEntityManager<
|
|
68
|
-
const S extends { readonly [M in string]?: Untyped | ReadonlyArray<Untyped> } = Record<never, never>,
|
|
69
|
-
>(stubs?: S & StubsOf<S>): MockEntityManager
|
|
70
|
-
|
|
71
|
-
/** A write made inside a transaction. */
|
|
72
|
-
export interface TransactionWrite {
|
|
73
|
-
readonly method: string
|
|
74
|
-
readonly args: ReadonlyArray<unknown>
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The transaction attached to a double by `fakeTransaction`. */
|
|
78
|
-
export interface FakeTransaction<M extends MockEntityManager = MockEntityManager> {
|
|
79
|
-
/** The double whose `transaction(work)` runs `work`. */
|
|
80
|
-
readonly em: M
|
|
81
|
-
/** How each transaction ended, in call order. */
|
|
82
|
-
readonly outcomes: ReadonlyArray<"commit" | "rollback" | "pending">
|
|
83
|
-
/** Number of transactions that committed. */
|
|
84
|
-
readonly commits: number
|
|
85
|
-
/** Number of transactions that rolled back (the callback threw; the error was rethrown). */
|
|
86
|
-
readonly rollbacks: number
|
|
87
|
-
/** The writes of the transactions that committed. */
|
|
88
|
-
readonly committedWrites: ReadonlyArray<TransactionWrite>
|
|
89
|
-
/** The writes of the transactions that rolled back: what a real rollback would have discarded. */
|
|
90
|
-
readonly rolledBackWrites: ReadonlyArray<TransactionWrite>
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Makes `em.transaction(work)` run `work` with a scoped view whose queryRunner stays active until commit or rollback. */
|
|
94
|
-
export declare function fakeTransaction<M extends MockEntityManager = MockEntityManager>(em?: M): FakeTransaction<M>
|
|
95
|
-
|
|
96
|
-
/** Whether `manager` is the view a `fakeTransaction` body received (used by `recordingEventBus` and `recordingQueueOutbox`). */
|
|
97
|
-
export declare function isTransactionManager(manager: unknown): boolean
|
|
98
|
-
|
|
99
|
-
/** The kit over a function factory, for callers that bring their own (used by the package spec). */
|
|
100
|
-
export declare function createEntityManagerKit(makeFn: (implementation?: (...args: never[]) => unknown) => unknown): {
|
|
101
|
-
mockEntityManager: typeof mockEntityManager
|
|
102
|
-
fakeTransaction: typeof fakeTransaction
|
|
103
|
-
}
|
package/event-bus.cjs
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
"use strict"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `recordingEventBus()` -- an `EventBus` double that records what a domain service publishes, so a spec asserts on `bus.events`.
|
|
5
|
-
*
|
|
6
|
-
* `publish(event, tx)` records the event with the manager it was written through. `events` is what the outbox would hold: one
|
|
7
|
-
* event per (event name, event id), like the real unique key, so a duplicate publication keeps one. `writes` is every call, duplicates
|
|
8
|
-
* included, and `entries` adds `inTransaction`: whether `tx` was the view a `fakeTransaction` body received, so a spec proves an event
|
|
9
|
-
* is published in the transaction of the change that caused it. The name of an event is the static `eventName` of its class.
|
|
10
|
-
*
|
|
11
|
-
* The read side is scripted: `setPendingRetries(count)` sets what `pendingRetries(eventClass)` answers, `deadLetters(eventClass)`
|
|
12
|
-
* returns what `queueDeadLetters(...letters)` filled, `requeue(id)` records the id, and `failNext(operation, error)` makes the next
|
|
13
|
-
* call of that operation reject once, so a spec drives the failure path.
|
|
14
|
-
*/
|
|
15
|
-
const { isTransactionManager } = require("./entity-manager.cjs")
|
|
16
|
-
|
|
17
|
-
const nameOf = (event) => event.constructor.eventName
|
|
18
|
-
|
|
19
|
-
function recordingEventBus() {
|
|
20
|
-
const written = []
|
|
21
|
-
const letters = []
|
|
22
|
-
const requeued = []
|
|
23
|
-
const failures = new Map()
|
|
24
|
-
let pending = 0
|
|
25
|
-
const failIfScripted = (operation) => {
|
|
26
|
-
if (!failures.has(operation)) return
|
|
27
|
-
const error = failures.get(operation)
|
|
28
|
-
failures.delete(operation)
|
|
29
|
-
throw error
|
|
30
|
-
}
|
|
31
|
-
return {
|
|
32
|
-
async publish(event, tx) {
|
|
33
|
-
failIfScripted("publish")
|
|
34
|
-
written.push({ event, tx, inTransaction: isTransactionManager(tx) })
|
|
35
|
-
},
|
|
36
|
-
async pendingRetries() {
|
|
37
|
-
failIfScripted("pendingRetries")
|
|
38
|
-
return pending
|
|
39
|
-
},
|
|
40
|
-
async deadLetters(eventClass) {
|
|
41
|
-
failIfScripted("deadLetters")
|
|
42
|
-
return letters.filter((letter) => letter.eventName === eventClass.eventName)
|
|
43
|
-
},
|
|
44
|
-
async requeue(id) {
|
|
45
|
-
failIfScripted("requeue")
|
|
46
|
-
requeued.push(id)
|
|
47
|
-
},
|
|
48
|
-
/** The events the outbox would hold: the first publication of each (event name, event id). */
|
|
49
|
-
get events() {
|
|
50
|
-
const seen = new Set()
|
|
51
|
-
return written
|
|
52
|
-
.filter(({ event }) => {
|
|
53
|
-
const key = `${nameOf(event)}\u0000${event.eventId}`
|
|
54
|
-
if (seen.has(key)) return false
|
|
55
|
-
seen.add(key)
|
|
56
|
-
return true
|
|
57
|
-
})
|
|
58
|
-
.map(({ event }) => event)
|
|
59
|
-
},
|
|
60
|
-
/** Every publication, in order: `{ event, tx, inTransaction }`. */
|
|
61
|
-
get entries() {
|
|
62
|
-
return [...written]
|
|
63
|
-
},
|
|
64
|
-
/** Every event published, duplicates included. */
|
|
65
|
-
get writes() {
|
|
66
|
-
return written.map(({ event }) => event)
|
|
67
|
-
},
|
|
68
|
-
/** True when something was published and every publication went through a transaction manager. */
|
|
69
|
-
get allInTransaction() {
|
|
70
|
-
return written.length > 0 && written.every((entry) => entry.inTransaction)
|
|
71
|
-
},
|
|
72
|
-
/** The stored events of one class. */
|
|
73
|
-
eventsOf(eventClass) {
|
|
74
|
-
return this.events.filter((event) => nameOf(event) === eventClass.eventName)
|
|
75
|
-
},
|
|
76
|
-
/** Sets the answer of `pendingRetries`. */
|
|
77
|
-
setPendingRetries(count) {
|
|
78
|
-
pending = count
|
|
79
|
-
},
|
|
80
|
-
/** Adds dead letters `deadLetters` hands out. */
|
|
81
|
-
queueDeadLetters(...items) {
|
|
82
|
-
letters.push(...items)
|
|
83
|
-
},
|
|
84
|
-
/** The ids passed to `requeue`, in order. */
|
|
85
|
-
get requeued() {
|
|
86
|
-
return [...requeued]
|
|
87
|
-
},
|
|
88
|
-
/** Makes the next call of `operation` reject with `error`, once. */
|
|
89
|
-
failNext(operation, error) {
|
|
90
|
-
failures.set(operation, error)
|
|
91
|
-
},
|
|
92
|
-
clear() {
|
|
93
|
-
for (const list of [written, letters, requeued]) list.length = 0
|
|
94
|
-
pending = 0
|
|
95
|
-
failures.clear()
|
|
96
|
-
},
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
module.exports = { recordingEventBus }
|
package/event-bus.d.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/** An event as the bus port takes it: a class instance with a stable id; its name is the static `eventName` of its class. */
|
|
2
|
-
export interface RecordedEvent {
|
|
3
|
-
readonly eventId: string
|
|
4
|
-
}
|
|
5
|
-
/** The static side of an event class. */
|
|
6
|
-
export interface RecordedEventClass {
|
|
7
|
-
readonly eventName: string
|
|
8
|
-
}
|
|
9
|
-
/** One recorded publication. */
|
|
10
|
-
export interface EventWrite<E extends RecordedEvent = RecordedEvent> {
|
|
11
|
-
readonly event: E
|
|
12
|
-
readonly tx: unknown
|
|
13
|
-
/** Whether `tx` was the view a `fakeTransaction` body received. */
|
|
14
|
-
readonly inTransaction: boolean
|
|
15
|
-
}
|
|
16
|
-
/** A dead letter as the bus port returns it. */
|
|
17
|
-
export interface RecordedDeadLetter {
|
|
18
|
-
readonly id: string
|
|
19
|
-
readonly eventName: string
|
|
20
|
-
readonly eventId: string
|
|
21
|
-
readonly reason: string
|
|
22
|
-
readonly attempts: number
|
|
23
|
-
}
|
|
24
|
-
/** The bus operations `failNext` can make reject. */
|
|
25
|
-
export type EventBusOperation = "publish" | "pendingRetries" | "deadLetters" | "requeue"
|
|
26
|
-
|
|
27
|
-
/** An `EventBus` double that records publications and scripts the read side. */
|
|
28
|
-
export interface RecordingEventBus<E extends RecordedEvent = RecordedEvent> {
|
|
29
|
-
publish(event: E, tx: unknown): Promise<void>
|
|
30
|
-
pendingRetries(eventClass: RecordedEventClass): Promise<number>
|
|
31
|
-
deadLetters(eventClass: RecordedEventClass): Promise<ReadonlyArray<RecordedDeadLetter>>
|
|
32
|
-
requeue(id: string): Promise<void>
|
|
33
|
-
/** What the outbox would hold: the first publication of each (event name, event id). */
|
|
34
|
-
readonly events: ReadonlyArray<E>
|
|
35
|
-
/** Every publication with its manager, duplicates included. */
|
|
36
|
-
readonly entries: ReadonlyArray<EventWrite<E>>
|
|
37
|
-
/** Every event published, duplicates included. */
|
|
38
|
-
readonly writes: ReadonlyArray<E>
|
|
39
|
-
/** True when something was published and every publication went through a `fakeTransaction` manager. */
|
|
40
|
-
readonly allInTransaction: boolean
|
|
41
|
-
eventsOf(eventClass: RecordedEventClass): ReadonlyArray<E>
|
|
42
|
-
/** Sets the answer of `pendingRetries`. */
|
|
43
|
-
setPendingRetries(count: number): void
|
|
44
|
-
/** Adds dead letters `deadLetters` hands out. */
|
|
45
|
-
queueDeadLetters(...items: ReadonlyArray<RecordedDeadLetter>): void
|
|
46
|
-
/** The ids passed to `requeue`. */
|
|
47
|
-
readonly requeued: ReadonlyArray<string>
|
|
48
|
-
/** Makes the next call of `operation` reject with `error`, once. */
|
|
49
|
-
failNext(operation: EventBusOperation, error: Error): void
|
|
50
|
-
clear(): void
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The event bus double. */
|
|
54
|
-
export declare function recordingEventBus<E extends RecordedEvent = RecordedEvent>(): RecordingEventBus<E>
|
package/ids.cjs
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"use strict"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `fakeIds()` -- the test double for the platform ids port (`{ next(): string }`; the production adapter is
|
|
5
|
-
* `randomUUID()` behind the port). Ids are deterministic and UUID-shaped, so a spec can assert on the exact persisted id:
|
|
6
|
-
* the first is `00000000-0000-4000-8000-000000000001`, the second `...0002`, and so on. A `prefix` (1 to 8 hex digits)
|
|
7
|
-
* changes the leading group so two generators in one spec never collide.
|
|
8
|
-
*/
|
|
9
|
-
class FakeIds {
|
|
10
|
-
/** @param {string} [prefix] - 1 to 8 hex digits for the first group; default "00000000". */
|
|
11
|
-
constructor(prefix = "00000000") {
|
|
12
|
-
if (!/^[0-9a-f]{1,8}$/i.test(prefix)) throw new Error("fakeIds: the prefix is 1 to 8 hex digits")
|
|
13
|
-
this._prefix = prefix.padStart(8, "0").toLowerCase()
|
|
14
|
-
this._issued = []
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** The next id. */
|
|
18
|
-
next() {
|
|
19
|
-
const n = String(this._issued.length + 1).padStart(12, "0")
|
|
20
|
-
const id = `${this._prefix}-0000-4000-8000-${n}`
|
|
21
|
-
this._issued.push(id)
|
|
22
|
-
return id
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Every id handed out so far, in order. */
|
|
26
|
-
get issued() {
|
|
27
|
-
return [...this._issued]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Starts the sequence over. */
|
|
31
|
-
reset() {
|
|
32
|
-
this._issued = []
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** @param {string} [prefix] */
|
|
37
|
-
function fakeIds(prefix) {
|
|
38
|
-
return new FakeIds(prefix)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
module.exports = { fakeIds, FakeIds }
|
package/ids.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/** The platform ids port a spec injects `fakeIds()` for: `{ next(): string }`. */
|
|
2
|
-
export declare class FakeIds {
|
|
3
|
-
constructor(prefix?: string)
|
|
4
|
-
/** The next deterministic UUID-shaped id. */
|
|
5
|
-
next(): string
|
|
6
|
-
/** Every id handed out so far, in order. */
|
|
7
|
-
readonly issued: ReadonlyArray<string>
|
|
8
|
-
/** Starts the sequence over. */
|
|
9
|
-
reset(): void
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** A deterministic id generator: `00000000-0000-4000-8000-000000000001`, `...0002`, ... */
|
|
13
|
-
export declare function fakeIds(prefix?: string): FakeIds
|
package/inbox.cjs
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
"use strict"
|
|
2
|
-
|
|
3
|
-
const { isDeepStrictEqual } = require("node:util")
|
|
4
|
-
const { isTransactionManager } = require("./entity-manager.cjs")
|
|
5
|
-
const CLIENT_SWEEP_BATCH = 500
|
|
6
|
-
|
|
7
|
-
/** The fake carries the production refusal code without depending on an application's error family. */
|
|
8
|
-
const inboxError = (code) => Object.assign(new Error(code), { code })
|
|
9
|
-
|
|
10
|
-
/** Clone JSON values directly so toJSON cannot change the request or the stored outcome. */
|
|
11
|
-
const snapshot = (value, ancestors = new Set()) => {
|
|
12
|
-
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
|
13
|
-
if (typeof value === "number" && Number.isFinite(value)) return value === 0 ? 0 : value
|
|
14
|
-
if (typeof value !== "object" || ancestors.has(value)) throw inboxError("IDEMPOTENCY_REQUEST_INVALID")
|
|
15
|
-
ancestors.add(value)
|
|
16
|
-
let result
|
|
17
|
-
if (Array.isArray(value)) result = Array.from(value, (member) => snapshot(member, ancestors))
|
|
18
|
-
else {
|
|
19
|
-
const prototype = Object.getPrototypeOf(value)
|
|
20
|
-
if (prototype !== Object.prototype && prototype !== null) throw inboxError("IDEMPOTENCY_REQUEST_INVALID")
|
|
21
|
-
result = Object.fromEntries(Object.entries(value).map(([key, member]) => [key, snapshot(member, ancestors)]))
|
|
22
|
-
}
|
|
23
|
-
ancestors.delete(value)
|
|
24
|
-
return result
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* `fakeInbox(clock?)` records delivery and client claims, so a spec asserts deduplication and the transaction passed by
|
|
29
|
-
* its service. Client calls replay JSON outcomes; `clock` gives retention tests a driven claim timestamp.
|
|
30
|
-
*
|
|
31
|
-
* `claim(source, eventId)` answers `true` for the first call of a (source, eventId) pair and `false` for every later one, like the
|
|
32
|
-
* real unique key; `release(source, eventId)` gives an ordinary delivery claim back while preserving client outcomes.
|
|
33
|
-
* `claims` is every claim in order, `claimed` the pairs that are held now, `released` every release, `seen(source, eventId)` marks a
|
|
34
|
-
* pair as already delivered (a redelivery), and `failNext(operation, error)` makes the next call of that operation reject once, so
|
|
35
|
-
* a spec drives the failure path.
|
|
36
|
-
*/
|
|
37
|
-
function fakeInbox(clock) {
|
|
38
|
-
const held = new Set()
|
|
39
|
-
const claims = []
|
|
40
|
-
const released = []
|
|
41
|
-
const clients = new Map()
|
|
42
|
-
const clientEntries = []
|
|
43
|
-
const sweeps = []
|
|
44
|
-
const failures = new Map()
|
|
45
|
-
const keyOf = (source, eventId) => `${source}\u0000${eventId}`
|
|
46
|
-
const failIfScripted = (operation) => {
|
|
47
|
-
if (!failures.has(operation)) return
|
|
48
|
-
const error = failures.get(operation)
|
|
49
|
-
failures.delete(operation)
|
|
50
|
-
throw error
|
|
51
|
-
}
|
|
52
|
-
return {
|
|
53
|
-
async claim(source, eventId) {
|
|
54
|
-
failIfScripted("claim")
|
|
55
|
-
claims.push({ source, eventId })
|
|
56
|
-
const key = keyOf(source, eventId)
|
|
57
|
-
if (held.has(key)) return false
|
|
58
|
-
held.add(key)
|
|
59
|
-
return true
|
|
60
|
-
},
|
|
61
|
-
async release(source, eventId) {
|
|
62
|
-
failIfScripted("release")
|
|
63
|
-
released.push({ source, eventId })
|
|
64
|
-
const key = keyOf(source, eventId)
|
|
65
|
-
if (!clients.has(key)) held.delete(key)
|
|
66
|
-
},
|
|
67
|
-
async runClient(request, tx, effect) {
|
|
68
|
-
failIfScripted("runClient")
|
|
69
|
-
const { principalId, operation, idempotencyKey } = request
|
|
70
|
-
if (!principalId.trim() || principalId.includes(":") || !operation.trim() || operation.includes(":")
|
|
71
|
-
|| !idempotencyKey.trim() || idempotencyKey.length > 64) {
|
|
72
|
-
throw inboxError("IDEMPOTENCY_REQUEST_INVALID")
|
|
73
|
-
}
|
|
74
|
-
const source = `client:${principalId}:${operation}`
|
|
75
|
-
const key = keyOf(source, idempotencyKey)
|
|
76
|
-
const body = snapshot(request.request)
|
|
77
|
-
claims.push({ source, eventId: idempotencyKey })
|
|
78
|
-
clientEntries.push({ source, eventId: idempotencyKey, tx, inTransaction: isTransactionManager(tx) })
|
|
79
|
-
const previous = clients.get(key)
|
|
80
|
-
if (previous !== undefined) {
|
|
81
|
-
if (!isDeepStrictEqual(previous.request, body)) throw inboxError("IDEMPOTENCY_KEY_REUSED")
|
|
82
|
-
return snapshot(await previous.result)
|
|
83
|
-
}
|
|
84
|
-
if (held.has(key)) throw inboxError("IDEMPOTENCY_KEY_REUSED")
|
|
85
|
-
const entry = { request: body, claimedAt: clock === undefined ? new Date(0) : clock.now(), completed: false }
|
|
86
|
-
held.add(key)
|
|
87
|
-
clients.set(key, entry)
|
|
88
|
-
entry.result = Promise.resolve().then(effect).then((value) => {
|
|
89
|
-
let stored
|
|
90
|
-
try {
|
|
91
|
-
stored = snapshot(value)
|
|
92
|
-
} catch (cause) {
|
|
93
|
-
throw Object.assign(inboxError("INBOX_OUTCOME_INVALID"), { cause })
|
|
94
|
-
}
|
|
95
|
-
entry.completed = true
|
|
96
|
-
return stored
|
|
97
|
-
}).catch((error) => {
|
|
98
|
-
if (clients.get(key) === entry) {
|
|
99
|
-
clients.delete(key)
|
|
100
|
-
held.delete(key)
|
|
101
|
-
}
|
|
102
|
-
throw error
|
|
103
|
-
})
|
|
104
|
-
return snapshot(await entry.result)
|
|
105
|
-
},
|
|
106
|
-
async sweepClients(cutoff, tx) {
|
|
107
|
-
sweeps.push({ cutoff: new Date(cutoff), tx, inTransaction: isTransactionManager(tx) })
|
|
108
|
-
failIfScripted("sweepClients")
|
|
109
|
-
let swept = 0
|
|
110
|
-
for (const [key, entry] of clients) {
|
|
111
|
-
if (swept === CLIENT_SWEEP_BATCH) break
|
|
112
|
-
if (!entry.completed || entry.claimedAt >= cutoff) continue
|
|
113
|
-
clients.delete(key)
|
|
114
|
-
held.delete(key)
|
|
115
|
-
swept += 1
|
|
116
|
-
}
|
|
117
|
-
return swept
|
|
118
|
-
},
|
|
119
|
-
/** Marks a pair as already claimed, so the next `claim` of it answers `false` (a redelivery). */
|
|
120
|
-
seen(source, eventId) {
|
|
121
|
-
held.add(keyOf(source, eventId))
|
|
122
|
-
},
|
|
123
|
-
/** Makes the next call of `operation` reject with `error`, once. */
|
|
124
|
-
failNext(operation, error) {
|
|
125
|
-
failures.set(operation, error)
|
|
126
|
-
},
|
|
127
|
-
/** Every `claim`, in order, duplicates included. */
|
|
128
|
-
get claims() {
|
|
129
|
-
return [...claims]
|
|
130
|
-
},
|
|
131
|
-
/** The pairs held now: claimed and not released. */
|
|
132
|
-
get claimed() {
|
|
133
|
-
return [...held].map((key) => {
|
|
134
|
-
const [source, eventId] = key.split("\u0000")
|
|
135
|
-
return { source, eventId }
|
|
136
|
-
})
|
|
137
|
-
},
|
|
138
|
-
/** Every `release`, in order. */
|
|
139
|
-
get released() {
|
|
140
|
-
return [...released]
|
|
141
|
-
},
|
|
142
|
-
/** Every client claim, including repeats, with the domain transaction it used. */
|
|
143
|
-
get clientEntries() {
|
|
144
|
-
return [...clientEntries]
|
|
145
|
-
},
|
|
146
|
-
/** Every retention attempt with its cutoff and domain transaction, including a scripted failure. */
|
|
147
|
-
get sweeps() {
|
|
148
|
-
return [...sweeps]
|
|
149
|
-
},
|
|
150
|
-
clear() {
|
|
151
|
-
held.clear()
|
|
152
|
-
claims.length = 0
|
|
153
|
-
released.length = 0
|
|
154
|
-
failures.clear()
|
|
155
|
-
clients.clear()
|
|
156
|
-
clientEntries.length = 0
|
|
157
|
-
sweeps.length = 0
|
|
158
|
-
},
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
module.exports = { fakeInbox }
|