@porulle/core 0.35.0 → 0.36.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;AAIpF,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,CAkErB"}
@@ -1,4 +1,5 @@
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],
@@ -65,10 +66,9 @@ export async function runAfterHooks(hooks, originalData, committedResult, operat
65
66
  await runHook();
66
67
  }
67
68
  catch (error) {
68
- errors.push({
69
- hookName,
70
- message: error instanceof Error ? error.message : String(error),
71
- });
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ errors.push({ hookName, message });
71
+ reportHookFailure({ hookName, message, deferred: false });
72
72
  context.logger.error(`After-hook "${hookName}" failed`, {
73
73
  error,
74
74
  });
@@ -80,6 +80,13 @@ export async function runAfterHooks(hooks, originalData, committedResult, operat
80
80
  await runHook();
81
81
  }
82
82
  catch (error) {
83
+ // Reported rather than collected: this runs after the commit, so the HookReport below has
84
+ // already been returned to the caller and there is nowhere else for this failure to go.
85
+ reportHookFailure({
86
+ hookName,
87
+ message: error instanceof Error ? error.message : String(error),
88
+ deferred: true,
89
+ });
83
90
  context.logger.error(`After-commit hook "${hookName}" failed`, {
84
91
  error,
85
92
  });
@@ -90,10 +97,9 @@ export async function runAfterHooks(hooks, originalData, committedResult, operat
90
97
  await runHook();
91
98
  }
92
99
  catch (error) {
93
- errors.push({
94
- hookName,
95
- message: error instanceof Error ? error.message : String(error),
96
- });
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ errors.push({ hookName, message });
102
+ reportHookFailure({ hookName, message, deferred: false });
97
103
  context.logger.error(`After-hook "${hookName}" failed`, {
98
104
  error,
99
105
  });
@@ -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
+ }
@@ -1,4 +1,8 @@
1
1
  export type HookHandler = (...args: never[]) => unknown;
2
+ /** Declare that this handler must run inside the writing transaction. Called by the manifest merge. */
3
+ export declare function markHookInTransaction(handler: unknown): void;
4
+ /** Whether a plugin asked for this handler to run in-transaction. Read by the kernel at boot. */
5
+ export declare function isHookMarkedInTransaction(handler: unknown): boolean;
2
6
  export declare class HookRegistry {
3
7
  private registry;
4
8
  private inTransactionHandlers;
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/kernel/hooks/registry.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC;AAQxD,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,qBAAqB,CAA8B;IAC3D,OAAO,CAAC,MAAM,CAAC,CAAiE;IAEhF,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,IAAI;IAKpE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI;IAKpD,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI;IAKjE,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI;IAKrD,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI;IAKlE,iBAAiB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO;IAIhD,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,EAAE;IAMxC;;;;;;;;;;;;OAYG;IACH,SAAS,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,GAAG,IAAI;IAIjF,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAcxD,OAAO,CAAC,WAAW;CASpB"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/kernel/hooks/registry.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC;AAgBxD,uGAAuG;AACvG,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5D;AAED,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAEnE;AAQD,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,qBAAqB,CAA8B;IAC3D,OAAO,CAAC,MAAM,CAAC,CAAiE;IAEhF,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,IAAI;IAKpE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI;IAKpD,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI;IAKjE,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI;IAKrD,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI;IAKlE,iBAAiB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO;IAIhD,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,EAAE;IAMxC;;;;;;;;;;;;OAYG;IACH,SAAS,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,GAAG,IAAI;IAIjF,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAcxD,OAAO,CAAC,WAAW;CASpB"}
@@ -1,3 +1,25 @@
1
+ /**
2
+ * Handlers a PLUGIN asked to run inside the writing transaction.
3
+ *
4
+ * Module-level rather than per-registry because a plugin's declaration and its registration are
5
+ * separated by the manifest merge: `manifest.hooks()` returns `{ key, handler, inTransaction }`
6
+ * records, which are flattened into `config.hooks` as a bare `key -> handler[]` map long before any
7
+ * HookRegistry exists. Marking the function itself is the only channel that survives that.
8
+ *
9
+ * Shipped in 0.35.1 because 0.35.0 made after-commit the default with no way for a plugin to opt
10
+ * out, which silently moved `loom_projection_pending` — a transactional outbox whose whole point is
11
+ * committing with the write it records — to after the commit.
12
+ */
13
+ const pluginInTransactionHandlers = new WeakSet();
14
+ /** Declare that this handler must run inside the writing transaction. Called by the manifest merge. */
15
+ export function markHookInTransaction(handler) {
16
+ if (typeof handler === "function")
17
+ pluginInTransactionHandlers.add(handler);
18
+ }
19
+ /** Whether a plugin asked for this handler to run in-transaction. Read by the kernel at boot. */
20
+ export function isHookMarkedInTransaction(handler) {
21
+ return typeof handler === "function" && pluginInTransactionHandlers.has(handler);
22
+ }
1
23
  export class HookRegistry {
2
24
  registry = new Map();
3
25
  inTransactionHandlers = new WeakSet();
@@ -28,6 +28,15 @@ export type PluginRouteRegistration = {
28
28
  export interface PluginHookRegistration {
29
29
  key: string;
30
30
  handler: (...args: unknown[]) => unknown;
31
+ /**
32
+ * Run this hook INSIDE the writing transaction instead of after it commits.
33
+ *
34
+ * The default is after-commit and is correct for anything with an external effect — a webhook, a
35
+ * search-index write, an email — none of which may announce a write that can still roll back.
36
+ * Set this only for an OUTBOX WRITER: a hook whose own write must commit or roll back together
37
+ * with the row it records. `loom_projection_pending` is the case this exists for.
38
+ */
39
+ inTransaction?: boolean;
31
40
  }
32
41
  export interface PluginContext {
33
42
  config: CommerceConfig;
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/kernel/plugin/manifest.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/kernel/plugin/manifest.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { markHookInTransaction } from "../hooks/registry.js";
1
2
  import { resolveOrgIdForCommerce } from "../../auth/org.js";
2
3
  import { isRoutePermissionGuard, markRoutePermissionGuard } from "../../interfaces/rest/utils.js";
3
4
  import { createScopedDb } from "../database/scoped-db.js";
@@ -141,6 +142,10 @@ export function defineCommercePlugin(manifest) {
141
142
  ...(result.hooks ?? {}),
142
143
  };
143
144
  for (const reg of registrations) {
145
+ // The marker rides on the function because this map is a bare key -> handler[] and cannot
146
+ // carry it; the kernel reads it back when it registers these at boot.
147
+ if (reg.inTransaction)
148
+ markHookInTransaction(reg.handler);
144
149
  hookMap[reg.key] = [...(hookMap[reg.key] ?? []), reg.handler];
145
150
  }
146
151
  result = { ...result, hooks: hookMap };
@@ -1 +1 @@
1
- {"version":3,"file":"kernel.d.ts","sourceRoot":"","sources":["../../src/runtime/kernel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAqBzD,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,sBAAsB,EAC5B,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EAAE,MAAM,EAAE,sBAAsB,EAAE,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAChF,OAAO,EACL,kBAAkB,EAClB,8BAA8B,GAC/B,MAAM,qBAAqB,CAAC;AAE7B,wBAAgB,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CA0I3D"}
1
+ {"version":3,"file":"kernel.d.ts","sourceRoot":"","sources":["../../src/runtime/kernel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAqBzD,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,sBAAsB,EAC5B,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EAAE,MAAM,EAAE,sBAAsB,EAAE,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAChF,OAAO,EACL,kBAAkB,EAClB,8BAA8B,GAC/B,MAAM,qBAAqB,CAAC;AAE7B,wBAAgB,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAiJ3D"}
@@ -1,4 +1,4 @@
1
- import { HookRegistry } from "../kernel/hooks/registry.js";
1
+ import { HookRegistry, isHookMarkedInTransaction } from "../kernel/hooks/registry.js";
2
2
  import { createDatabaseConnection } from "../kernel/database/adapter.js";
3
3
  import { topoSortModules } from "../kernel/module/index.js";
4
4
  import { WebhookDeliveryWorker } from "../modules/webhooks/worker.js";
@@ -115,7 +115,15 @@ export function createKernel(config) {
115
115
  };
116
116
  for (const [key, handlers] of Object.entries(config.hooks ?? {})) {
117
117
  for (const handler of handlers) {
118
- hooks.append(key, handler);
118
+ // A plugin that declared `inTransaction` gets appendInTransaction, so its hook runs inside
119
+ // the writing transaction rather than in the after-commit drain. Everything else defaults to
120
+ // after-commit, which is what 0.35.0 established.
121
+ if (isHookMarkedInTransaction(handler)) {
122
+ hooks.appendInTransaction(key, handler);
123
+ }
124
+ else {
125
+ hooks.append(key, handler);
126
+ }
119
127
  }
120
128
  }
121
129
  for (const model of config.analytics?.models ?? []) {
@@ -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.0",
3
+ "version": "0.36.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,6 @@
1
1
  import type { AfterHook, BeforeHook, HookContext, HookOperation } from "./types.js";
2
2
  import { deferAfterCommit } from "./deferred.js";
3
+ import { reportHookFailure } from "./failures.js";
3
4
 
4
5
  export interface HookError {
5
6
  hookName: string;
@@ -108,10 +109,9 @@ export async function runAfterHooks<T>(
108
109
  try {
109
110
  await runHook();
110
111
  } catch (error) {
111
- errors.push({
112
- hookName,
113
- message: error instanceof Error ? error.message : String(error),
114
- });
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ errors.push({ hookName, message });
114
+ reportHookFailure({ hookName, message, deferred: false });
115
115
  context.logger.error(`After-hook "${hookName}" failed`, {
116
116
  error,
117
117
  });
@@ -123,6 +123,13 @@ export async function runAfterHooks<T>(
123
123
  try {
124
124
  await runHook();
125
125
  } catch (error) {
126
+ // Reported rather than collected: this runs after the commit, so the HookReport below has
127
+ // already been returned to the caller and there is nowhere else for this failure to go.
128
+ reportHookFailure({
129
+ hookName,
130
+ message: error instanceof Error ? error.message : String(error),
131
+ deferred: true,
132
+ });
126
133
  context.logger.error(`After-commit hook "${hookName}" failed`, {
127
134
  error,
128
135
  });
@@ -133,10 +140,9 @@ export async function runAfterHooks<T>(
133
140
  try {
134
141
  await runHook();
135
142
  } catch (error) {
136
- errors.push({
137
- hookName,
138
- message: error instanceof Error ? error.message : String(error),
139
- });
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ errors.push({ hookName, message });
145
+ reportHookFailure({ hookName, message, deferred: false });
140
146
  context.logger.error(`After-hook "${hookName}" failed`, {
141
147
  error,
142
148
  });
@@ -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
+ }
@@ -1,5 +1,29 @@
1
1
  export type HookHandler = (...args: never[]) => unknown;
2
2
 
3
+ /**
4
+ * Handlers a PLUGIN asked to run inside the writing transaction.
5
+ *
6
+ * Module-level rather than per-registry because a plugin's declaration and its registration are
7
+ * separated by the manifest merge: `manifest.hooks()` returns `{ key, handler, inTransaction }`
8
+ * records, which are flattened into `config.hooks` as a bare `key -> handler[]` map long before any
9
+ * HookRegistry exists. Marking the function itself is the only channel that survives that.
10
+ *
11
+ * Shipped in 0.35.1 because 0.35.0 made after-commit the default with no way for a plugin to opt
12
+ * out, which silently moved `loom_projection_pending` — a transactional outbox whose whole point is
13
+ * committing with the write it records — to after the commit.
14
+ */
15
+ const pluginInTransactionHandlers = new WeakSet<object>();
16
+
17
+ /** Declare that this handler must run inside the writing transaction. Called by the manifest merge. */
18
+ export function markHookInTransaction(handler: unknown): void {
19
+ if (typeof handler === "function") pluginInTransactionHandlers.add(handler as object);
20
+ }
21
+
22
+ /** Whether a plugin asked for this handler to run in-transaction. Read by the kernel at boot. */
23
+ export function isHookMarkedInTransaction(handler: unknown): boolean {
24
+ return typeof handler === "function" && pluginInTransactionHandlers.has(handler as object);
25
+ }
26
+
3
27
  type HookEntry = {
4
28
  prepended: HookHandler[];
5
29
  configured: HookHandler[];
@@ -1,4 +1,5 @@
1
1
  import type { Hono } from "hono";
2
+ import { markHookInTransaction } from "../hooks/registry.js";
2
3
  import type { OpenAPIHono, RouteConfig } from "@hono/zod-openapi";
3
4
  import type { PluginDb } from "../database/plugin-types.js";
4
5
  import type { DatabaseAdapter } from "../database/adapter.js";
@@ -43,6 +44,15 @@ export type PluginRouteRegistration =
43
44
  export interface PluginHookRegistration {
44
45
  key: string;
45
46
  handler: (...args: unknown[]) => unknown;
47
+ /**
48
+ * Run this hook INSIDE the writing transaction instead of after it commits.
49
+ *
50
+ * The default is after-commit and is correct for anything with an external effect — a webhook, a
51
+ * search-index write, an email — none of which may announce a write that can still roll back.
52
+ * Set this only for an OUTBOX WRITER: a hook whose own write must commit or roll back together
53
+ * with the row it records. `loom_projection_pending` is the case this exists for.
54
+ */
55
+ inTransaction?: boolean;
46
56
  }
47
57
 
48
58
  // ─── Plugin Context (available to routes at boot time) ───────
@@ -294,6 +304,9 @@ export function defineCommercePlugin(
294
304
  ...(result.hooks ?? {}),
295
305
  };
296
306
  for (const reg of registrations) {
307
+ // The marker rides on the function because this map is a bare key -> handler[] and cannot
308
+ // carry it; the kernel reads it back when it registers these at boot.
309
+ if (reg.inTransaction) markHookInTransaction(reg.handler);
297
310
  hookMap[reg.key] = [...(hookMap[reg.key] ?? []), reg.handler];
298
311
  }
299
312
  result = { ...result, hooks: hookMap };
@@ -1,5 +1,5 @@
1
1
  import type { CommerceConfig } from "../config/types.js";
2
- import { HookRegistry, type HookHandler } from "../kernel/hooks/registry.js";
2
+ import { HookRegistry, isHookMarkedInTransaction, type HookHandler } from "../kernel/hooks/registry.js";
3
3
  import { createDatabaseConnection } from "../kernel/database/adapter.js";
4
4
  import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
5
5
  import type { AppModule } from "../kernel/module/index.js";
@@ -162,7 +162,14 @@ export function createKernel(config: CommerceConfig): Kernel {
162
162
 
163
163
  for (const [key, handlers] of Object.entries(config.hooks ?? {})) {
164
164
  for (const handler of handlers) {
165
- hooks.append(key, handler as HookHandler);
165
+ // A plugin that declared `inTransaction` gets appendInTransaction, so its hook runs inside
166
+ // the writing transaction rather than in the after-commit drain. Everything else defaults to
167
+ // after-commit, which is what 0.35.0 established.
168
+ if (isHookMarkedInTransaction(handler)) {
169
+ hooks.appendInTransaction(key, handler as HookHandler);
170
+ } else {
171
+ hooks.append(key, handler as HookHandler);
172
+ }
166
173
  }
167
174
  }
168
175
 
@@ -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";