@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.
@@ -1,7 +1,5 @@
1
1
  CREATE SCHEMA IF NOT EXISTS effectstream;
2
2
 
3
- CREATE EXTENSION IF NOT EXISTS pg_ivm;
4
-
5
3
  CREATE TABLE effectstream.effectstream_expected_version (
6
4
  app_version_major INTEGER NOT NULL,
7
5
  app_version_minor INTEGER NOT NULL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/db",
3
- "version": "0.100.19",
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.19",
25
- "@effectstream/log": "0.100.19",
26
- "@effectstream/runtime": "0.100.19",
27
- "@effectstream/sync": "0.100.19",
28
- "@effectstream/utils": "0.100.19",
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.17.2",
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
+ }
@@ -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 IVMs for the given sync protocols.
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(primitive, lastBlockHeight, dbConn);
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
- const migrationName = `dynamic-tables-${type}-${name}`;
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
+ });