@porulle/core 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/kernel/hooks/executor.d.ts.map +1 -1
- package/dist/kernel/hooks/executor.js +24 -1
- package/dist/modules/media/schema.d.ts.map +1 -1
- package/dist/modules/media/schema.js +2 -0
- package/dist/test-utils/create-two-connection-adapter.d.ts +33 -0
- package/dist/test-utils/create-two-connection-adapter.d.ts.map +1 -0
- package/dist/test-utils/create-two-connection-adapter.js +40 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +3 -3
- package/src/kernel/hooks/executor.ts +25 -1
- package/src/modules/media/schema.ts +8 -0
- package/src/test-utils/create-two-connection-adapter.ts +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
"eslint": "^9.39.1",
|
|
63
63
|
"typescript": "5.9.2",
|
|
64
64
|
"vitest": "^3.2.4",
|
|
65
|
-
"@porulle/
|
|
66
|
-
"@porulle/
|
|
65
|
+
"@porulle/typescript-config": "0.1.0",
|
|
66
|
+
"@porulle/eslint-config": "0.1.0"
|
|
67
67
|
},
|
|
68
68
|
"publishConfig": {
|
|
69
69
|
"access": "public"
|
|
@@ -1,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,
|
|
@@ -31,6 +31,14 @@ export const mediaAssets = pgTable(
|
|
|
31
31
|
},
|
|
32
32
|
(table) => ({
|
|
33
33
|
orgIdx: index("idx_media_assets_org").on(table.organizationId),
|
|
34
|
+
orgChannelImageUrlHashIdx: index("idx_media_assets_org_channel_image_url_hash").on(
|
|
35
|
+
table.organizationId,
|
|
36
|
+
sql`(${table.metadata}->>'channelImageUrlHash')`,
|
|
37
|
+
),
|
|
38
|
+
orgChannelImageExternalIdIdx: index("idx_media_assets_org_channel_image_external_id").on(
|
|
39
|
+
table.organizationId,
|
|
40
|
+
sql`(${table.metadata}->>'channelImageExternalId')`,
|
|
41
|
+
),
|
|
34
42
|
}),
|
|
35
43
|
);
|
|
36
44
|
|
|
@@ -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
|
+
}
|