@porulle/core 0.34.0 → 0.35.1

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.
Files changed (43) hide show
  1. package/dist/kernel/database/adapter.d.ts.map +1 -1
  2. package/dist/kernel/database/adapter.js +23 -1
  3. package/dist/kernel/hooks/deferred.d.ts +4 -0
  4. package/dist/kernel/hooks/deferred.d.ts.map +1 -0
  5. package/dist/kernel/hooks/deferred.js +44 -0
  6. package/dist/kernel/hooks/executor.d.ts +1 -1
  7. package/dist/kernel/hooks/executor.d.ts.map +1 -1
  8. package/dist/kernel/hooks/executor.js +50 -16
  9. package/dist/kernel/hooks/registry.d.ts +8 -0
  10. package/dist/kernel/hooks/registry.d.ts.map +1 -1
  11. package/dist/kernel/hooks/registry.js +34 -0
  12. package/dist/kernel/plugin/manifest.d.ts +9 -0
  13. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  14. package/dist/kernel/plugin/manifest.js +5 -0
  15. package/dist/modules/catalog/entity-service.js +7 -7
  16. package/dist/modules/customers/service.js +4 -4
  17. package/dist/modules/fulfillment/service.js +2 -2
  18. package/dist/modules/inventory/service.d.ts.map +1 -1
  19. package/dist/modules/inventory/service.js +1 -1
  20. package/dist/modules/orders/service.d.ts.map +1 -1
  21. package/dist/modules/orders/service.js +3 -3
  22. package/dist/modules/promotions/service.js +3 -3
  23. package/dist/runtime/kernel-register-hooks.js +1 -1
  24. package/dist/runtime/kernel.d.ts.map +1 -1
  25. package/dist/runtime/kernel.js +10 -2
  26. package/dist/test-utils/create-pglite-adapter.d.ts +5 -1
  27. package/dist/test-utils/create-pglite-adapter.d.ts.map +1 -1
  28. package/dist/test-utils/create-pglite-adapter.js +15 -11
  29. package/package.json +1 -1
  30. package/src/kernel/database/adapter.ts +24 -1
  31. package/src/kernel/hooks/deferred.ts +53 -0
  32. package/src/kernel/hooks/executor.ts +48 -11
  33. package/src/kernel/hooks/registry.ts +39 -0
  34. package/src/kernel/plugin/manifest.ts +13 -0
  35. package/src/modules/catalog/entity-service.ts +7 -7
  36. package/src/modules/customers/service.ts +4 -4
  37. package/src/modules/fulfillment/service.ts +2 -2
  38. package/src/modules/inventory/service.ts +1 -0
  39. package/src/modules/orders/service.ts +3 -1
  40. package/src/modules/promotions/service.ts +3 -3
  41. package/src/runtime/kernel-register-hooks.ts +1 -1
  42. package/src/runtime/kernel.ts +9 -2
  43. package/src/test-utils/create-pglite-adapter.ts +20 -2
