@porulle/core 0.36.0 → 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;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
+ {"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"}
@@ -6,6 +6,29 @@ export function mergeHookReports(a, b) {
6
6
  hasErrors: a.hasErrors || b.hasErrors,
7
7
  };
8
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
+ }
9
32
  /** Default hook timeout: 20 seconds */
10
33
  const HOOK_TIMEOUT_MS = 20_000;
11
34
  function withTimeout(promiseOrValue, timeoutMs, hookName) {
@@ -58,7 +81,7 @@ export async function runAfterHooks(hooks, originalData, committedResult, operat
58
81
  result: committedResult,
59
82
  operation,
60
83
  context: runsInTransaction(hook)
61
- ? context
84
+ ? inTransactionHookContext(context)
62
85
  : { ...context, tx: null },
63
86
  }), HOOK_TIMEOUT_MS, hookName);
64
87
  if (runsInTransaction(hook)) {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -62,8 +62,8 @@
62
62
  "eslint": "^9.39.1",
63
63
  "typescript": "5.9.2",
64
64
  "vitest": "^3.2.4",
65
- "@porulle/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,4 +1,5 @@
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";
3
4
  import { reportHookFailure } from "./failures.js";
4
5
 
@@ -19,6 +20,29 @@ export function mergeHookReports(a: HookReport, b: HookReport): HookReport {
19
20
  };
20
21
  }
21
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
+
22
46
  /** Default hook timeout: 20 seconds */
23
47
  const HOOK_TIMEOUT_MS = 20_000;
24
48
 
@@ -98,7 +122,7 @@ export async function runAfterHooks<T>(
98
122
  result: committedResult,
99
123
  operation,
100
124
  context: runsInTransaction(hook)
101
- ? context
125
+ ? inTransactionHookContext(context)
102
126
  : { ...context, tx: null },
103
127
  }),
104
128
  HOOK_TIMEOUT_MS,
@@ -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
+ }