@effectstream/db 0.100.19 → 0.100.21
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/migrations/system-up-v-0.0.0.sql +0 -2
- package/package.json +7 -7
- package/src/MaterializedViewStrategy.ts +50 -0
- package/src/capabilities.ts +118 -0
- package/src/dynamic-tables.ts +26 -4
- package/src/mod.ts +7 -0
- package/src/sql/rollup_inputs.queries.ts +55 -0
- package/src/sql/rollup_inputs.sql +11 -0
- package/src/unhandled-rejection-wiring.test.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/db",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.21",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -21,15 +21,15 @@
|
|
|
21
21
|
"./version": "./migrations/system-version.ts"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@effectstream/coroutine": "0.100.
|
|
25
|
-
"@effectstream/log": "0.100.
|
|
26
|
-
"@effectstream/runtime": "0.100.
|
|
27
|
-
"@effectstream/sync": "0.100.
|
|
28
|
-
"@effectstream/utils": "0.100.
|
|
24
|
+
"@effectstream/coroutine": "0.100.21",
|
|
25
|
+
"@effectstream/log": "0.100.21",
|
|
26
|
+
"@effectstream/runtime": "0.100.21",
|
|
27
|
+
"@effectstream/sync": "0.100.21",
|
|
28
|
+
"@effectstream/utils": "0.100.21",
|
|
29
29
|
"@electric-sql/pglite": "^0.3.14",
|
|
30
30
|
"@pgtyped/runtime": "2.4.2",
|
|
31
31
|
"@pgtyped/parser": "2.4.2",
|
|
32
|
-
"pg": "8.
|
|
32
|
+
"pg": "8.21.0",
|
|
33
33
|
"pg-tx": "^1.0.1",
|
|
34
34
|
"effection": "^3.5.0",
|
|
35
35
|
"pg-gateway": "^0.3.0-beta.4"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy for publishing the read-side relation that primitives query
|
|
3
|
+
* (e.g. `primitives.erc20_balances_view_<name>`).
|
|
4
|
+
*
|
|
5
|
+
* The intermediate table and the maintenance trigger are identical in both
|
|
6
|
+
* modes — only how the view itself is created differs. The query API
|
|
7
|
+
* (`SELECT * FROM primitives.<viewName>`) is unchanged across strategies.
|
|
8
|
+
*/
|
|
9
|
+
export interface MaterializedViewStrategy {
|
|
10
|
+
/**
|
|
11
|
+
* Stable identifier used to namespace migration rows per strategy.
|
|
12
|
+
* Empty string for the legacy pg_ivm strategy, so its migration rows keep
|
|
13
|
+
* the same names that existing deployments already have in their
|
|
14
|
+
* `effectstream_migration_history`. Only the new fallback strategy uses a
|
|
15
|
+
* suffixed name. This avoids re-applying dynamic-table DDL (which would
|
|
16
|
+
* fail on already-existing triggers/IMMVs) when an existing app upgrades.
|
|
17
|
+
*/
|
|
18
|
+
readonly migrationSuffix: "" | "-view";
|
|
19
|
+
/** Emit DDL that publishes `viewName` as a queryable relation backed by `selectSql`. */
|
|
20
|
+
createView(viewName: string, selectSql: string): string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Uses the `pg_ivm` extension to create an incrementally maintained
|
|
25
|
+
* materialized view. Requires the extension to be installed in the database.
|
|
26
|
+
*
|
|
27
|
+
* Migration rows for this strategy use the historical unsuffixed name
|
|
28
|
+
* (`dynamic-tables-<type>-<name>`) so deployments that were already running
|
|
29
|
+
* the engine before the strategy pattern existed keep matching their
|
|
30
|
+
* previously-applied rows on upgrade — no re-application, no DDL conflicts.
|
|
31
|
+
*/
|
|
32
|
+
export const pgIvmStrategy: MaterializedViewStrategy = {
|
|
33
|
+
migrationSuffix: "",
|
|
34
|
+
createView: (viewName, selectSql) =>
|
|
35
|
+
`SELECT pgivm.create_immv('${viewName}', $IVM$${selectSql}$IVM$);`,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fallback: a plain SQL view computed on read. The intermediate table is
|
|
40
|
+
* still trigger-maintained, so the only extra cost is re-applying the
|
|
41
|
+
* (typically cheap) WHERE filter at query time.
|
|
42
|
+
*
|
|
43
|
+
* Migration rows use a `-view` suffix to keep this strategy's history
|
|
44
|
+
* distinct from the pg_ivm strategy's.
|
|
45
|
+
*/
|
|
46
|
+
export const plainViewStrategy: MaterializedViewStrategy = {
|
|
47
|
+
migrationSuffix: "-view",
|
|
48
|
+
createView: (viewName, selectSql) =>
|
|
49
|
+
`CREATE OR REPLACE VIEW ${viewName} AS ${selectSql};`,
|
|
50
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { PoolClient } from "pg";
|
|
2
|
+
import { ENV } from "@effectstream/utils/node-env";
|
|
3
|
+
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
4
|
+
import {
|
|
5
|
+
type MaterializedViewStrategy,
|
|
6
|
+
pgIvmStrategy,
|
|
7
|
+
plainViewStrategy,
|
|
8
|
+
} from "./MaterializedViewStrategy.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Optional database features the engine probes for at startup.
|
|
12
|
+
*
|
|
13
|
+
* - `pgIvm`: the `pg_ivm` extension that provides incrementally maintained
|
|
14
|
+
* materialized views. When unavailable, the engine can fall back to plain
|
|
15
|
+
* SQL views over the same trigger-maintained intermediate tables — but
|
|
16
|
+
* only if the operator has explicitly opted in via `ENV.ALLOW_NO_PG_IVM`.
|
|
17
|
+
*/
|
|
18
|
+
export interface DatabaseCapabilities {
|
|
19
|
+
pgIvm: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Probes the connected database for optional features. Pure detection —
|
|
24
|
+
* no policy decision is made here; the caller (typically via
|
|
25
|
+
* {@link selectViewStrategy}) decides whether the resulting capability set
|
|
26
|
+
* is acceptable.
|
|
27
|
+
*
|
|
28
|
+
* - Best-effort `CREATE EXTENSION IF NOT EXISTS pg_ivm` (no-op if already
|
|
29
|
+
* installed, soft-fail if the binary is missing on the server).
|
|
30
|
+
* - Then queries `pg_extension` to confirm the real state.
|
|
31
|
+
*/
|
|
32
|
+
export async function detectCapabilities(
|
|
33
|
+
client: Pick<PoolClient, "query">,
|
|
34
|
+
): Promise<DatabaseCapabilities> {
|
|
35
|
+
try {
|
|
36
|
+
await client.query("CREATE EXTENSION IF NOT EXISTS pg_ivm");
|
|
37
|
+
} catch {
|
|
38
|
+
// Extension binary not installed on this server — fall through and let
|
|
39
|
+
// the pg_extension lookup confirm the absence.
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { rows } = await client.query<{ exists: boolean }>(
|
|
43
|
+
`SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_ivm') AS exists`,
|
|
44
|
+
);
|
|
45
|
+
return { pgIvm: rows[0]?.exists === true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Decide which materialized-view strategy to use based on detected
|
|
50
|
+
* capabilities and operator policy.
|
|
51
|
+
*
|
|
52
|
+
* Behavior matrix:
|
|
53
|
+
*
|
|
54
|
+
* | pg_ivm available | ALLOW_NO_PG_IVM | Result |
|
|
55
|
+
* | :--------------- | :-------------- | :--------------------------------------- |
|
|
56
|
+
* | yes | any | pgIvmStrategy (production path) |
|
|
57
|
+
* | no | false / unset | **throws** — startup aborts loudly |
|
|
58
|
+
* | no | true | plainViewStrategy + prominent startup WARN |
|
|
59
|
+
*
|
|
60
|
+
* The throw is intentional: silently degrading to a slower read path in
|
|
61
|
+
* production is much worse than failing early with a clear remediation
|
|
62
|
+
* message. Operators who genuinely need the dev/test fallback must opt in.
|
|
63
|
+
*/
|
|
64
|
+
export function selectViewStrategy(
|
|
65
|
+
capabilities: DatabaseCapabilities,
|
|
66
|
+
): MaterializedViewStrategy {
|
|
67
|
+
if (capabilities.pgIvm) return pgIvmStrategy;
|
|
68
|
+
|
|
69
|
+
if (!ENV.ALLOW_NO_PG_IVM) {
|
|
70
|
+
throw new Error(formatMissingPgIvmError());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
log.local(
|
|
74
|
+
ComponentNames.EFFECTSTREAM_RUNTIME,
|
|
75
|
+
"pg_ivm-fallback",
|
|
76
|
+
SeverityNumber.WARN,
|
|
77
|
+
(l) =>
|
|
78
|
+
l(
|
|
79
|
+
[
|
|
80
|
+
"─────────────────────────────────────────────────────────────",
|
|
81
|
+
" effectstream: running WITHOUT pg_ivm (fallback mode)",
|
|
82
|
+
" ALLOW_NO_PG_IVM=true is set; using plain SQL views over the",
|
|
83
|
+
" trigger-maintained intermediate tables.",
|
|
84
|
+
"",
|
|
85
|
+
" This path is intended for dev/test and low-volume apps only.",
|
|
86
|
+
" Read-path performance degrades sharply on long-lived contracts",
|
|
87
|
+
" with high address cardinality (intermediate tables grow",
|
|
88
|
+
" monotonically; the plain view does a seq scan + filter per",
|
|
89
|
+
" read). DO NOT run production on this configuration.",
|
|
90
|
+
"─────────────────────────────────────────────────────────────",
|
|
91
|
+
].join("\n"),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
return plainViewStrategy;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatMissingPgIvmError(): string {
|
|
98
|
+
return [
|
|
99
|
+
"",
|
|
100
|
+
"Effectstream startup aborted: the `pg_ivm` PostgreSQL extension is not",
|
|
101
|
+
"available on this database.",
|
|
102
|
+
"",
|
|
103
|
+
"The engine uses `pg_ivm` for incrementally maintained materialized views,",
|
|
104
|
+
"which give compact, fast reads on long-lived contracts. Without it the",
|
|
105
|
+
"engine can fall back to plain SQL views over the trigger-maintained",
|
|
106
|
+
"intermediate tables — but that path degrades sharply on high-cardinality",
|
|
107
|
+
"data and is only suitable for dev/test and low-volume apps.",
|
|
108
|
+
"",
|
|
109
|
+
"To proceed:",
|
|
110
|
+
" • Install pg_ivm in your PostgreSQL instance:",
|
|
111
|
+
" https://github.com/sraoss/pg_ivm",
|
|
112
|
+
" • OR explicitly accept the dev-mode fallback by setting:",
|
|
113
|
+
" ALLOW_NO_PG_IVM=true",
|
|
114
|
+
" (NOT recommended for production — see the database docs for the",
|
|
115
|
+
" performance ceiling.)",
|
|
116
|
+
"",
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
package/src/dynamic-tables.ts
CHANGED
|
@@ -6,26 +6,42 @@ import type { PoolClient } from "pg";
|
|
|
6
6
|
import type { VersionInfo } from "../migrations/system-version.ts";
|
|
7
7
|
import { applyMigrations } from "../scripts/apply-migrations.ts";
|
|
8
8
|
import { findMigrationByName } from "./sql/system.queries.ts";
|
|
9
|
+
import {
|
|
10
|
+
type MaterializedViewStrategy,
|
|
11
|
+
plainViewStrategy,
|
|
12
|
+
} from "./MaterializedViewStrategy.ts";
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* Creates dynamic tables for the given sync protocols.
|
|
12
|
-
* This is used to create the
|
|
16
|
+
* This is used to create the read-side relations (incrementally maintained
|
|
17
|
+
* via pg_ivm when available, otherwise plain SQL views) for each primitive's
|
|
18
|
+
* intermediate state.
|
|
13
19
|
*
|
|
14
20
|
* For example for ERC20 it creates a table that holds the balance for each address.
|
|
15
21
|
* For ERC721 it creates a table that holds the token id for each address.
|
|
16
22
|
*
|
|
17
23
|
* @param dbConn - The database connection.
|
|
18
24
|
* @param syncProtocols - The sync protocols.
|
|
25
|
+
* @param strategy - How to publish the read-side view. Defaults to the plain
|
|
26
|
+
* view fallback so callers that haven't probed capabilities yet stay safe
|
|
27
|
+
* on managed Postgres without pg_ivm. The runtime detects the extension
|
|
28
|
+
* and passes `pgIvmStrategy` explicitly when available.
|
|
19
29
|
*/
|
|
20
30
|
export function* createDynamicTables(
|
|
21
31
|
versionInfo: VersionInfo,
|
|
22
32
|
lastBlockHeight: number,
|
|
23
33
|
dbConn: PoolClient,
|
|
24
34
|
syncProtocols: any[], // AllSyncProtocols[],
|
|
35
|
+
strategy: MaterializedViewStrategy = plainViewStrategy,
|
|
25
36
|
) {
|
|
26
37
|
for (const syncProtocol of syncProtocols) {
|
|
27
38
|
for (const primitive of syncProtocol.config.primitives) {
|
|
28
|
-
yield* createDynamicTableForPrimitive(
|
|
39
|
+
yield* createDynamicTableForPrimitive(
|
|
40
|
+
primitive,
|
|
41
|
+
lastBlockHeight,
|
|
42
|
+
dbConn,
|
|
43
|
+
strategy,
|
|
44
|
+
);
|
|
29
45
|
}
|
|
30
46
|
}
|
|
31
47
|
}
|
|
@@ -39,6 +55,7 @@ function* createDynamicTableForPrimitive(
|
|
|
39
55
|
},
|
|
40
56
|
lastBlockHeight: number,
|
|
41
57
|
dbConn: PoolClient,
|
|
58
|
+
strategy: MaterializedViewStrategy,
|
|
42
59
|
) {
|
|
43
60
|
const type = p.primitive.type;
|
|
44
61
|
const name = p.primitive.name;
|
|
@@ -54,10 +71,15 @@ function* createDynamicTableForPrimitive(
|
|
|
54
71
|
|
|
55
72
|
// This primitive does not have dynamic tables.
|
|
56
73
|
if (!sqlFunction) return;
|
|
57
|
-
const code = sqlFunction(name);
|
|
74
|
+
const code = sqlFunction(name, strategy);
|
|
58
75
|
if (!code) return;
|
|
59
76
|
|
|
60
|
-
|
|
77
|
+
// pgIvmStrategy uses the legacy unsuffixed name so existing deployments
|
|
78
|
+
// upgrading from a pre-strategy build keep matching their previously-
|
|
79
|
+
// applied migration rows. plainViewStrategy uses a `-view` suffix to keep
|
|
80
|
+
// its history distinct, so a deployment that later gains or loses pg_ivm
|
|
81
|
+
// re-applies the correct view-creation DDL.
|
|
82
|
+
const migrationName = `dynamic-tables-${type}-${name}${strategy.migrationSuffix}`;
|
|
61
83
|
const [migration] = yield* until(findMigrationByName.run({
|
|
62
84
|
name: migrationName,
|
|
63
85
|
isSystemMigration: true,
|
package/src/mod.ts
CHANGED
|
@@ -64,6 +64,13 @@ export {
|
|
|
64
64
|
getPrimitiveIntermediatePrefix,
|
|
65
65
|
getPrimitivePrefix,
|
|
66
66
|
} from "./dynamic-tables.ts";
|
|
67
|
+
export { detectCapabilities, selectViewStrategy } from "./capabilities.ts";
|
|
68
|
+
export type { DatabaseCapabilities } from "./capabilities.ts";
|
|
69
|
+
export {
|
|
70
|
+
pgIvmStrategy,
|
|
71
|
+
plainViewStrategy,
|
|
72
|
+
} from "./MaterializedViewStrategy.ts";
|
|
73
|
+
export type { MaterializedViewStrategy } from "./MaterializedViewStrategy.ts";
|
|
67
74
|
// export { startPGlite } from "./start-pglite.ts";
|
|
68
75
|
|
|
69
76
|
export { getMigrationsForBlockHeight } from "./migrations.ts";
|
|
@@ -383,6 +383,61 @@ const getFutureGameInputByMaxTimestampIR: any = {"usedParamSet":{"max_timestamp"
|
|
|
383
383
|
export const getFutureGameInputByMaxTimestamp = new PreparedQuery<IGetFutureGameInputByMaxTimestampParams,IGetFutureGameInputByMaxTimestampResult>(getFutureGameInputByMaxTimestampIR);
|
|
384
384
|
|
|
385
385
|
|
|
386
|
+
/** 'GetEarliestScheduledBlockHeight' parameters type */
|
|
387
|
+
export type IGetEarliestScheduledBlockHeightParams = void;
|
|
388
|
+
|
|
389
|
+
/** 'GetEarliestScheduledBlockHeight' return type */
|
|
390
|
+
export interface IGetEarliestScheduledBlockHeightResult {
|
|
391
|
+
min_block_height: number | null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 'GetEarliestScheduledBlockHeight' query type */
|
|
395
|
+
export interface IGetEarliestScheduledBlockHeightQuery {
|
|
396
|
+
params: IGetEarliestScheduledBlockHeightParams;
|
|
397
|
+
result: IGetEarliestScheduledBlockHeightResult;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const getEarliestScheduledBlockHeightIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT MIN(future_block_height) AS min_block_height\nFROM effectstream.rollup_input_future_block"};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Query generated from SQL:
|
|
404
|
+
* ```
|
|
405
|
+
* SELECT MIN(future_block_height) AS min_block_height
|
|
406
|
+
* FROM effectstream.rollup_input_future_block
|
|
407
|
+
* ```
|
|
408
|
+
*/
|
|
409
|
+
export const getEarliestScheduledBlockHeight = new PreparedQuery<IGetEarliestScheduledBlockHeightParams,IGetEarliestScheduledBlockHeightResult>(getEarliestScheduledBlockHeightIR);
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
/** 'GetEarliestScheduledTimestamp' parameters type */
|
|
413
|
+
export type IGetEarliestScheduledTimestampParams = void;
|
|
414
|
+
|
|
415
|
+
/** 'GetEarliestScheduledTimestamp' return type */
|
|
416
|
+
export interface IGetEarliestScheduledTimestampResult {
|
|
417
|
+
min_timestamp: Date | null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 'GetEarliestScheduledTimestamp' query type */
|
|
421
|
+
export interface IGetEarliestScheduledTimestampQuery {
|
|
422
|
+
params: IGetEarliestScheduledTimestampParams;
|
|
423
|
+
result: IGetEarliestScheduledTimestampResult;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const getEarliestScheduledTimestampIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT MIN(rollup_input_future_timestamp.future_ms_timestamp) AS min_timestamp\nFROM effectstream.rollup_input_future_timestamp\nLEFT OUTER JOIN effectstream.rollup_input_result\n ON (effectstream.rollup_input_result.id = effectstream.rollup_input_future_timestamp.id)\nWHERE effectstream.rollup_input_result.id IS NULL"};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Query generated from SQL:
|
|
430
|
+
* ```
|
|
431
|
+
* SELECT MIN(rollup_input_future_timestamp.future_ms_timestamp) AS min_timestamp
|
|
432
|
+
* FROM effectstream.rollup_input_future_timestamp
|
|
433
|
+
* LEFT OUTER JOIN effectstream.rollup_input_result
|
|
434
|
+
* ON (effectstream.rollup_input_result.id = effectstream.rollup_input_future_timestamp.id)
|
|
435
|
+
* WHERE effectstream.rollup_input_result.id IS NULL
|
|
436
|
+
* ```
|
|
437
|
+
*/
|
|
438
|
+
export const getEarliestScheduledTimestamp = new PreparedQuery<IGetEarliestScheduledTimestampParams,IGetEarliestScheduledTimestampResult>(getEarliestScheduledTimestampIR);
|
|
439
|
+
|
|
440
|
+
|
|
386
441
|
/** 'GetGameInputResultByBlockHeight' parameters type */
|
|
387
442
|
export interface IGetGameInputResultByBlockHeightParams {
|
|
388
443
|
block_height: number;
|
|
@@ -144,6 +144,17 @@ WHERE rollup_input_future_timestamp.future_ms_timestamp <= :max_timestamp! AND
|
|
|
144
144
|
effectstream.rollup_input_result.id IS NULL
|
|
145
145
|
ORDER BY rollup_input_future_timestamp.future_ms_timestamp ASC;
|
|
146
146
|
|
|
147
|
+
/* @name getEarliestScheduledBlockHeight */
|
|
148
|
+
SELECT MIN(future_block_height) AS min_block_height
|
|
149
|
+
FROM effectstream.rollup_input_future_block;
|
|
150
|
+
|
|
151
|
+
/* @name getEarliestScheduledTimestamp */
|
|
152
|
+
SELECT MIN(rollup_input_future_timestamp.future_ms_timestamp) AS min_timestamp
|
|
153
|
+
FROM effectstream.rollup_input_future_timestamp
|
|
154
|
+
LEFT OUTER JOIN effectstream.rollup_input_result
|
|
155
|
+
ON (effectstream.rollup_input_result.id = effectstream.rollup_input_future_timestamp.id)
|
|
156
|
+
WHERE effectstream.rollup_input_result.id IS NULL;
|
|
157
|
+
|
|
147
158
|
/* @name getGameInputResultByBlockHeight */
|
|
148
159
|
SELECT
|
|
149
160
|
rollup_inputs.id,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Pins the PRODUCTION wiring used in pg-connection.ts:
|
|
2
|
+
// installUnhandledRejectionLogger(EFFECTSTREAM_PGLITE, isTransientPgError)
|
|
3
|
+
// i.e. the live `unhandledRejection` handler treats `isTransientPgError` as its
|
|
4
|
+
// survive predicate. This is the test @acedward asked for: prove that when
|
|
5
|
+
// something other than a known-transient pg error escapes to the global
|
|
6
|
+
// handler (e.g. a generic error torn out of the effection root), the process is
|
|
7
|
+
// actually told to exit — it does NOT silently log-and-survive into a zombie
|
|
8
|
+
// whose effection tree is dead but whose process is still up.
|
|
9
|
+
|
|
10
|
+
import { test, expect } from "bun:test";
|
|
11
|
+
import { buildUnhandledRejectionHandler } from "@effectstream/log";
|
|
12
|
+
import { isTransientPgError } from "./transient-pg-errors.ts";
|
|
13
|
+
|
|
14
|
+
const wireProductionHandler = () => {
|
|
15
|
+
const exits: number[] = [];
|
|
16
|
+
const handle = buildUnhandledRejectionHandler(
|
|
17
|
+
"pglite",
|
|
18
|
+
isTransientPgError,
|
|
19
|
+
(code) => exits.push(code),
|
|
20
|
+
);
|
|
21
|
+
return { handle, exits };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test("generic error escaping the effection root is fatal (no zombie)", () => {
|
|
25
|
+
const { handle, exits } = wireProductionHandler();
|
|
26
|
+
|
|
27
|
+
// What a halted effection scope surfaces: an ordinary Error, not a pg error.
|
|
28
|
+
handle(new Error("effection root task died"));
|
|
29
|
+
|
|
30
|
+
expect(exits).toEqual([1]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a logic/SQL bug is fatal", () => {
|
|
34
|
+
const { handle, exits } = wireProductionHandler();
|
|
35
|
+
handle(new Error("column foo does not exist"));
|
|
36
|
+
expect(exits).toEqual([1]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a genuine transient pg blip survives (the intended exemption)", () => {
|
|
40
|
+
const { handle, exits } = wireProductionHandler();
|
|
41
|
+
|
|
42
|
+
handle({
|
|
43
|
+
code: "ECONNRESET",
|
|
44
|
+
message:
|
|
45
|
+
"Client network socket disconnected before secure TLS connection was established",
|
|
46
|
+
stack: " at node_modules/pg-pool/index.js:45:11",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(exits).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a non-pg ECONNRESET-shaped rejection is still fatal", () => {
|
|
53
|
+
const { handle, exits } = wireProductionHandler();
|
|
54
|
+
|
|
55
|
+
// Same code, but not from the pg stack => isTransientPgError returns false.
|
|
56
|
+
handle({
|
|
57
|
+
code: "ETIMEDOUT",
|
|
58
|
+
message: "fetch failed",
|
|
59
|
+
stack: " at node:internal/deps/undici/undici.js:1234:5",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(exits).toEqual([1]);
|
|
63
|
+
});
|