@starci/jest-preset 2.3.5 → 2.3.6

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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.6 - 2026-10-25
4
+ - Added: the fake inbox records client operations and sweeps (`clientEntries`, `sweeps`) and the entity-manager and index doubles follow the idempotent client inbox of the alpha.31 cohort; the lite and full example back ends spec against them.
5
+
3
6
  ## 2.3.5 - 2026-10-23
4
7
  - Changed: the preset sets no `testTimeout` (the jest default applies; a world spec sets its own), owner ruling 2026-10-22: no loosening.
5
8
  - Changed: the type probes assert their compiler diagnostics (file, line, code) from their own rejected fixtures instead of inline ts directives; the generated metadata anchor carries no coverage-exclusion comment; a spec refuses any inline suppression in the package.
package/README.md CHANGED
@@ -33,7 +33,7 @@ and the exclusions. A repository has no other jest setting to tune.
33
33
  runs on code that does not type-check.
34
34
  - **Coverage of the measured logic, per-file 100.** `collectCoverageFrom` is one glob per `coverage.roots` entry covering `<root>/**/*.{<roles>}.ts`
35
35
  (the `ruleParams.be.logicRoles` of the rendered scope), minus its `excludes`, `dist` and `coverage`, and `coverageThreshold` holds
36
- `{ lines: 100, branches: 100, functions: 100, statements: 100 }` on each of those globs — a glob key applies to each file on its own, so every file of a
36
+ `{ lines: 100, branches: 100, functions: 100, statements: 100 }` on one union glob of those roots. Jest applies a glob threshold to each file on its own, so every file of a
37
37
  `ruleParams.be.logicRoles` role inside a measured slot must reach 100 on every metric (an average never passes). The
38
38
  managed `test` script runs the unit project with `--coverage` and fails below it. Coverage is measured by v8, which counts the
39
39
  real source. The unit project overlays `isolatedModules: true`, `importHelpers: true` and `sourceMap: true` on the repository tsconfig, so ts-jest
@@ -141,8 +141,21 @@ await inbox.claim("sepay", "e-1") // true the first time, false for the s
141
141
  await inbox.release("sepay", "e-1") // gives the claim back: the next claim answers true
142
142
  inbox.seen("sepay", "e-2") // a redelivery: the next claim of the pair answers false
143
143
  inbox.claims; inbox.claimed; inbox.released; inbox.failNext("claim", new Error("db down")); inbox.clear()
144
+
145
+ const clientInbox = fakeInbox(clock) // the driven clock stamps client claims for retention tests
146
+ await clientInbox.runClient({ principalId: "p-1", operation: "checkout.place", idempotencyKey: "key-1", request: { quantity: 2 } }, tx, async () => ({ kind: "ok", value: { orderId: "o-1" } }))
147
+ clientInbox.clientEntries // every client claim with its tx and inTransaction flag
148
+ await clientInbox.sweepClients(cutoff, tx)
149
+ clientInbox.sweeps // every sweep attempt's cutoff, tx and inTransaction flag, failures included
144
150
  ```
145
151
 
152
+ Client repeats replay the stored JSON outcome, including a refusal; a different request for the same namespace and key
153
+ rejects with `IDEMPOTENCY_KEY_REUSED`. Concurrent repeats wait for the first effect. A rejected effect gives its claim back.
154
+ Delivery `release` preserves client receipts and their outcomes.
155
+ The double records the transaction passed to it; a later outer transaction rollback needs real database integration proof.
156
+ Without a clock, client claims use the Unix epoch. The sweep removes one bounded batch of completed expired client claims
157
+ and preserves delivery claims.
158
+
146
159
  ## `builder`, `fakeIds` and the Outcome matchers
147
160
 
148
161
  ```ts
