@porulle/core 0.35.1 → 0.37.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/dist/kernel/hooks/executor.d.ts.map +1 -1
- package/dist/kernel/hooks/executor.js +38 -9
- package/dist/kernel/hooks/failures.d.ts +27 -0
- package/dist/kernel/hooks/failures.d.ts.map +1 -0
- package/dist/kernel/hooks/failures.js +31 -0
- package/dist/test-utils/create-two-connection-adapter.d.ts +33 -0
- package/dist/test-utils/create-two-connection-adapter.d.ts.map +1 -0
- package/dist/test-utils/create-two-connection-adapter.js +40 -0
- package/dist/test-utils/hook-failure-setup.d.ts +2 -0
- package/dist/test-utils/hook-failure-setup.d.ts.map +1 -0
- package/dist/test-utils/hook-failure-setup.js +23 -0
- package/dist/test-utils/hook-failures.d.ts +22 -0
- package/dist/test-utils/hook-failures.d.ts.map +1 -0
- package/dist/test-utils/hook-failures.js +71 -0
- package/dist/testing.d.ts +3 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +12 -0
- package/package.json +3 -3
- package/src/kernel/hooks/executor.ts +39 -9
- package/src/kernel/hooks/failures.ts +55 -0
- package/src/test-utils/create-two-connection-adapter.ts +55 -0
- package/src/test-utils/hook-failure-setup.ts +29 -0
- package/src/test-utils/hook-failures.ts +84 -0
- package/src/testing.ts +15 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../../src/kernel/hooks/executor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../../src/kernel/hooks/executor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAKpF,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,UAAU,GAAG,UAAU,CAKzE;AA4DD,wBAAsB,cAAc,CAAC,CAAC,EACpC,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,EACtB,IAAI,EAAE,CAAC,EACP,SAAS,EAAE,aAAa,EACxB,OAAO,EAAE,WAAW,GACnB,OAAO,CAAC,CAAC,CAAC,CAmBZ;AAED,wBAAsB,aAAa,CAAC,CAAC,EACnC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,EACrB,YAAY,EAAE,CAAC,GAAG,IAAI,EACtB,eAAe,EAAE,CAAC,EAClB,SAAS,EAAE,aAAa,EACxB,OAAO,EAAE,WAAW,EACpB,iBAAiB,GAAE,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC,KAAK,OAAqB,GAC/D,OAAO,CAAC,UAAU,CAAC,CAkErB"}
|
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
import { deferAfterCommit } from "./deferred.js";
|
|
2
|
+
import { reportHookFailure } from "./failures.js";
|
|
2
3
|
export function mergeHookReports(a, b) {
|
|
3
4
|
return {
|
|
4
5
|
errors: [...a.errors, ...b.errors],
|
|
5
6
|
hasErrors: a.hasErrors || b.hasErrors,
|
|
6
7
|
};
|
|
7
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* The context a hook marked `inTransaction: true` receives.
|
|
11
|
+
*
|
|
12
|
+
* `inTransaction` buys ORDERING on its own — the hook runs inline, before the commit. It does not
|
|
13
|
+
* make the hook's WRITES part of the transaction: `context.db` is the plugin db handle, and on a
|
|
14
|
+
* two-connection driver (Neon over HTTP, where every plain query is its own request) a write on
|
|
15
|
+
* that handle is not in the transaction and survives its rollback. Measured on the deployed Worker
|
|
16
|
+
* on 2026-09-15: an aborted write left `entity_exists = 0` and `pending_for_aborted = 1` — a row
|
|
17
|
+
* that rolled back announcing itself in the outbox whose entire purpose is committing with it.
|
|
18
|
+
*
|
|
19
|
+
* So the kernel hands such a hook a context it cannot get this wrong from: `db` IS the transaction.
|
|
20
|
+
* A plugin author writing the obvious thing lands inside the transaction, with nothing to remember.
|
|
21
|
+
*
|
|
22
|
+
* `tx` can still be null here — a marked hook also fires for a write performed outside any
|
|
23
|
+
* transaction, and `catalogHookContext` passes `tx: null` for one. Such a hook keeps the outside
|
|
24
|
+
* connection, because that is the only connection there is; handing it nothing would silently stop
|
|
25
|
+
* every non-transactional write from announcing itself, which is worse than the defect above.
|
|
26
|
+
*/
|
|
27
|
+
function inTransactionHookContext(context) {
|
|
28
|
+
if (context.tx == null)
|
|
29
|
+
return context;
|
|
30
|
+
return { ...context, db: context.tx };
|
|
31
|
+
}
|
|
8
32
|
/** Default hook timeout: 20 seconds */
|
|
9
33
|
const HOOK_TIMEOUT_MS = 20_000;
|
|
10
34
|
function withTimeout(promiseOrValue, timeoutMs, hookName) {
|
|
@@ -57,7 +81,7 @@ export async function runAfterHooks(hooks, originalData, committedResult, operat
|
|
|
57
81
|
result: committedResult,
|
|
58
82
|
operation,
|
|
59
83
|
context: runsInTransaction(hook)
|
|
60
|
-
? context
|
|
84
|
+
? inTransactionHookContext(context)
|
|
61
85
|
: { ...context, tx: null },
|
|
62
86
|
}), HOOK_TIMEOUT_MS, hookName);
|
|
63
87
|
if (runsInTransaction(hook)) {
|
|
@@ -65,10 +89,9 @@ export async function runAfterHooks(hooks, originalData, committedResult, operat
|
|
|
65
89
|
await runHook();
|
|
66
90
|
}
|
|
67
91
|
catch (error) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
});
|
|
92
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
93
|
+
errors.push({ hookName, message });
|
|
94
|
+
reportHookFailure({ hookName, message, deferred: false });
|
|
72
95
|
context.logger.error(`After-hook "${hookName}" failed`, {
|
|
73
96
|
error,
|
|
74
97
|
});
|
|
@@ -80,6 +103,13 @@ export async function runAfterHooks(hooks, originalData, committedResult, operat
|
|
|
80
103
|
await runHook();
|
|
81
104
|
}
|
|
82
105
|
catch (error) {
|
|
106
|
+
// Reported rather than collected: this runs after the commit, so the HookReport below has
|
|
107
|
+
// already been returned to the caller and there is nowhere else for this failure to go.
|
|
108
|
+
reportHookFailure({
|
|
109
|
+
hookName,
|
|
110
|
+
message: error instanceof Error ? error.message : String(error),
|
|
111
|
+
deferred: true,
|
|
112
|
+
});
|
|
83
113
|
context.logger.error(`After-commit hook "${hookName}" failed`, {
|
|
84
114
|
error,
|
|
85
115
|
});
|
|
@@ -90,10 +120,9 @@ export async function runAfterHooks(hooks, originalData, committedResult, operat
|
|
|
90
120
|
await runHook();
|
|
91
121
|
}
|
|
92
122
|
catch (error) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
errors.push({ hookName, message });
|
|
125
|
+
reportHookFailure({ hookName, message, deferred: false });
|
|
97
126
|
context.logger.error(`After-hook "${hookName}" failed`, {
|
|
98
127
|
error,
|
|
99
128
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { HookError } from "./executor.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where after-hook failures go when nobody is listening.
|
|
4
|
+
*
|
|
5
|
+
* An after-hook must never fail the write it is announcing, so `runAfterHooks` collects failures
|
|
6
|
+
* into a `HookReport` and the after-COMMIT path cannot even do that — by the time a deferred hook
|
|
7
|
+
* runs, the service method has already returned its Result. The consequence measured on 2026-09-15:
|
|
8
|
+
* removing the after-commit boundary from the plugin db path made four connector suites take
|
|
9
|
+
* 578.23 s instead of 27.29 s and log 51 `Hook "deliverWebhooks" timed out after 20000ms`, with
|
|
10
|
+
* exit 0 and 22 of 22 tests passing. The only thing that disagreed was the wall clock.
|
|
11
|
+
*
|
|
12
|
+
* So failures are reported here as well as logged. Production installs no observer and pays one
|
|
13
|
+
* undefined check; a test harness installs one and can fail the test that caused it.
|
|
14
|
+
*/
|
|
15
|
+
export interface HookFailure extends HookError {
|
|
16
|
+
/** True when the hook had been deferred to after the commit, so no `HookReport` can carry it. */
|
|
17
|
+
deferred: boolean;
|
|
18
|
+
}
|
|
19
|
+
export type HookFailureObserver = (failure: HookFailure) => void;
|
|
20
|
+
/** Install the observer, or pass `undefined` to remove it. Returns the previous one. */
|
|
21
|
+
export declare function observeHookFailures(next: HookFailureObserver | undefined): HookFailureObserver | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Report one after-hook failure. Never throws: an observer that throws would turn a swallowed hook
|
|
24
|
+
* failure into a failed write, which is the behaviour this whole path exists to prevent.
|
|
25
|
+
*/
|
|
26
|
+
export declare function reportHookFailure(failure: HookFailure): void;
|
|
27
|
+
//# sourceMappingURL=failures.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"failures.d.ts","sourceRoot":"","sources":["../../../src/kernel/hooks/failures.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE/C;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,iGAAiG;IACjG,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,WAAW,KAAK,IAAI,CAAC;AAcjE,wFAAwF;AACxF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,mBAAmB,GAAG,SAAS,GAAG,mBAAmB,GAAG,SAAS,CAK1G;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAQ5D"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observer lives on `globalThis`, not in a module-level binding, because this module is loaded
|
|
3
|
+
* TWICE in a monorepo test run and a module-level one would be two unrelated variables. Vitest
|
|
4
|
+
* resolves `@porulle/core` through the package's `import` condition to `dist/`, so a plugin suite
|
|
5
|
+
* runs the built executor; core's own suites and any vitest setup file import the source by
|
|
6
|
+
* relative path. An observer installed on the source copy is invisible to the built one, which is
|
|
7
|
+
* exactly how the first version of this instrument reported nothing while 51 hook timeouts went by.
|
|
8
|
+
*/
|
|
9
|
+
const OBSERVER_KEY = Symbol.for("@porulle/core:hook-failure-observer");
|
|
10
|
+
/** Install the observer, or pass `undefined` to remove it. Returns the previous one. */
|
|
11
|
+
export function observeHookFailures(next) {
|
|
12
|
+
const holder = globalThis;
|
|
13
|
+
const previous = holder[OBSERVER_KEY];
|
|
14
|
+
holder[OBSERVER_KEY] = next;
|
|
15
|
+
return previous;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Report one after-hook failure. Never throws: an observer that throws would turn a swallowed hook
|
|
19
|
+
* failure into a failed write, which is the behaviour this whole path exists to prevent.
|
|
20
|
+
*/
|
|
21
|
+
export function reportHookFailure(failure) {
|
|
22
|
+
const observer = globalThis[OBSERVER_KEY];
|
|
23
|
+
if (!observer)
|
|
24
|
+
return;
|
|
25
|
+
try {
|
|
26
|
+
observer(failure);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// An observer is an instrument. It does not get to change the outcome it is measuring.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test adapter whose transaction handle and `db` handle are two DIFFERENT connections.
|
|
3
|
+
*
|
|
4
|
+
* Every other test adapter in this repo is one PGlite instance, which hands the transaction body
|
|
5
|
+
* the SAME `db` handle it hands everyone else. That makes a whole class of defect invisible by
|
|
6
|
+
* construction: a write issued on the `db` handle while a transaction is open rides that open
|
|
7
|
+
* transaction, so it commits and rolls back with it, and a test asserting "the write rolled back"
|
|
8
|
+
* passes whether or not the code under test routed the write through `tx` at all.
|
|
9
|
+
*
|
|
10
|
+
* On the driver this project actually deploys — Neon over HTTP — every plain query on the `db`
|
|
11
|
+
* handle is its own request on its own connection. Such a write does NOT join the open transaction
|
|
12
|
+
* and SURVIVES its rollback. Measured on the deployed Worker, 2026-09-15:
|
|
13
|
+
*
|
|
14
|
+
* POST /api/loom/_proof/abort-after-hook
|
|
15
|
+
* committed: false entity_exists = 0 pending_for_aborted = 1
|
|
16
|
+
*
|
|
17
|
+
* This adapter reproduces that property with two PGlite instances: transactions run on one, the
|
|
18
|
+
* `db` handle is the other. The two do not share data, which a real two-connection driver would —
|
|
19
|
+
* that is the one way it is unlike Neon, and it is why the assertions below name WHICH connection
|
|
20
|
+
* a row landed on rather than reading either one alone.
|
|
21
|
+
*/
|
|
22
|
+
import type { DatabaseAdapter } from "../kernel/database/adapter.js";
|
|
23
|
+
import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
|
|
24
|
+
export interface TwoConnectionTestAdapter {
|
|
25
|
+
adapter: DatabaseAdapter;
|
|
26
|
+
/** The connection `adapter.transaction` opens its transaction on. */
|
|
27
|
+
txDb: DrizzleDatabase;
|
|
28
|
+
/** The connection `adapter.db` points at — the "outside" connection. */
|
|
29
|
+
outsideDb: DrizzleDatabase;
|
|
30
|
+
cleanup: () => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare function createTwoConnectionTestAdapter(): Promise<TwoConnectionTestAdapter>;
|
|
33
|
+
//# sourceMappingURL=create-two-connection-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-two-connection-adapter.d.ts","sourceRoot":"","sources":["../../src/test-utils/create-two-connection-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAGxE,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,eAAe,CAAC;IACzB,qEAAqE;IACrE,IAAI,EAAE,eAAe,CAAC;IACtB,wEAAwE;IACxE,SAAS,EAAE,eAAe,CAAC;IAC3B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AAED,wBAAsB,8BAA8B,IAAI,OAAO,CAAC,wBAAwB,CAAC,CAmBxF"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test adapter whose transaction handle and `db` handle are two DIFFERENT connections.
|
|
3
|
+
*
|
|
4
|
+
* Every other test adapter in this repo is one PGlite instance, which hands the transaction body
|
|
5
|
+
* the SAME `db` handle it hands everyone else. That makes a whole class of defect invisible by
|
|
6
|
+
* construction: a write issued on the `db` handle while a transaction is open rides that open
|
|
7
|
+
* transaction, so it commits and rolls back with it, and a test asserting "the write rolled back"
|
|
8
|
+
* passes whether or not the code under test routed the write through `tx` at all.
|
|
9
|
+
*
|
|
10
|
+
* On the driver this project actually deploys — Neon over HTTP — every plain query on the `db`
|
|
11
|
+
* handle is its own request on its own connection. Such a write does NOT join the open transaction
|
|
12
|
+
* and SURVIVES its rollback. Measured on the deployed Worker, 2026-09-15:
|
|
13
|
+
*
|
|
14
|
+
* POST /api/loom/_proof/abort-after-hook
|
|
15
|
+
* committed: false entity_exists = 0 pending_for_aborted = 1
|
|
16
|
+
*
|
|
17
|
+
* This adapter reproduces that property with two PGlite instances: transactions run on one, the
|
|
18
|
+
* `db` handle is the other. The two do not share data, which a real two-connection driver would —
|
|
19
|
+
* that is the one way it is unlike Neon, and it is why the assertions below name WHICH connection
|
|
20
|
+
* a row landed on rather than reading either one alone.
|
|
21
|
+
*/
|
|
22
|
+
import { createPGliteTestAdapter } from "./create-pglite-adapter.js";
|
|
23
|
+
export async function createTwoConnectionTestAdapter() {
|
|
24
|
+
const transactional = await createPGliteTestAdapter();
|
|
25
|
+
const outside = await createPGliteTestAdapter();
|
|
26
|
+
const adapter = {
|
|
27
|
+
provider: "postgresql",
|
|
28
|
+
db: outside.db,
|
|
29
|
+
transaction: transactional.adapter.transaction,
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
adapter,
|
|
33
|
+
txDb: transactional.db,
|
|
34
|
+
outsideDb: outside.db,
|
|
35
|
+
cleanup: async () => {
|
|
36
|
+
await transactional.cleanup();
|
|
37
|
+
await outside.cleanup();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hook-failure-setup.d.ts","sourceRoot":"","sources":["../../src/test-utils/hook-failure-setup.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { afterEach, beforeEach } from "vitest";
|
|
2
|
+
import { armHookFailureCollector, resetHookFailures, unallowedHookFailureMessage, } from "./hook-failures.js";
|
|
3
|
+
/**
|
|
4
|
+
* A vitest setup file, wired once in `vitest.shared.js` so every package in the monorepo inherits
|
|
5
|
+
* it. It is a setup file rather than something `createPluginTestApp` installs because the check has
|
|
6
|
+
* to be a default nobody remembers to ask for: the case it exists to catch is precisely a suite
|
|
7
|
+
* nobody thought to instrument.
|
|
8
|
+
*
|
|
9
|
+
* Measured 2026-09-15 on the connector suites with the after-commit boundary removed from the
|
|
10
|
+
* plugin db path: 578.23 s instead of 27.29 s, 51 `Hook "deliverWebhooks" timed out after 20000ms`,
|
|
11
|
+
* and exit 0 with every test passing. After-hook failures are collected into a `HookReport` and
|
|
12
|
+
* never thrown, so the run was green; nothing in the monorepo asked whether the report was clean.
|
|
13
|
+
*/
|
|
14
|
+
armHookFailureCollector();
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
resetHookFailures();
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
const message = unallowedHookFailureMessage();
|
|
20
|
+
resetHookFailures();
|
|
21
|
+
if (message)
|
|
22
|
+
throw new Error(message);
|
|
23
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type HookFailure } from "../kernel/hooks/failures.js";
|
|
2
|
+
/**
|
|
3
|
+
* Permit one named hook to fail during the current test without failing it.
|
|
4
|
+
*
|
|
5
|
+
* Per test and per hook on purpose: a suite that deliberately throws from `myBrokenHook` still
|
|
6
|
+
* fails if `deliverWebhooks` deadlocks alongside it, which a global mute would hide. The allowance
|
|
7
|
+
* is cleared before every test, so it has to be asked for where it is meant.
|
|
8
|
+
*/
|
|
9
|
+
export declare function allowHookFailure(hookName: string): void;
|
|
10
|
+
/** Every failure recorded for the current test, allowed or not. */
|
|
11
|
+
export declare function recordedHookFailures(): readonly HookFailure[];
|
|
12
|
+
export declare function resetHookFailures(): void;
|
|
13
|
+
export declare function armHookFailureCollector(): void;
|
|
14
|
+
/**
|
|
15
|
+
* The message for an unallowed failure, or undefined when there is nothing to report.
|
|
16
|
+
*
|
|
17
|
+
* A hook TIMEOUT is called out separately: a hook that throws is usually the suite's own doing,
|
|
18
|
+
* while a hook that times out is almost always deadlocked against the transaction holding its
|
|
19
|
+
* connection — a defect in the code under test, not in the test.
|
|
20
|
+
*/
|
|
21
|
+
export declare function unallowedHookFailureMessage(): string | undefined;
|
|
22
|
+
//# sourceMappingURL=hook-failures.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hook-failures.d.ts","sourceRoot":"","sources":["../../src/test-utils/hook-failures.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,6BAA6B,CAAC;AA2BpF;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAEvD;AAED,mEAAmE;AACnE,wBAAgB,oBAAoB,IAAI,SAAS,WAAW,EAAE,CAE7D;AAED,wBAAgB,iBAAiB,IAAI,IAAI,CAIxC;AAED,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,IAAI,MAAM,GAAG,SAAS,CAuBhE"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { observeHookFailures } from "../kernel/hooks/failures.js";
|
|
2
|
+
/**
|
|
3
|
+
* Collected after-hook failures for the test currently running, and the per-test allowance for the
|
|
4
|
+
* ones a suite causes on purpose.
|
|
5
|
+
*
|
|
6
|
+
* The state lives here rather than in the vitest setup file so that a suite importing
|
|
7
|
+
* `allowHookFailure` from `@porulle/core/testing` and the setup file that reads it resolve to the
|
|
8
|
+
* same module instance.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* On `globalThis` for the same reason the observer is: this module is loaded once as source (by the
|
|
12
|
+
* vitest setup file) and once as `dist/` (by anything importing `@porulle/core/testing`), so a
|
|
13
|
+
* suite calling `allowHookFailure` would otherwise be writing to a different Set from the one the
|
|
14
|
+
* setup file reads.
|
|
15
|
+
*/
|
|
16
|
+
const STATE_KEY = Symbol.for("@porulle/core:hook-failure-state");
|
|
17
|
+
function state() {
|
|
18
|
+
const holder = globalThis;
|
|
19
|
+
holder[STATE_KEY] ??= { seen: [], allowed: new Set() };
|
|
20
|
+
return holder[STATE_KEY];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Permit one named hook to fail during the current test without failing it.
|
|
24
|
+
*
|
|
25
|
+
* Per test and per hook on purpose: a suite that deliberately throws from `myBrokenHook` still
|
|
26
|
+
* fails if `deliverWebhooks` deadlocks alongside it, which a global mute would hide. The allowance
|
|
27
|
+
* is cleared before every test, so it has to be asked for where it is meant.
|
|
28
|
+
*/
|
|
29
|
+
export function allowHookFailure(hookName) {
|
|
30
|
+
state().allowed.add(hookName);
|
|
31
|
+
}
|
|
32
|
+
/** Every failure recorded for the current test, allowed or not. */
|
|
33
|
+
export function recordedHookFailures() {
|
|
34
|
+
return state().seen;
|
|
35
|
+
}
|
|
36
|
+
export function resetHookFailures() {
|
|
37
|
+
const current = state();
|
|
38
|
+
current.seen.length = 0;
|
|
39
|
+
current.allowed.clear();
|
|
40
|
+
}
|
|
41
|
+
export function armHookFailureCollector() {
|
|
42
|
+
observeHookFailures((failure) => state().seen.push(failure));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The message for an unallowed failure, or undefined when there is nothing to report.
|
|
46
|
+
*
|
|
47
|
+
* A hook TIMEOUT is called out separately: a hook that throws is usually the suite's own doing,
|
|
48
|
+
* while a hook that times out is almost always deadlocked against the transaction holding its
|
|
49
|
+
* connection — a defect in the code under test, not in the test.
|
|
50
|
+
*/
|
|
51
|
+
export function unallowedHookFailureMessage() {
|
|
52
|
+
const { seen, allowed } = state();
|
|
53
|
+
const unallowed = seen.filter((failure) => !allowed.has(failure.hookName));
|
|
54
|
+
if (unallowed.length === 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
const timeouts = unallowed.filter((failure) => / timed out after \d+ms$/.test(failure.message));
|
|
57
|
+
const lines = unallowed.map((failure) => ` ${failure.deferred ? "after-commit" : "after"}-hook "${failure.hookName}": ${failure.message}`);
|
|
58
|
+
return [
|
|
59
|
+
`${unallowed.length} after-hook failure(s) during this test:`,
|
|
60
|
+
...lines,
|
|
61
|
+
"",
|
|
62
|
+
timeouts.length > 0
|
|
63
|
+
? `${timeouts.length} of them TIMED OUT. A hook that times out is almost always blocked on the ` +
|
|
64
|
+
"connection held by the transaction it was fired from — check that the write path establishes " +
|
|
65
|
+
"an after-commit boundary rather than running the hook inside the transaction."
|
|
66
|
+
: "An after-hook failure never fails the write it announces, so without this check the test " +
|
|
67
|
+
"passes and the failure is only visible in a log.",
|
|
68
|
+
"",
|
|
69
|
+
"If the suite causes this on purpose, call allowHookFailure(\"<hookName>\") in that test.",
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
package/dist/testing.d.ts
CHANGED
|
@@ -12,5 +12,8 @@ export { createPluginTestApp, type PluginTestApp, type TestAppEnv } from "./test
|
|
|
12
12
|
export { TEST_ORG_ID, testAdminActor, testStaffActor, testCustomerActor, testNoPermActor, jsonHeaders, } from "./test-utils/test-actors.js";
|
|
13
13
|
export { beforeHook, afterHook } from "./test-utils/typed-hooks.js";
|
|
14
14
|
export { markOrderPaidForTest } from "./test-utils/order-test-helpers.js";
|
|
15
|
+
export { isInsideTransaction } from "./kernel/hooks/deferred.js";
|
|
16
|
+
export { allowHookFailure, recordedHookFailures } from "./test-utils/hook-failures.js";
|
|
17
|
+
export type { HookFailure } from "./kernel/hooks/failures.js";
|
|
15
18
|
export type { Actor } from "./auth/types.js";
|
|
16
19
|
//# sourceMappingURL=testing.d.ts.map
|
package/dist/testing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,4CAA4C,CAAC;AACrF,OAAO,EAAE,2BAA2B,EAAE,MAAM,gDAAgD,CAAC;AAC7F,OAAO,EAAE,mBAAmB,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,MAAM,wCAAwC,CAAC;AAClH,OAAO,EACL,WAAW,EACX,cAAc,EAAE,cAAc,EAAE,iBAAiB,EAAE,eAAe,EAClE,WAAW,GACZ,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,4CAA4C,CAAC;AACrF,OAAO,EAAE,2BAA2B,EAAE,MAAM,gDAAgD,CAAC;AAC7F,OAAO,EAAE,mBAAmB,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,MAAM,wCAAwC,CAAC;AAClH,OAAO,EACL,WAAW,EACX,cAAc,EAAE,cAAc,EAAE,iBAAiB,EAAE,eAAe,EAClE,WAAW,GACZ,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAC;AAQ1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAMjE,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AACvF,YAAY,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAG9D,YAAY,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC"}
|
package/dist/testing.js
CHANGED
|
@@ -12,3 +12,15 @@ export { createPluginTestApp } from "./test-utils/create-plugin-test-app.js";
|
|
|
12
12
|
export { TEST_ORG_ID, testAdminActor, testStaffActor, testCustomerActor, testNoPermActor, jsonHeaders, } from "./test-utils/test-actors.js";
|
|
13
13
|
export { beforeHook, afterHook } from "./test-utils/typed-hooks.js";
|
|
14
14
|
export { markOrderPaidForTest } from "./test-utils/order-test-helpers.js";
|
|
15
|
+
// The after-commit boundary predicate, so a suite can assert that the code under test really is
|
|
16
|
+
// inside one. A plugin reaches the database through the `ctx.db` HANDLE, not the adapter, and the
|
|
17
|
+
// boundary on that path comes from the proxy in `normalizeExecuteShape`; without it an after-hook
|
|
18
|
+
// runs INSIDE the open transaction and deadlocks on its own connection. That regression is silent
|
|
19
|
+
// — after-hook failures are collected into a HookReport and never thrown — so a suite has to ask
|
|
20
|
+
// the question directly rather than wait for a symptom.
|
|
21
|
+
export { isInsideTransaction } from "./kernel/hooks/deferred.js";
|
|
22
|
+
// A hook that fails is announced to nobody: an after-hook must not fail the write it records, so
|
|
23
|
+
// `runAfterHooks` collects failures into a HookReport and the after-commit path cannot even do
|
|
24
|
+
// that. The vitest setup file wired in vitest.shared.js turns them into a test failure; this is the
|
|
25
|
+
// escape hatch for a suite that causes one deliberately.
|
|
26
|
+
export { allowHookFailure, recordedHookFailures } from "./test-utils/hook-failures.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
"eslint": "^9.39.1",
|
|
63
63
|
"typescript": "5.9.2",
|
|
64
64
|
"vitest": "^3.2.4",
|
|
65
|
-
"@porulle/
|
|
66
|
-
"@porulle/
|
|
65
|
+
"@porulle/typescript-config": "0.1.0",
|
|
66
|
+
"@porulle/eslint-config": "0.1.0"
|
|
67
67
|
},
|
|
68
68
|
"publishConfig": {
|
|
69
69
|
"access": "public"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AfterHook, BeforeHook, HookContext, HookOperation } from "./types.js";
|
|
2
|
+
import type { PluginDb } from "../database/plugin-types.js";
|
|
2
3
|
import { deferAfterCommit } from "./deferred.js";
|
|
4
|
+
import { reportHookFailure } from "./failures.js";
|
|
3
5
|
|
|
4
6
|
export interface HookError {
|
|
5
7
|
hookName: string;
|
|
@@ -18,6 +20,29 @@ export function mergeHookReports(a: HookReport, b: HookReport): HookReport {
|
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The context a hook marked `inTransaction: true` receives.
|
|
25
|
+
*
|
|
26
|
+
* `inTransaction` buys ORDERING on its own — the hook runs inline, before the commit. It does not
|
|
27
|
+
* make the hook's WRITES part of the transaction: `context.db` is the plugin db handle, and on a
|
|
28
|
+
* two-connection driver (Neon over HTTP, where every plain query is its own request) a write on
|
|
29
|
+
* that handle is not in the transaction and survives its rollback. Measured on the deployed Worker
|
|
30
|
+
* on 2026-09-15: an aborted write left `entity_exists = 0` and `pending_for_aborted = 1` — a row
|
|
31
|
+
* that rolled back announcing itself in the outbox whose entire purpose is committing with it.
|
|
32
|
+
*
|
|
33
|
+
* So the kernel hands such a hook a context it cannot get this wrong from: `db` IS the transaction.
|
|
34
|
+
* A plugin author writing the obvious thing lands inside the transaction, with nothing to remember.
|
|
35
|
+
*
|
|
36
|
+
* `tx` can still be null here — a marked hook also fires for a write performed outside any
|
|
37
|
+
* transaction, and `catalogHookContext` passes `tx: null` for one. Such a hook keeps the outside
|
|
38
|
+
* connection, because that is the only connection there is; handing it nothing would silently stop
|
|
39
|
+
* every non-transactional write from announcing itself, which is worse than the defect above.
|
|
40
|
+
*/
|
|
41
|
+
function inTransactionHookContext(context: HookContext): HookContext {
|
|
42
|
+
if (context.tx == null) return context;
|
|
43
|
+
return { ...context, db: context.tx as PluginDb };
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
/** Default hook timeout: 20 seconds */
|
|
22
47
|
const HOOK_TIMEOUT_MS = 20_000;
|
|
23
48
|
|
|
@@ -97,7 +122,7 @@ export async function runAfterHooks<T>(
|
|
|
97
122
|
result: committedResult,
|
|
98
123
|
operation,
|
|
99
124
|
context: runsInTransaction(hook)
|
|
100
|
-
? context
|
|
125
|
+
? inTransactionHookContext(context)
|
|
101
126
|
: { ...context, tx: null },
|
|
102
127
|
}),
|
|
103
128
|
HOOK_TIMEOUT_MS,
|
|
@@ -108,10 +133,9 @@ export async function runAfterHooks<T>(
|
|
|
108
133
|
try {
|
|
109
134
|
await runHook();
|
|
110
135
|
} catch (error) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
});
|
|
136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
137
|
+
errors.push({ hookName, message });
|
|
138
|
+
reportHookFailure({ hookName, message, deferred: false });
|
|
115
139
|
context.logger.error(`After-hook "${hookName}" failed`, {
|
|
116
140
|
error,
|
|
117
141
|
});
|
|
@@ -123,6 +147,13 @@ export async function runAfterHooks<T>(
|
|
|
123
147
|
try {
|
|
124
148
|
await runHook();
|
|
125
149
|
} catch (error) {
|
|
150
|
+
// Reported rather than collected: this runs after the commit, so the HookReport below has
|
|
151
|
+
// already been returned to the caller and there is nowhere else for this failure to go.
|
|
152
|
+
reportHookFailure({
|
|
153
|
+
hookName,
|
|
154
|
+
message: error instanceof Error ? error.message : String(error),
|
|
155
|
+
deferred: true,
|
|
156
|
+
});
|
|
126
157
|
context.logger.error(`After-commit hook "${hookName}" failed`, {
|
|
127
158
|
error,
|
|
128
159
|
});
|
|
@@ -133,10 +164,9 @@ export async function runAfterHooks<T>(
|
|
|
133
164
|
try {
|
|
134
165
|
await runHook();
|
|
135
166
|
} catch (error) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
});
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
errors.push({ hookName, message });
|
|
169
|
+
reportHookFailure({ hookName, message, deferred: false });
|
|
140
170
|
context.logger.error(`After-hook "${hookName}" failed`, {
|
|
141
171
|
error,
|
|
142
172
|
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { HookError } from "./executor.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where after-hook failures go when nobody is listening.
|
|
5
|
+
*
|
|
6
|
+
* An after-hook must never fail the write it is announcing, so `runAfterHooks` collects failures
|
|
7
|
+
* into a `HookReport` and the after-COMMIT path cannot even do that — by the time a deferred hook
|
|
8
|
+
* runs, the service method has already returned its Result. The consequence measured on 2026-09-15:
|
|
9
|
+
* removing the after-commit boundary from the plugin db path made four connector suites take
|
|
10
|
+
* 578.23 s instead of 27.29 s and log 51 `Hook "deliverWebhooks" timed out after 20000ms`, with
|
|
11
|
+
* exit 0 and 22 of 22 tests passing. The only thing that disagreed was the wall clock.
|
|
12
|
+
*
|
|
13
|
+
* So failures are reported here as well as logged. Production installs no observer and pays one
|
|
14
|
+
* undefined check; a test harness installs one and can fail the test that caused it.
|
|
15
|
+
*/
|
|
16
|
+
export interface HookFailure extends HookError {
|
|
17
|
+
/** True when the hook had been deferred to after the commit, so no `HookReport` can carry it. */
|
|
18
|
+
deferred: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type HookFailureObserver = (failure: HookFailure) => void;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The observer lives on `globalThis`, not in a module-level binding, because this module is loaded
|
|
25
|
+
* TWICE in a monorepo test run and a module-level one would be two unrelated variables. Vitest
|
|
26
|
+
* resolves `@porulle/core` through the package's `import` condition to `dist/`, so a plugin suite
|
|
27
|
+
* runs the built executor; core's own suites and any vitest setup file import the source by
|
|
28
|
+
* relative path. An observer installed on the source copy is invisible to the built one, which is
|
|
29
|
+
* exactly how the first version of this instrument reported nothing while 51 hook timeouts went by.
|
|
30
|
+
*/
|
|
31
|
+
const OBSERVER_KEY = Symbol.for("@porulle/core:hook-failure-observer");
|
|
32
|
+
|
|
33
|
+
type ObserverHolder = { [OBSERVER_KEY]?: HookFailureObserver | undefined };
|
|
34
|
+
|
|
35
|
+
/** Install the observer, or pass `undefined` to remove it. Returns the previous one. */
|
|
36
|
+
export function observeHookFailures(next: HookFailureObserver | undefined): HookFailureObserver | undefined {
|
|
37
|
+
const holder = globalThis as ObserverHolder;
|
|
38
|
+
const previous = holder[OBSERVER_KEY];
|
|
39
|
+
holder[OBSERVER_KEY] = next;
|
|
40
|
+
return previous;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Report one after-hook failure. Never throws: an observer that throws would turn a swallowed hook
|
|
45
|
+
* failure into a failed write, which is the behaviour this whole path exists to prevent.
|
|
46
|
+
*/
|
|
47
|
+
export function reportHookFailure(failure: HookFailure): void {
|
|
48
|
+
const observer = (globalThis as ObserverHolder)[OBSERVER_KEY];
|
|
49
|
+
if (!observer) return;
|
|
50
|
+
try {
|
|
51
|
+
observer(failure);
|
|
52
|
+
} catch {
|
|
53
|
+
// An observer is an instrument. It does not get to change the outcome it is measuring.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test adapter whose transaction handle and `db` handle are two DIFFERENT connections.
|
|
3
|
+
*
|
|
4
|
+
* Every other test adapter in this repo is one PGlite instance, which hands the transaction body
|
|
5
|
+
* the SAME `db` handle it hands everyone else. That makes a whole class of defect invisible by
|
|
6
|
+
* construction: a write issued on the `db` handle while a transaction is open rides that open
|
|
7
|
+
* transaction, so it commits and rolls back with it, and a test asserting "the write rolled back"
|
|
8
|
+
* passes whether or not the code under test routed the write through `tx` at all.
|
|
9
|
+
*
|
|
10
|
+
* On the driver this project actually deploys — Neon over HTTP — every plain query on the `db`
|
|
11
|
+
* handle is its own request on its own connection. Such a write does NOT join the open transaction
|
|
12
|
+
* and SURVIVES its rollback. Measured on the deployed Worker, 2026-09-15:
|
|
13
|
+
*
|
|
14
|
+
* POST /api/loom/_proof/abort-after-hook
|
|
15
|
+
* committed: false entity_exists = 0 pending_for_aborted = 1
|
|
16
|
+
*
|
|
17
|
+
* This adapter reproduces that property with two PGlite instances: transactions run on one, the
|
|
18
|
+
* `db` handle is the other. The two do not share data, which a real two-connection driver would —
|
|
19
|
+
* that is the one way it is unlike Neon, and it is why the assertions below name WHICH connection
|
|
20
|
+
* a row landed on rather than reading either one alone.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { DatabaseAdapter } from "../kernel/database/adapter.js";
|
|
24
|
+
import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
|
|
25
|
+
import { createPGliteTestAdapter } from "./create-pglite-adapter.js";
|
|
26
|
+
|
|
27
|
+
export interface TwoConnectionTestAdapter {
|
|
28
|
+
adapter: DatabaseAdapter;
|
|
29
|
+
/** The connection `adapter.transaction` opens its transaction on. */
|
|
30
|
+
txDb: DrizzleDatabase;
|
|
31
|
+
/** The connection `adapter.db` points at — the "outside" connection. */
|
|
32
|
+
outsideDb: DrizzleDatabase;
|
|
33
|
+
cleanup: () => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createTwoConnectionTestAdapter(): Promise<TwoConnectionTestAdapter> {
|
|
37
|
+
const transactional = await createPGliteTestAdapter();
|
|
38
|
+
const outside = await createPGliteTestAdapter();
|
|
39
|
+
|
|
40
|
+
const adapter: DatabaseAdapter = {
|
|
41
|
+
provider: "postgresql",
|
|
42
|
+
db: outside.db,
|
|
43
|
+
transaction: transactional.adapter.transaction,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
adapter,
|
|
48
|
+
txDb: transactional.db,
|
|
49
|
+
outsideDb: outside.db,
|
|
50
|
+
cleanup: async () => {
|
|
51
|
+
await transactional.cleanup();
|
|
52
|
+
await outside.cleanup();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterEach, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
armHookFailureCollector,
|
|
4
|
+
resetHookFailures,
|
|
5
|
+
unallowedHookFailureMessage,
|
|
6
|
+
} from "./hook-failures.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A vitest setup file, wired once in `vitest.shared.js` so every package in the monorepo inherits
|
|
10
|
+
* it. It is a setup file rather than something `createPluginTestApp` installs because the check has
|
|
11
|
+
* to be a default nobody remembers to ask for: the case it exists to catch is precisely a suite
|
|
12
|
+
* nobody thought to instrument.
|
|
13
|
+
*
|
|
14
|
+
* Measured 2026-09-15 on the connector suites with the after-commit boundary removed from the
|
|
15
|
+
* plugin db path: 578.23 s instead of 27.29 s, 51 `Hook "deliverWebhooks" timed out after 20000ms`,
|
|
16
|
+
* and exit 0 with every test passing. After-hook failures are collected into a `HookReport` and
|
|
17
|
+
* never thrown, so the run was green; nothing in the monorepo asked whether the report was clean.
|
|
18
|
+
*/
|
|
19
|
+
armHookFailureCollector();
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
resetHookFailures();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
const message = unallowedHookFailureMessage();
|
|
27
|
+
resetHookFailures();
|
|
28
|
+
if (message) throw new Error(message);
|
|
29
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { observeHookFailures, type HookFailure } from "../kernel/hooks/failures.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collected after-hook failures for the test currently running, and the per-test allowance for the
|
|
5
|
+
* ones a suite causes on purpose.
|
|
6
|
+
*
|
|
7
|
+
* The state lives here rather than in the vitest setup file so that a suite importing
|
|
8
|
+
* `allowHookFailure` from `@porulle/core/testing` and the setup file that reads it resolve to the
|
|
9
|
+
* same module instance.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* On `globalThis` for the same reason the observer is: this module is loaded once as source (by the
|
|
13
|
+
* vitest setup file) and once as `dist/` (by anything importing `@porulle/core/testing`), so a
|
|
14
|
+
* suite calling `allowHookFailure` would otherwise be writing to a different Set from the one the
|
|
15
|
+
* setup file reads.
|
|
16
|
+
*/
|
|
17
|
+
const STATE_KEY = Symbol.for("@porulle/core:hook-failure-state");
|
|
18
|
+
|
|
19
|
+
type State = { seen: HookFailure[]; allowed: Set<string> };
|
|
20
|
+
type StateHolder = { [STATE_KEY]?: State };
|
|
21
|
+
|
|
22
|
+
function state(): State {
|
|
23
|
+
const holder = globalThis as StateHolder;
|
|
24
|
+
holder[STATE_KEY] ??= { seen: [], allowed: new Set<string>() };
|
|
25
|
+
return holder[STATE_KEY];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Permit one named hook to fail during the current test without failing it.
|
|
30
|
+
*
|
|
31
|
+
* Per test and per hook on purpose: a suite that deliberately throws from `myBrokenHook` still
|
|
32
|
+
* fails if `deliverWebhooks` deadlocks alongside it, which a global mute would hide. The allowance
|
|
33
|
+
* is cleared before every test, so it has to be asked for where it is meant.
|
|
34
|
+
*/
|
|
35
|
+
export function allowHookFailure(hookName: string): void {
|
|
36
|
+
state().allowed.add(hookName);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every failure recorded for the current test, allowed or not. */
|
|
40
|
+
export function recordedHookFailures(): readonly HookFailure[] {
|
|
41
|
+
return state().seen;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resetHookFailures(): void {
|
|
45
|
+
const current = state();
|
|
46
|
+
current.seen.length = 0;
|
|
47
|
+
current.allowed.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function armHookFailureCollector(): void {
|
|
51
|
+
observeHookFailures((failure) => state().seen.push(failure));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The message for an unallowed failure, or undefined when there is nothing to report.
|
|
56
|
+
*
|
|
57
|
+
* A hook TIMEOUT is called out separately: a hook that throws is usually the suite's own doing,
|
|
58
|
+
* while a hook that times out is almost always deadlocked against the transaction holding its
|
|
59
|
+
* connection — a defect in the code under test, not in the test.
|
|
60
|
+
*/
|
|
61
|
+
export function unallowedHookFailureMessage(): string | undefined {
|
|
62
|
+
const { seen, allowed } = state();
|
|
63
|
+
const unallowed = seen.filter((failure) => !allowed.has(failure.hookName));
|
|
64
|
+
if (unallowed.length === 0) return undefined;
|
|
65
|
+
|
|
66
|
+
const timeouts = unallowed.filter((failure) => / timed out after \d+ms$/.test(failure.message));
|
|
67
|
+
const lines = unallowed.map(
|
|
68
|
+
(failure) => ` ${failure.deferred ? "after-commit" : "after"}-hook "${failure.hookName}": ${failure.message}`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
`${unallowed.length} after-hook failure(s) during this test:`,
|
|
73
|
+
...lines,
|
|
74
|
+
"",
|
|
75
|
+
timeouts.length > 0
|
|
76
|
+
? `${timeouts.length} of them TIMED OUT. A hook that times out is almost always blocked on the ` +
|
|
77
|
+
"connection held by the transaction it was fired from — check that the write path establishes " +
|
|
78
|
+
"an after-commit boundary rather than running the hook inside the transaction."
|
|
79
|
+
: "An after-hook failure never fails the write it announces, so without this check the test " +
|
|
80
|
+
"passes and the failure is only visible in a log.",
|
|
81
|
+
"",
|
|
82
|
+
"If the suite causes this on purpose, call allowHookFailure(\"<hookName>\") in that test.",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
package/src/testing.ts
CHANGED
|
@@ -18,5 +18,20 @@ export {
|
|
|
18
18
|
export { beforeHook, afterHook } from "./test-utils/typed-hooks.js";
|
|
19
19
|
export { markOrderPaidForTest } from "./test-utils/order-test-helpers.js";
|
|
20
20
|
|
|
21
|
+
// The after-commit boundary predicate, so a suite can assert that the code under test really is
|
|
22
|
+
// inside one. A plugin reaches the database through the `ctx.db` HANDLE, not the adapter, and the
|
|
23
|
+
// boundary on that path comes from the proxy in `normalizeExecuteShape`; without it an after-hook
|
|
24
|
+
// runs INSIDE the open transaction and deadlocks on its own connection. That regression is silent
|
|
25
|
+
// — after-hook failures are collected into a HookReport and never thrown — so a suite has to ask
|
|
26
|
+
// the question directly rather than wait for a symptom.
|
|
27
|
+
export { isInsideTransaction } from "./kernel/hooks/deferred.js";
|
|
28
|
+
|
|
29
|
+
// A hook that fails is announced to nobody: an after-hook must not fail the write it records, so
|
|
30
|
+
// `runAfterHooks` collects failures into a HookReport and the after-commit path cannot even do
|
|
31
|
+
// that. The vitest setup file wired in vitest.shared.js turns them into a test failure; this is the
|
|
32
|
+
// escape hatch for a suite that causes one deliberately.
|
|
33
|
+
export { allowHookFailure, recordedHookFailures } from "./test-utils/hook-failures.js";
|
|
34
|
+
export type { HookFailure } from "./kernel/hooks/failures.js";
|
|
35
|
+
|
|
21
36
|
// Actor type re-export for plugin tests that build custom test actors.
|
|
22
37
|
export type { Actor } from "./auth/types.js";
|