@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.
@@ -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;AAGpF,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;AAqCD,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,CA6DrB"}
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
- errors.push({
69
- hookName,
70
- message: error instanceof Error ? error.message : String(error),
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
- errors.push({
94
- hookName,
95
- message: error instanceof Error ? error.message : String(error),
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=hook-failure-setup.d.ts.map
@@ -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
@@ -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;AAG1E,YAAY,EAAE,KAAK,EAAE,MAAM,iBAAiB,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.35.1",
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/eslint-config": "0.1.0",
66
- "@porulle/typescript-config": "0.1.0"
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
- errors.push({
112
- hookName,
113
- message: error instanceof Error ? error.message : String(error),
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
- errors.push({
137
- hookName,
138
- message: error instanceof Error ? error.message : String(error),
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";