@@ -38,8 +38,12 @@ export interface QueryLog {
38
38
  * - db: The Drizzle ORM instance for direct queries
39
39
  * - cleanup: Function to truncate all tables (call between tests)
40
40
  */
41
+ /** The test adapter carries one thing a production adapter does not: see `inTransaction`. */
42
+ export type PGliteTestAdapter = DatabaseAdapter & {
43
+ inTransaction(): boolean;
44
+ };
41
45
  export declare function createPGliteTestAdapter(): Promise<{
42
- adapter: DatabaseAdapter;
46
+ adapter: PGliteTestAdapter;
43
47
  db: DrizzleDatabase;
44
48
  cleanup: () => Promise<void>;
45
49
  queryLog: QueryLog;
@@ -1 +1 @@
1
- {"version":3,"file":"create-pglite-adapter.d.ts","sourceRoot":"","sources":["../../src/test-utils/create-pglite-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAMrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB,4DAA4D;IAC5D,KAAK,IAAI,IAAI,CAAC;IACd,mEAAmE;IACnE,IAAI,IAAI,MAAM,EAAE,CAAC;CAClB;AA0BD;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC;IACvD,OAAO,EAAE,eAAe,CAAC;IACzB,EAAE,EAAE,eAAe,CAAC;IACpB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,QAAQ,EAAE,QAAQ,CAAC;CACpB,CAAC,CA6GD"}
1
+ {"version":3,"file":"create-pglite-adapter.d.ts","sourceRoot":"","sources":["../../src/test-utils/create-pglite-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAMrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB,4DAA4D;IAC5D,KAAK,IAAI,IAAI,CAAC;IACd,mEAAmE;IACnE,IAAI,IAAI,MAAM,EAAE,CAAC;CAClB;AA0BD;;;;;;;;;;GAUG;AACH,6FAA6F;AAC7F,MAAM,MAAM,iBAAiB,GAAG,eAAe,GAAG;IAAE,aAAa,IAAI,OAAO,CAAA;CAAE,CAAC;AAE/E,wBAAsB,uBAAuB,IAAI,OAAO,CAAC;IACvD,OAAO,EAAE,iBAAiB,CAAC;IAC3B,EAAE,EAAE,eAAe,CAAC;IACpB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,QAAQ,EAAE,QAAQ,CAAC;CACpB,CAAC,CA4HD"}
@@ -36,17 +36,6 @@ async function pushCoreSchema(db) {
36
36
  const { apply } = await drizzleKit.pushSchema(coreSchema, db);
37
37
  await apply();
38
38
  }
39
- /**
40
- * Creates a PGlite-backed database adapter for testing.
41
- *
42
- * Each call creates a new isolated PGlite instance with its own
43
- * in-memory database. Core schema is pushed once during initialization.
44
- *
45
- * @returns A promise resolving to an object containing:
46
- * - adapter: The DatabaseAdapter for use with createKernel
47
- * - db: The Drizzle ORM instance for direct queries
48
- * - cleanup: Function to truncate all tables (call between tests)
49
- */
50
39
  export async function createPGliteTestAdapter() {
51
40
  // Create in-memory PGlite instance
52
41
  const pg = new PGlite();
@@ -128,6 +117,21 @@ export async function createPGliteTestAdapter() {
128
117
  provider: "postgresql",
129
118
  db,
130
119
  transaction,
120
+ /**
121
+ * Whether a transaction body is executing RIGHT NOW on this adapter.
122
+ *
123
+ * Exposed for one reason: an after-hook that fires before its transaction commits is
124
+ * invisible to every assertion a PGlite suite can otherwise make. This adapter hands the
125
+ * transaction body the SAME `db` handle it hands everyone else, so a hook's query inside an
126
+ * open transaction succeeds and reads uncommitted rows exactly as if it had committed — the
127
+ * instrument is blind to the defect by construction. A recording jobs adapter stamping each
128
+ * enqueue with this flag is the only thing in a PGlite test that can tell "enqueued after
129
+ * commit" from "enqueued inside the transaction that may still roll back".
130
+ *
131
+ * Test-utils only. Production adapters do not carry it, and nothing outside a test may branch
132
+ * on it — a behaviour that depends on this flag would be a behaviour no production adapter has.
133
+ */
134
+ inTransaction: () => inTransaction,
131
135
  };
132
136
  /**
133
137
  * Cleanup function to reset data between tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,3 +1,5 @@
1
+ import { withDeferredHooks } from "../hooks/deferred.js";
2
+
1
3
  /**
2
4
  * Database adapter interface for the commerce engine.
3
5
  *
@@ -72,6 +74,24 @@ function normalizeExecuteShape<T extends object>(db: T): T {
72
74
  return result;
73
75
  };
74
76
  }
77
+ if (prop === "transaction") {
78
+ const orig = Reflect.get(target, prop, target);
79
+ if (typeof orig !== "function") return orig;
80
+ // The OTHER half of the after-commit drain, and the one the card this shipped for
81
+ // actually needs. `createDatabaseConnection().transaction` covers core services and REST
82
+ // routes, but a plugin does not get the adapter — it is handed this db HANDLE as
83
+ // `ctx.db` (TaskContext.db is a DrizzleDatabase) and calls `ctx.db.transaction(...)`.
84
+ // `plugin-channel-connector` constructs its service with no transaction argument in
85
+ // production and therefore imports every product through exactly this path, so without
86
+ // the wrapper here the fix would be green in every suite (they inject the adapter's
87
+ // transaction explicitly) and inert on the deployed import.
88
+ //
89
+ // Double-wrapping is safe: `withDeferredHooks` joins an existing store and drains only at
90
+ // the outermost boundary, so adapter.transaction -> db.transaction nests without draining
91
+ // twice.
92
+ return (fn: (tx: unknown) => Promise<unknown>) =>
93
+ withDeferredHooks(() => (orig as (f: typeof fn) => Promise<unknown>).call(target, fn));
94
+ }
75
95
  const value = Reflect.get(target, prop, target);
76
96
  // Bind methods to the real instance so drizzle's internals (incl. any
77
97
  // private fields) are never accessed through the proxy.
@@ -86,7 +106,10 @@ export function createDatabaseConnection(input: DatabaseConnectionFactoryInput):
86
106
  provider: adapter.provider,
87
107
  db: normalizeExecuteShape(adapter.db as object),
88
108
  transaction<T>(fn: (tx: unknown) => Promise<T>): Promise<T> {
89
- return adapter.transaction((tx) => fn(normalizeExecuteShape(tx as object)));
109
+ return withDeferredHooks(() =>
110
+ // adapter.transaction resolving IS the commit, so draining after it
111
+ // resolves is draining after commit.
112
+ adapter.transaction((tx) => fn(normalizeExecuteShape(tx as object))));
90
113
  },
91
114
  };
92
115
  }
@@ -0,0 +1,53 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ interface DeferredStore {
4
+ deferred: Array<() => Promise<void>>;
5
+ }
6
+
7
+ const deferredStorage = new AsyncLocalStorage<DeferredStore>();
8
+
9
+ export function isInsideTransaction(): boolean {
10
+ return deferredStorage.getStore() != null;
11
+ }
12
+
13
+ export function deferAfterCommit(thunk: () => Promise<void>): boolean {
14
+ const pending = deferredStorage.getStore();
15
+ if (!pending) return false;
16
+ pending.deferred.push(thunk);
17
+ return true;
18
+ }
19
+
20
+ async function drainDeferred(pending: DeferredStore): Promise<void> {
21
+ // `for...of` over a live array on purpose: a deferred hook that defers another one is drained
22
+ // in the same pass rather than being dropped.
23
+ for (const thunk of pending.deferred) {
24
+ try {
25
+ await thunk();
26
+ } catch {
27
+ // Each thunk logs its own hook failure (see runAfterHooks); swallow so siblings still run.
28
+ }
29
+ }
30
+ }
31
+
32
+ export async function withDeferredHooks<T>(fn: () => Promise<T>): Promise<T> {
33
+ const existing = deferredStorage.getStore();
34
+ if (existing) {
35
+ return fn();
36
+ }
37
+
38
+ const pending: DeferredStore = { deferred: [] };
39
+ // A throw propagates without reaching the drain: a rolled-back transaction discards its deferred
40
+ // hooks rather than announcing a write that never happened. Stated rather than implied.
41
+ const result = await deferredStorage.run(pending, fn);
42
+
43
+ // AWAITED, never `void`. `adapter.transaction` resolving IS the commit, so the hooks below run
44
+ // after commit — and they must still run INSIDE the invocation that started them. The Workers
45
+ // runtime discards a promise that is neither awaited nor handed to `waitUntil` once the
46
+ // invocation ends, which this codebase has already paid for once: a fire-and-forget queue nudge
47
+ // wrote 104 outbox rows and sent ZERO messages on the deployed Worker while every Node test
48
+ // passed. Detaching this drain would do the same to every webhook and search-index update in the
49
+ // product. "After commit" is the requirement; "after the service method returns" is not, and is
50
+ // what tempts you to detach it.
51
+ await drainDeferred(pending);
52
+ return result;
53
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AfterHook, BeforeHook, HookContext, HookOperation } from "./types.js";
2
+ import { deferAfterCommit } from "./deferred.js";
2
3
 
3
4
  export interface HookError {
4
5
  hookName: string;
@@ -84,30 +85,66 @@ export async function runAfterHooks<T>(
84
85
  committedResult: T,
85
86
  operation: HookOperation,
86
87
  context: HookContext,
88
+ runsInTransaction: (hook: AfterHook<T>) => boolean = () => false,
87
89
  ): Promise<HookReport> {
88
90
  const errors: HookError[] = [];
89
91
  for (const hook of hooks) {
90
92
  const hookName = hook.name || "(anonymous afterHook)";
91
- try {
92
- await withTimeout(
93
+ const runHook = () =>
94
+ withTimeout(
93
95
  hook({
94
96
  data: originalData,
95
97
  result: committedResult,
96
98
  operation,
97
- context,
99
+ context: runsInTransaction(hook)
100
+ ? context
101
+ : { ...context, tx: null },
98
102
  }),
99
103
  HOOK_TIMEOUT_MS,
100
104
  hookName,
101
105
  );
102
- } catch (error) {
103
- errors.push({
104
- hookName,
105
- message: error instanceof Error ? error.message : String(error),
106
- });
107
- context.logger.error(`After-hook "${hookName}" failed`, {
108
- error,
109
- });
106
+
107
+ if (runsInTransaction(hook)) {
108
+ try {
109
+ await runHook();
110
+ } catch (error) {
111
+ errors.push({
112
+ hookName,
113
+ message: error instanceof Error ? error.message : String(error),
114
+ });
115
+ context.logger.error(`After-hook "${hookName}" failed`, {
116
+ error,
117
+ });
118
+ }
119
+ continue;
120
+ }
121
+
122
+ const deferred = deferAfterCommit(async () => {
123
+ try {
124
+ await runHook();
125
+ } catch (error) {
126
+ context.logger.error(`After-commit hook "${hookName}" failed`, {
127
+ error,
128
+ });
129
+ }
130
+ });
131
+
132
+ if (!deferred) {
133
+ try {
134
+ await runHook();
135
+ } catch (error) {
136
+ errors.push({
137
+ hookName,
138
+ message: error instanceof Error ? error.message : String(error),
139
+ });
140
+ context.logger.error(`After-hook "${hookName}" failed`, {
141
+ error,
142
+ });
143
+ }
110
144
  }
111
145
  }
146
+ // After-commit hook failures are logged when the transaction drains; they
147
+ // cannot appear here because deferred hooks run after commit, once the
148
+ // service method has already returned its Result.
112
149
  return { errors, hasErrors: errors.length > 0 };
113
150
  }
@@ -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[];
@@ -8,6 +32,7 @@ type HookEntry = {
8
32
 
9
33
  export class HookRegistry {
10
34
  private registry = new Map<string, HookEntry>();
35
+ private inTransactionHandlers = new WeakSet<HookHandler>();
11
36
  private logger?: { error: (obj: Record<string, unknown>, msg: string) => void };
12
37
 
13
38
  registerConfigHooks(hookName: string, handlers: HookHandler[]): void {
@@ -20,11 +45,25 @@ export class HookRegistry {
20
45
  this.registry.get(hookName)!.appended.push(handler);
21
46
  }
22
47
 
48
+ appendInTransaction(hookName: string, handler: HookHandler): void {
49
+ this.inTransactionHandlers.add(handler);
50
+ this.append(hookName, handler);
51
+ }
52
+
23
53
  prepend(hookName: string, handler: HookHandler): void {
24
54
  this.ensureEntry(hookName);
25
55
  this.registry.get(hookName)!.prepended.push(handler);
26
56
  }
27
57
 
58
+ prependInTransaction(hookName: string, handler: HookHandler): void {
59
+ this.inTransactionHandlers.add(handler);
60
+ this.prepend(hookName, handler);
61
+ }
62
+
63
+ runsInTransaction(handler: HookHandler): boolean {
64
+ return this.inTransactionHandlers.has(handler);
65
+ }
66
+
28
67
  resolve(hookName: string): HookHandler[] {
29
68
  const entry = this.registry.get(hookName);
30
69
  if (!entry) return [];
@@ -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 };
@@ -285,7 +285,7 @@ export class EntityService {
285
285
  if (existingBySlug) return Err(new CommerceConflictError(`Entity with slug ${input.slug} already exists.`));
286
286
  const beforeHooks = this.deps.hooks.resolve("catalog.beforeCreate") as CatalogCreateBeforeHook[];
287
287
  const afterHooks = this.deps.hooks.resolve("catalog.afterCreate") as CatalogCreateAfterHook[];
288
- const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.create"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database), commerceConfig: this.deps.config });
288
+ const context: HookContext = catalogHookContext(this.deps, actor, ctx, "create");
289
289
  const processedInput = await runBeforeHooks(beforeHooks, input, "create", context);
290
290
  if (processedInput.sourceStoreId != null) assertPermission(actor, "catalog:sync");
291
291
  const customFieldsResult = await this.validateCustomFields(processedInput.type, processedInput.customFields, actor, ctx);
@@ -297,7 +297,7 @@ export class EntityService {
297
297
  await this.repo.createAttribute({ entityId: entity.id, locale: processedInput.attributes.locale ?? "en", title: processedInput.attributes.title, subtitle: processedInput.attributes.subtitle, description: processedInput.attributes.description, richDescription: processedInput.attributes.richDescription, seoTitle: processedInput.attributes.seoTitle, seoDescription: processedInput.attributes.seoDescription }, ctx);
298
298
  }
299
299
  await this.writeCustomFields(entity.id, customFieldsResult.value, ctx);
300
- const hookReport = await runAfterHooks(afterHooks, null, entity, "create", context);
300
+ const hookReport = await runAfterHooks(afterHooks, null, entity, "create", context, (hook) => this.deps.hooks.runsInTransaction(hook));
301
301
  const hydrated = await this.hydrateEntity(entity, undefined, ctx);
302
302
  return Ok(hydrated, hookReport.hasErrors ? { hookErrors: hookReport.errors } : undefined);
303
303
  } catch (error) { return Err(toCommerceError(error)); } }
@@ -318,7 +318,7 @@ export class EntityService {
318
318
  const updated = await this.repo.updateEntity(id, { ...(processed.slug !== undefined ? { slug: processed.slug } : {}), ...(processed.status !== undefined ? { status: processed.status as SellableEntity["status"] } : {}), ...(processed.taxClass !== undefined ? { taxClass: processed.taxClass } : {}), ...(processed.metadata !== undefined ? { metadata: processed.metadata } : {}), ...(processed.isVisible !== undefined ? { isVisible: processed.isVisible } : {}) }, txCtx);
319
319
  if (!updated) return Err(new CommerceNotFoundError("Entity not found."));
320
320
  await this.writeCustomFields(existing.id, customFieldsResult.value, txCtx, true);
321
- const hookReport = await runAfterHooks(afterHooks, existing, updated, "update", context);
321
+ const hookReport = await runAfterHooks(afterHooks, existing, updated, "update", context, (hook) => this.deps.hooks.runsInTransaction(hook));
322
322
  const hydrated = await this.hydrateEntity(updated, undefined, txCtx);
323
323
  return Ok(hydrated, hookReport.hasErrors ? { hookErrors: hookReport.errors } : undefined);
324
324
  } catch (error) { return Err(toCommerceError(error)); } }
@@ -367,7 +367,7 @@ export class EntityService {
367
367
  }
368
368
  const result = await this.hydrateEntity(entity, processed.options ?? options, ctx);
369
369
  const entityAfterHooks = this.deps.hooks.resolve(`catalog.${entity.type}.afterRead`) as CatalogReadAfterHook[];
370
- const report = mergeHookReports(await runAfterHooks(globalAfterHooks, null, result, "read", context), await runAfterHooks(entityAfterHooks, null, result, "read", context));
370
+ const report = mergeHookReports(await runAfterHooks(globalAfterHooks, null, result, "read", context, (hook) => this.deps.hooks.runsInTransaction(hook)), await runAfterHooks(entityAfterHooks, null, result, "read", context, (hook) => this.deps.hooks.runsInTransaction(hook)));
371
371
  return Ok(result, report.hasErrors ? { hookErrors: report.errors } : undefined);
372
372
  }
373
373
 
@@ -418,7 +418,7 @@ export class EntityService {
418
418
  }
419
419
  const result = await this.hydrateEntity(entity, processed.options ?? options, ctx);
420
420
  const entityAfterHooks = this.deps.hooks.resolve(`catalog.${entity.type}.afterRead`) as CatalogReadAfterHook[];
421
- const report = mergeHookReports(await runAfterHooks(globalAfterHooks, null, result, "read", context), await runAfterHooks(entityAfterHooks, null, result, "read", context));
421
+ const report = mergeHookReports(await runAfterHooks(globalAfterHooks, null, result, "read", context, (hook) => this.deps.hooks.runsInTransaction(hook)), await runAfterHooks(entityAfterHooks, null, result, "read", context, (hook) => this.deps.hooks.runsInTransaction(hook)));
422
422
  return Ok(result, report.hasErrors ? { hookErrors: report.errors } : undefined);
423
423
  }
424
424
 
@@ -490,7 +490,7 @@ export class EntityService {
490
490
  const result = { items: hydratedItems, pagination: paged.pagination };
491
491
  const listTypeFilter = processed.filter?.type;
492
492
  const entityAfterHooks = listTypeFilter ? (this.deps.hooks.resolve(`catalog.${listTypeFilter}.afterList`) as CatalogListAfterHook[]) : [];
493
- const report = mergeHookReports(await runAfterHooks(globalAfterHooks, null, result, "list", context), await runAfterHooks(entityAfterHooks, null, result, "list", context));
493
+ const report = mergeHookReports(await runAfterHooks(globalAfterHooks, null, result, "list", context, (hook) => this.deps.hooks.runsInTransaction(hook)), await runAfterHooks(entityAfterHooks, null, result, "list", context, (hook) => this.deps.hooks.runsInTransaction(hook)));
494
494
  return Ok(result, report.hasErrors ? { hookErrors: report.errors } : undefined);
495
495
  }
496
496
 
@@ -533,7 +533,7 @@ export class EntityService {
533
533
  const afterHooks = this.deps.hooks.resolve("catalog.afterUpdate") as CatalogUpdateAfterHook[];
534
534
  const context = catalogHookContext(this.deps, actor, ctx, "update");
535
535
  context.context.changedFieldPaths = changedFieldPaths;
536
- await runAfterHooks(afterHooks, entity, entity, "update", context);
536
+ await runAfterHooks(afterHooks, entity, entity, "update", context, (hook) => this.deps.hooks.runsInTransaction(hook));
537
537
  }
538
538
  return Ok(undefined);
539
539
  }
@@ -102,7 +102,7 @@ export class CustomerService {
102
102
  "customers.afterCreate",
103
103
  ) as AfterHook<Customer>[];
104
104
  const hctx = hookContext(actor ?? null, this.deps.services, this.deps.database, this.deps.config, ctx?.tx ?? null);
105
- await runAfterHooks(afterHooks, null, customer, "create", hctx);
105
+ await runAfterHooks(afterHooks, null, customer, "create", hctx, (hook) => this.deps.hooks.runsInTransaction(hook));
106
106
 
107
107
  return Ok(customer);
108
108
  }
@@ -195,7 +195,7 @@ export class CustomerService {
195
195
  "customers.afterCreate",
196
196
  ) as AfterHook<Customer>[];
197
197
  const hctx = hookContext(actor, this.deps.services, this.deps.database, this.deps.config, ctx?.tx ?? null);
198
- await runAfterHooks(afterHooks, null, customer, "create", hctx);
198
+ await runAfterHooks(afterHooks, null, customer, "create", hctx, (hook) => this.deps.hooks.runsInTransaction(hook));
199
199
 
200
200
  return customer;
201
201
  }
@@ -277,7 +277,7 @@ export class CustomerService {
277
277
  const hctx = hookContext(
278
278
  actor ?? ctx?.actor ?? null, this.deps.services, this.deps.database, this.deps.config, ctx?.tx ?? null,
279
279
  );
280
- await runAfterHooks(afterHooks, existing, updated, "update", hctx);
280
+ await runAfterHooks(afterHooks, existing, updated, "update", hctx, (hook) => this.deps.hooks.runsInTransaction(hook));
281
281
 
282
282
  return Ok(updated);
283
283
  }
@@ -308,7 +308,7 @@ export class CustomerService {
308
308
  "customers.afterUpdate",
309
309
  ) as AfterHook<Customer>[];
310
310
  const hctx = hookContext(resolvedActor, this.deps.services, this.deps.database, this.deps.config, ctx?.tx ?? null);
311
- await runAfterHooks(afterHooks, customer, updated, "update", hctx);
311
+ await runAfterHooks(afterHooks, customer, updated, "update", hctx, (hook) => this.deps.hooks.runsInTransaction(hook));
312
312
 
313
313
  return Ok(updated);
314
314
  }
@@ -331,7 +331,7 @@ export class FulfillmentService {
331
331
  database: { db: this.deps.database.db as PluginDb },
332
332
  commerceConfig: this.deps.config,
333
333
  });
334
- await runAfterHooks(afterHooks, null, record, "create", hookCtx);
334
+ await runAfterHooks(afterHooks, null, record, "create", hookCtx, (hook) => this.deps.hooks.runsInTransaction(hook));
335
335
 
336
336
  // Create a fulfillment line item linking this fulfillment to the order line item
337
337
  for (const li of record.lineItems) {
@@ -490,7 +490,7 @@ export class FulfillmentService {
490
490
  database: { db: this.deps.database.db as PluginDb },
491
491
  commerceConfig: this.deps.config,
492
492
  });
493
- await runAfterHooks(afterHooks, null, record, "create", hookCtx);
493
+ await runAfterHooks(afterHooks, null, record, "create", hookCtx, (hook) => this.deps.hooks.runsInTransaction(hook));
494
494
 
495
495
  return Ok(record);
496
496
  }
@@ -446,6 +446,7 @@ export class InventoryService {
446
446
  level,
447
447
  "update",
448
448
  hookCtx,
449
+ (hook) => this.deps.hooks.runsInTransaction(hook),
449
450
  );
450
451
 
451
452
  return Ok({ level, before, after, delta, movementId: movement.id });
@@ -749,6 +749,7 @@ export class OrderService {
749
749
  hydrated,
750
750
  "create",
751
751
  hookCtx,
752
+ (hook) => this.deps.hooks.runsInTransaction(hook),
752
753
  );
753
754
 
754
755
  return Ok(
@@ -797,7 +798,7 @@ export class OrderService {
797
798
  ) as AfterHook<HydratedOrder>[];
798
799
  if (afterGetHooks.length > 0) {
799
800
  const hookCtx = context(actor, this.deps.services, this.deps.database, this.deps.config, ctx?.tx);
800
- await runAfterHooks(afterGetHooks, null, hydrated, "read", hookCtx);
801
+ await runAfterHooks(afterGetHooks, null, hydrated, "read", hookCtx, (hook) => this.deps.hooks.runsInTransaction(hook));
801
802
  }
802
803
 
803
804
  return Ok(hydrated);
@@ -1257,6 +1258,7 @@ export class OrderService {
1257
1258
  hydrated,
1258
1259
  "statusChange",
1259
1260
  hookCtx,
1261
+ (hook) => this.deps.hooks.runsInTransaction(hook),
1260
1262
  );
1261
1263
 
1262
1264
  return Ok(
@@ -245,7 +245,7 @@ export class PromotionService {
245
245
  const hctx = hookContext(
246
246
  actor ?? ctx?.actor ?? null, this.deps.services, this.deps.database, this.deps.config, ctx?.tx ?? null,
247
247
  );
248
- await runAfterHooks(afterHooks, null, promotion, "create", hctx);
248
+ await runAfterHooks(afterHooks, null, promotion, "create", hctx, (hook) => this.deps.hooks.runsInTransaction(hook));
249
249
 
250
250
  return Ok(promotion);
251
251
  }
@@ -266,7 +266,7 @@ export class PromotionService {
266
266
  ) as AfterHook<Promotion>[];
267
267
  // Actor-less by design; resolves to the deployment's declared organization.
268
268
  const hctx = hookContext(null, this.deps.services, this.deps.database, this.deps.config, ctx?.tx ?? null);
269
- await runAfterHooks(afterHooks, promotion, updated, "update", hctx);
269
+ await runAfterHooks(afterHooks, promotion, updated, "update", hctx, (hook) => this.deps.hooks.runsInTransaction(hook));
270
270
 
271
271
  return Ok(updated);
272
272
  }
@@ -345,7 +345,7 @@ export class PromotionService {
345
345
  const hctx = hookContext(
346
346
  actor ?? ctx?.actor ?? null, this.deps.services, this.deps.database, this.deps.config, ctx?.tx ?? null,
347
347
  );
348
- await runAfterHooks(afterHooks, existing, updated, "update", hctx);
348
+ await runAfterHooks(afterHooks, existing, updated, "update", hctx, (hook) => this.deps.hooks.runsInTransaction(hook));
349
349
 
350
350
  return Ok(updated);
351
351
  }
@@ -58,6 +58,6 @@ export function registerConfiguredKernelHooks(
58
58
  hooks.append("catalog.afterUpdate", syncToSearchIndex);
59
59
 
60
60
  for (const [key, handler] of Object.entries(auditHooks)) {
61
- hooks.append(key, handler);
61
+ hooks.appendInTransaction(key, handler);
62
62
  }
63
63
  }
@@ -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
 
@@ -74,8 +74,11 @@ async function pushCoreSchema(db: DrizzleDatabase): Promise<void> {
74
74
  * - db: The Drizzle ORM instance for direct queries
75
75
  * - cleanup: Function to truncate all tables (call between tests)
76
76
  */
77
+ /** The test adapter carries one thing a production adapter does not: see `inTransaction`. */
78
+ export type PGliteTestAdapter = DatabaseAdapter & { inTransaction(): boolean };
79
+
77
80
  export async function createPGliteTestAdapter(): Promise<{
78
- adapter: DatabaseAdapter;
81
+ adapter: PGliteTestAdapter;
79
82
  db: DrizzleDatabase;
80
83
  cleanup: () => Promise<void>;
81
84
  queryLog: QueryLog;
@@ -160,10 +163,25 @@ export async function createPGliteTestAdapter(): Promise<{
160
163
  return run as Promise<T>;
161
164
  }
162
165
 
163
- const adapter: DatabaseAdapter = {
166
+ const adapter: DatabaseAdapter & { inTransaction(): boolean } = {
164
167
  provider: "postgresql",
165
168
  db,
166
169
  transaction,
170
+ /**
171
+ * Whether a transaction body is executing RIGHT NOW on this adapter.
172
+ *
173
+ * Exposed for one reason: an after-hook that fires before its transaction commits is
174
+ * invisible to every assertion a PGlite suite can otherwise make. This adapter hands the
175
+ * transaction body the SAME `db` handle it hands everyone else, so a hook's query inside an
176
+ * open transaction succeeds and reads uncommitted rows exactly as if it had committed — the
177
+ * instrument is blind to the defect by construction. A recording jobs adapter stamping each
178
+ * enqueue with this flag is the only thing in a PGlite test that can tell "enqueued after
179
+ * commit" from "enqueued inside the transaction that may still roll back".
180
+ *
181
+ * Test-utils only. Production adapters do not carry it, and nothing outside a test may branch
182
+ * on it — a behaviour that depends on this flag would be a behaviour no production adapter has.
183
+ */
184
+ inTransaction: () => inTransaction,
167
185
  };
168
186
 
169
187
  /**