@@ -85,6 +85,7 @@ function createEntityManagerKit(makeFn) {
85
85
  const scopedFor = (run) => {
86
86
  const view = new Proxy(em, {
87
87
  get(target, property, receiver) {
88
+ if (property === "queryRunner") return { get isTransactionActive() { return run.outcome === "pending" } }
88
89
  const value = Reflect.get(target, property, receiver)
89
90
  if (property === "transaction") return (...args) => args[args.length - 1](view)
90
91
  if (typeof property !== "string" || typeof value !== "function") return value
@@ -90,7 +90,7 @@ export interface FakeTransaction<M extends MockEntityManager = MockEntityManager
90
90
  readonly rolledBackWrites: ReadonlyArray<TransactionWrite>
91
91
  }
92
92
 
93
- /** Makes `em.transaction(work)` run `work` with a scoped view of the double and record commit, rollback and writes. */
93
+ /** Makes `em.transaction(work)` run `work` with a scoped view whose queryRunner stays active until commit or rollback. */
94
94
  export declare function fakeTransaction<M extends MockEntityManager = MockEntityManager>(em?: M): FakeTransaction<M>
95
95
 
96
96
  /** Whether `manager` is the view a `fakeTransaction` body received (used by `recordingEventBus` and `recordingQueueOutbox`). */
package/inbox.cjs CHANGED
@@ -1,19 +1,46 @@
1
1
  "use strict"
2
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
+
3
27
  /**
4
- * `fakeInbox()` -- the twin of `recordingOutbox()` for the delivery side: an `Inbox` double with the behaviour of the real
5
- * claim, so a spec asserts what a consumer or a signed webhook did with a delivery instead of stubbing `claim` by hand.
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.
6
30
  *
7
31
  * `claim(source, eventId)` answers `true` for the first call of a (source, eventId) pair and `false` for every later one, like the
8
- * real unique key; `release(source, eventId)` gives the claim back, so the next claim of the pair answers `true` again.
32
+ * real unique key; `release(source, eventId)` gives an ordinary delivery claim back while preserving client outcomes.
9
33
  * `claims` is every claim in order, `claimed` the pairs that are held now, `released` every release, `seen(source, eventId)` marks a
10
34
  * pair as already delivered (a redelivery), and `failNext(operation, error)` makes the next call of that operation reject once, so
11
35
  * a spec drives the failure path.
12
36
  */
13
- function fakeInbox() {
37
+ function fakeInbox(clock) {
14
38
  const held = new Set()
15
39
  const claims = []
16
40
  const released = []
41
+ const clients = new Map()
42
+ const clientEntries = []
43
+ const sweeps = []
17
44
  const failures = new Map()
18
45
  const keyOf = (source, eventId) => `${source}\u0000${eventId}`
19
46
  const failIfScripted = (operation) => {
@@ -34,7 +61,60 @@ function fakeInbox() {
34
61
  async release(source, eventId) {
35
62
  failIfScripted("release")
36
63
  released.push({ source, eventId })
37
- held.delete(keyOf(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
38
118
  },
39
119
  /** Marks a pair as already claimed, so the next `claim` of it answers `false` (a redelivery). */
40
120
  seen(source, eventId) {
@@ -59,11 +139,22 @@ function fakeInbox() {
59
139
  get released() {
60
140
  return [...released]
61
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
+ },
62
150
  clear() {
63
151
  held.clear()
64
152
  claims.length = 0
65
153
  released.length = 0
66
154
  failures.clear()
155
+ clients.clear()
156
+ clientEntries.length = 0
157
+ sweeps.length = 0
67
158
  },
68
159
  }
69
160
  }
package/inbox.d.ts CHANGED
@@ -4,14 +4,39 @@ export interface InboxEntry {
4
4
  readonly eventId: string
5
5
  }
6
6
  /** The inbox operations `failNext` can make reject. */
7
- export type InboxOperation = "claim" | "release"
7
+ export type InboxOperation = "claim" | "release" | "runClient" | "sweepClients"
8
+
9
+ /** The client operation namespace, exact key and JSON request whose outcome the inbox stores. */
10
+ export interface InboxClientOperation {
11
+ readonly principalId: string
12
+ readonly operation: string
13
+ readonly idempotencyKey: string
14
+ readonly request: unknown
15
+ }
16
+
17
+ /** A client claim with the manager passed by its caller. */
18
+ export interface InboxClientEntry extends InboxEntry {
19
+ readonly tx: unknown
20
+ readonly inTransaction: boolean
21
+ }
22
+
23
+ /** One retention sweep with the cutoff instant and manager passed by its caller. */
24
+ export interface InboxSweep {
25
+ readonly cutoff: Date
26
+ readonly tx: unknown
27
+ readonly inTransaction: boolean
28
+ }
8
29
 
9
30
  /** An `Inbox` double with the behaviour of the real claim. */
10
31
  export interface FakeInbox {
11
32
  /** True for the first claim of a (source, eventId) pair, false for every later one until it is released. */
12
33
  claim(source: string, eventId: string): Promise<boolean>
13
- /** Gives the claim back, so the next claim of the pair answers true. */
34
+ /** Gives an ordinary delivery claim back while preserving client receipts and their outcomes. */
14
35
  release(source: string, eventId: string): Promise<void>
36
+ /** Runs once for the client namespace and key, replays the JSON outcome and refuses a different body. */
37
+ runClient<T>(request: InboxClientOperation, tx: unknown, effect: () => Promise<T>): Promise<T>
38
+ /** Deletes at most one batch of completed client claims older than the cutoff. */
39
+ sweepClients(cutoff: Date, tx: unknown): Promise<number>
15
40
  /** Marks a pair as already claimed (a redelivery). */
16
41
  seen(source: string, eventId: string): void
17
42
  /** Makes the next call of `operation` reject with `error`, once. */
@@ -22,8 +47,12 @@ export interface FakeInbox {
22
47
  readonly claimed: ReadonlyArray<InboxEntry>
23
48
  /** Every release, in order. */
24
49
  readonly released: ReadonlyArray<InboxEntry>
50
+ /** Every client claim with its manager; this double records transaction ownership, not database commit semantics. */
51
+ readonly clientEntries: ReadonlyArray<InboxClientEntry>
52
+ /** Every sweep attempt with its cutoff and transaction, including a scripted failure. */
53
+ readonly sweeps: ReadonlyArray<InboxSweep>
25
54
  clear(): void
26
55
  }
27
56
 
28
- /** The inbox double. */
29
- export declare function fakeInbox(): FakeInbox
57
+ /** The inbox double. Pass a driven clock to test retention; omitted clocks stamp client claims at the Unix epoch. */
58
+ export declare function fakeInbox(clock?: { now(): Date }): FakeInbox
package/index.cjs CHANGED
@@ -37,6 +37,14 @@ function collectCoverageFrom(coverage) {
37
37
  return [...coverage.roots.map((root) => rootGlob(root, coverage.roles)), ...[...coverage.excludes, ...COVERAGE_EXCLUDES].map((glob) => `!${glob}`)]
38
38
  }
39
39
 
40
+ /** One union glob keeps Jest's per-file checks while a scoped map may contain files from only one measured root. */
41
+ function coverageThresholdOf(coverage, root) {
42
+ const rootGlobs = coverage.roots.map((entry) => rootGlob(entry, coverage.roles)).filter((glob) => hasCoverageSubjects(root, glob))
43
+ if (rootGlobs.length === 0) return {}
44
+ const glob = rootGlobs.length === 1 ? rootGlobs[0] : `{${rootGlobs.join(",")}}`
45
+ return { [`./${glob}`]: { ...COVERAGE_THRESHOLD } }
46
+ }
47
+
40
48
  /**
41
49
  * Sonar `sonar.exclusions`: specs, e2e specs, dist and coverage output are not analysed at all. The list lives in sonar-exclusions.cjs, a
42
50
  * require-free module @starci/hfs also ships byte for byte (packages/hfs/runtime, scripts/hfs/sync-runtime.mjs), so `starci app scaffold`
@@ -236,7 +244,7 @@ function starciJestConfig(options) {
236
244
  ...(shard ? {} : {
237
245
  collectCoverageFrom: collectCoverageFrom(coverage),
238
246
  // A glob key is applied to every matching file on its own: each measured file must reach 100, not the average.
239
- coverageThreshold: Object.fromEntries(coverage.roots.map((root) => rootGlob(root, coverage.roles)).filter((glob) => hasCoverageSubjects(process.cwd(), glob)).map((glob) => [`./${glob}`, { ...COVERAGE_THRESHOLD }])),
247
+ coverageThreshold: coverageThresholdOf(coverage, root),
240
248
  }),
241
249
  coverageDirectory: "coverage",
242
250
  // lcov is what Sonar imports (coverage/lcov.info); text-summary and text are what the developer reads; a shard writes the json the merge reads.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/jest-preset",
3
- "version": "2.3.5",
3
+ "version": "2.3.6",
4
4
  "description": "The StarCi jest config for Nest repositories (unit, integration, e2e and contract projects, ts-jest with diagnostics off, per-file 100 coverage of *.service.ts only) and the one shared unit-test double kit: typed mock<T>(), mockEntityManager(), fakeTransaction(), fakeCache(), fakeLock(), recordingEventBus(), recordingQueueOutbox(), fakeInbox(), FakeClock, fakeIds(), builder() and the Outcome matchers toBeRefused/toSucceedWith.",
5
5
  "license": "MIT",
6
6
  "private": false,