@effectstream/db 0.100.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.
Files changed (50) hide show
  1. package/assets-config.json +28 -0
  2. package/migrations/assets.ts +35 -0
  3. package/migrations/readme.md +37 -0
  4. package/migrations/system-down-v-0.0.0.sql +2 -0
  5. package/migrations/system-down-v-0.0.1.sql +14 -0
  6. package/migrations/system-up-v-0.0.0.sql +40 -0
  7. package/migrations/system-up-v-0.0.1.sql +117 -0
  8. package/migrations/system-up-v-0.0.2.sql +22 -0
  9. package/migrations/system-up-v-0.8.1.sql +5 -0
  10. package/migrations/system-version.ts +147 -0
  11. package/package.json +33 -0
  12. package/pgtypedconfig.json +21 -0
  13. package/scripts/Dockerfile +13 -0
  14. package/scripts/apply-migrations.ts +49 -0
  15. package/scripts/pgtyped-internal.ts +43 -0
  16. package/scripts/pgtyped-update.ts +119 -0
  17. package/scripts/start-pglite.ts +71 -0
  18. package/scripts/wait-for-db.ts +43 -0
  19. package/src/dynamic-tables.ts +110 -0
  20. package/src/event-indexing.ts +31 -0
  21. package/src/migrations.ts +28 -0
  22. package/src/mod.ts +65 -0
  23. package/src/pg-connection.test.ts +99 -0
  24. package/src/pg-connection.ts +211 -0
  25. package/src/register-events.ts +40 -0
  26. package/src/scheduled-constructors.ts +117 -0
  27. package/src/snapshot-handler.ts +164 -0
  28. package/src/sql/accounts.queries.ts +350 -0
  29. package/src/sql/accounts.sql +77 -0
  30. package/src/sql/achievements.queries.ts +74 -0
  31. package/src/sql/achievements.sql +17 -0
  32. package/src/sql/block-heights.queries.ts +205 -0
  33. package/src/sql/block-heights.sql +45 -0
  34. package/src/sql/events.queries.ts +201 -0
  35. package/src/sql/events.sql +41 -0
  36. package/src/sql/nonces.queries.ts +85 -0
  37. package/src/sql/nonces.sql +11 -0
  38. package/src/sql/primitives.queries.ts +35 -0
  39. package/src/sql/primitives.sql +4 -0
  40. package/src/sql/rollup_inputs.queries.ts +654 -0
  41. package/src/sql/rollup_inputs.sql +227 -0
  42. package/src/sql/statistics.queries.ts +160 -0
  43. package/src/sql/statistics.sql +55 -0
  44. package/src/sql/sync-protocols/page.queries.ts +140 -0
  45. package/src/sql/sync-protocols/page.sql +39 -0
  46. package/src/sql/system.queries.ts +356 -0
  47. package/src/sql/system.sql +75 -0
  48. package/src/sql/tables.queries.ts +89 -0
  49. package/src/sql/tables.sql +17 -0
  50. package/src/system.ts +41 -0
@@ -0,0 +1,211 @@
1
+ import type { Client, PoolClient, PoolConfig } from "pg";
2
+ import pg from "pg";
3
+ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
4
+ import { type Operation, run, sleep } from "effection";
5
+ import { ENV } from "@effectstream/utils/node-env";
6
+
7
+ let readonlyDBConn: pg.Pool | null;
8
+
9
+ // PGLite does not support multiple connections, so we need to use a mutex to ensure that only one query is executed at a time.
10
+ // * For transactions use yield* acquireDBMutex(); ...Your Operations... releaseDBMutex();
11
+ // * For single queries use await runPreparedQuery(myQuery.run(params, dbConn));
12
+ // IMPORTANT: This is only for PGLite instances, for full pgsql servers, this is not needed.0
13
+ // TODO This is a very simple mutex implementation.
14
+ // It releases the mutex randomly, and not in order.
15
+ // It only works for deno's single-threaded runtime.
16
+ // pglite exports async-mutex, so we can use that for a proper solution.
17
+ let db_mutex: "free" | "locked" = "free";
18
+ /**
19
+ * Acquires a mutex to ensure that only one query is executed at a time.
20
+ * If running in PGLite, it will acquire a mutex to ensure that only one query is executed at a time.
21
+ * If running in a full pgsql server, it will do nothing.
22
+ * Always run run `releaseDBMutex()` after the query locks no longer needed, other threads are blocked until the mutex is released.
23
+ */
24
+ const _waitUntilFree = {
25
+ waiting: [] as {
26
+ priority: 'high' | 'low';
27
+ name: string;
28
+ date: string;
29
+ }[],
30
+ running: {
31
+ name: "",
32
+ date: "",
33
+ },
34
+ db_mutex,
35
+ };
36
+
37
+ // Get data for database.
38
+ export const waitUntilFree = () => {
39
+ return {
40
+ ..._waitUntilFree,
41
+ db_mutex,
42
+ };
43
+ };
44
+
45
+ export function* acquireDBMutex(lockName: string, priority: 'high' | 'low' = 'low'): Operation<void> {
46
+ if (!ENV.PGLITE) return;
47
+ _waitUntilFree.waiting.push({
48
+ priority,
49
+ name: lockName,
50
+ date: new Date().toISOString(),
51
+ });
52
+
53
+ while (true) {
54
+ // If there is a high priority lock, wait for it to be released.
55
+ if (priority === 'low' && _waitUntilFree.waiting.find((w) => w.priority === 'high')) {
56
+ // do nothing
57
+ }
58
+
59
+ // If there is no lock, acquire it.
60
+ else if (db_mutex === "free") {
61
+ db_mutex = "locked";
62
+ _waitUntilFree.waiting.splice(
63
+ _waitUntilFree.waiting.findIndex((w) => w.name === lockName),
64
+ 1,
65
+ );
66
+ _waitUntilFree.running = {
67
+ name: lockName,
68
+ date: new Date().toISOString(),
69
+ };
70
+ break;
71
+ }
72
+
73
+ // This is only for debugging purposes.
74
+ const i = _waitUntilFree.waiting.findIndex((w) => w.name === lockName);
75
+ if (i !== -1) {
76
+ const now = new Date().getTime();
77
+ const waitingSince = new Date(_waitUntilFree.waiting[i].date).getTime();
78
+ const waitingFor = now - waitingSince;
79
+ if (waitingFor > 2500) {
80
+ console.error(
81
+ `[DB Mutex] Waiting for ${waitingFor}[ms] for ${lockName}.
82
+ Locked by ${_waitUntilFree.running.name}. This is a critical error, please restart the sync service.`,
83
+ );
84
+ }
85
+ }
86
+
87
+ yield* sleep(10);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Releases a mutex to allow other queries to be executed.
93
+ * If running in PGLite, it will release a mutex to allow other queries to be executed.
94
+ * If running in a full pgsql server, it will do nothing.
95
+ * Do not call this function if you did not call `acquireDBMutex()` before.
96
+ */
97
+ export function releaseDBMutex(lockName: string) {
98
+ if (!ENV.PGLITE) return;
99
+
100
+ if (_waitUntilFree.running.name !== lockName) {
101
+ console.error(
102
+ "releasing mutex",
103
+ _waitUntilFree.running,
104
+ "but the lock name does not match",
105
+ lockName,
106
+ );
107
+ }
108
+ _waitUntilFree.running = { name: "", date: "" };
109
+ db_mutex = "free";
110
+ }
111
+
112
+ /**
113
+ * Helper function to run a db query in a thread-safe manner.
114
+ * If running in PGLite, it will acquire a mutex to ensure that only one query is executed at a time.
115
+ * If running in a full pgsql server, it will run normally.
116
+ */
117
+ export async function runPreparedQuery<T>(
118
+ p: Promise<T[]>,
119
+ name: string,
120
+ ): Promise<T[]> {
121
+ let result: T[];
122
+ try {
123
+ if (ENV.PGLITE) {
124
+ await run(() => acquireDBMutex(`pq:${name}`));
125
+ }
126
+
127
+ let t;
128
+ const timeout: Promise<T[]> = new Promise((resolve) => {
129
+ t = setTimeout(() => {
130
+ console.error("[CRITICAL ERROR] Database query timed out", name);
131
+ if (ENV.PGLITE) {
132
+ // TODO: This is a temporary fix to allow the query to continue.
133
+ // Only allow to continue in PGLITE.
134
+ // We suspect this is PGLITE specific error.
135
+ resolve([]);
136
+ }
137
+ }, 2500);
138
+ });
139
+ result = await Promise.race([p, timeout]);
140
+ clearTimeout(t);
141
+ } finally {
142
+ releaseDBMutex(`pq:${name}`);
143
+ }
144
+ return result;
145
+ }
146
+
147
+ export const getConnection = (
148
+ creds?: PoolConfig,
149
+ readonly = false,
150
+ ): pg.Pool => {
151
+ if (!creds) {
152
+ creds = {
153
+ host: ENV.DB_HOST,
154
+ user: ENV.DB_USER,
155
+ password: ENV.PGLITE ? undefined : ENV.DB_PW,
156
+ database: ENV.DB_NAME,
157
+ port: ENV.DB_PORT,
158
+ };
159
+ }
160
+ if (readonly && readonlyDBConn) return readonlyDBConn;
161
+
162
+ // TODO: make this configurable for non pglite instances
163
+ const MAX_CONNECTIONS = ENV.PGLITE ? 1 : 10;
164
+
165
+ const pool = new pg.Pool({ ...creds, max: MAX_CONNECTIONS });
166
+ pool.on("error", (err: unknown) =>
167
+ log.remote(
168
+ ComponentNames.EFFECTSTREAM_PGLITE,
169
+ ["query"],
170
+ SeverityNumber.ERROR,
171
+ (log) => log(err),
172
+ ));
173
+ pool.on("connect", (_client: PoolClient) => {
174
+ // On each new client initiated, need to register for error(this is a serious bug on pg, the client throw errors although it should not)
175
+ // https://github.com/brianc/node-postgres/issues/2499#issuecomment-805477725
176
+ _client.on("error", (err: Error) => {
177
+ log.remote(
178
+ ComponentNames.EFFECTSTREAM_PGLITE,
179
+ ["connect"],
180
+ SeverityNumber.ERROR,
181
+ (log) => log(err),
182
+ );
183
+ });
184
+ });
185
+
186
+ if (readonly) {
187
+ const ensureReadOnly =
188
+ `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`;
189
+ void pool.query(ensureReadOnly); // note: this query modifies the DB state
190
+ readonlyDBConn = pool;
191
+ }
192
+
193
+ return pool;
194
+ };
195
+
196
+ // For notifications use a direct (non-pooled) persistent connection.
197
+ export const getPersistentConnection = (creds: PoolConfig): Client => {
198
+ const client = new pg.Client(creds);
199
+ client.connect(() => {});
200
+ // https://github.com/brianc/node-postgres/issues/2499#issuecomment-805477725
201
+ // On each new client initiated, need to register for error(this is a serious bug on pg, the client throw errors although it should not)
202
+ client.on("error", (err: Error) => {
203
+ log.remote(
204
+ ComponentNames.EFFECTSTREAM_PGLITE,
205
+ ["query"],
206
+ SeverityNumber.ERROR,
207
+ (log) => log(err),
208
+ );
209
+ });
210
+ return client;
211
+ };
@@ -0,0 +1,40 @@
1
+ import type { PoolClient } from "pg";
2
+ import {
3
+ getEventByTopic,
4
+ getTopics,
5
+ registerEventType,
6
+ } from "./sql/events.queries.ts";
7
+
8
+ export async function registerEventTypes(
9
+ pool: PoolClient,
10
+ eventDescriptions: { topic: string; name: string }[],
11
+ ): Promise<boolean> {
12
+ const allStoredTopics = await getTopics.run(undefined, pool);
13
+
14
+ // check that no events were deleted.
15
+ for (const storedTopic of allStoredTopics) {
16
+ const found = eventDescriptions.find((ed) =>
17
+ ed.topic === storedTopic.topic
18
+ );
19
+
20
+ if (found === undefined) {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ for (const event of eventDescriptions) {
26
+ const registeredTopic = await getEventByTopic.run(
27
+ { topic: event.topic },
28
+ pool,
29
+ );
30
+
31
+ if (registeredTopic.length === 0) {
32
+ await registerEventType.run(
33
+ { name: event.name, topic: event.topic },
34
+ pool,
35
+ );
36
+ }
37
+ }
38
+
39
+ return true;
40
+ }
@@ -0,0 +1,117 @@
1
+ import type { StateUpdateStream } from "@effectstream/coroutine";
2
+ import { World } from "@effectstream/coroutine";
3
+ import {
4
+ newScheduledHeightData,
5
+ newScheduledTimestampData,
6
+ removeAllScheduledDataByInputData,
7
+ removeScheduledBlockData,
8
+ removeScheduledTimestampData,
9
+ } from "./sql/rollup_inputs.queries.ts";
10
+ import {
11
+ AddressType,
12
+ type Caip2,
13
+ type TxHash,
14
+ type UnknownFormat,
15
+ } from "@effectstream/utils";
16
+ import {
17
+ type BlockNumber,
18
+ strip0x,
19
+ type TimestampMs,
20
+ type WalletAddress,
21
+ } from "@effectstream/utils";
22
+ import { Buffer } from "node:buffer";
23
+
24
+ export type UpdateAtTrigger = { blockHeight: BlockNumber } | {
25
+ timestamp: TimestampMs;
26
+ };
27
+
28
+ /**
29
+ * Create an SQL update which schedules a piece of data to be run through
30
+ * the STF at a future block height / timestamp.
31
+ *
32
+ * @param inputData The input to pass to the STF, generally in Paima Concise format.
33
+ * @param blockHeight The future block height at which to post the input to the STF.
34
+ * @returns
35
+ */
36
+ export function* createScheduledData(
37
+ inputData: string,
38
+ trigger: UpdateAtTrigger,
39
+ source:
40
+ | {
41
+ primitiveName: string;
42
+ txHash: TxHash;
43
+ caip2: Caip2;
44
+ fromAddress: WalletAddress;
45
+ fromAddressType: AddressType;
46
+ contractAddress: undefined | WalletAddress;
47
+ }
48
+ | {
49
+ precompile: UnknownFormat;
50
+ },
51
+ ): StateUpdateStream<void> {
52
+ if ("blockHeight" in trigger) {
53
+ const sourceParams = "precompile" in source
54
+ ? {
55
+ from_address: source.precompile,
56
+ // TODO: Check this value. What is the precompile types?
57
+ from_address_type: AddressType.NONE,
58
+ primitive_name: null,
59
+ origin_tx_hash: null,
60
+ caip2: null,
61
+ origin_contract_address: null,
62
+ }
63
+ : {
64
+ from_address: source.fromAddress,
65
+ from_address_type: source.fromAddressType,
66
+ primitive_name: source.primitiveName,
67
+ origin_tx_hash: Buffer.from(strip0x(source.txHash), "hex"),
68
+ caip2: source.caip2,
69
+ origin_contract_address: source.contractAddress,
70
+ };
71
+
72
+ yield* World.resolve(newScheduledHeightData, {
73
+ future_block_height: trigger.blockHeight,
74
+ input_data: inputData,
75
+ ...sourceParams,
76
+ });
77
+ } else {
78
+ if (!("precompile" in source)) {
79
+ // in particular primitives only schedule data in the same block they trigger
80
+ throw new Error("primitives should not schedule timers by date");
81
+ }
82
+ yield* World.resolve(newScheduledTimestampData, {
83
+ future_ms_timestamp: new Date(trigger.timestamp),
84
+ input_data: inputData,
85
+ from_address: source.precompile,
86
+ from_address_type: AddressType.NONE,
87
+ });
88
+ }
89
+ }
90
+
91
+ // Create an SQL update which deletes an upcoming scheduled data
92
+ // NOTE: if trigger is null, then delete ALL schedules that match inputData.
93
+ export function* deleteScheduledData(
94
+ inputData: string,
95
+ trigger: UpdateAtTrigger | null,
96
+ ): StateUpdateStream<void> {
97
+ if (trigger === null) {
98
+ yield* World.resolve(removeAllScheduledDataByInputData, {
99
+ input_data: inputData,
100
+ });
101
+ return;
102
+ }
103
+
104
+ if ("blockHeight" in trigger) {
105
+ // Delete exact schedule by command and height
106
+ yield* World.resolve(removeScheduledBlockData, {
107
+ block_height: trigger.blockHeight,
108
+ input_data: inputData,
109
+ });
110
+ } else {
111
+ // Delete exact schedule by command and timestamp
112
+ yield* World.resolve(removeScheduledTimestampData, {
113
+ ms_timestamp: new Date(trigger.timestamp),
114
+ input_data: inputData,
115
+ });
116
+ }
117
+ }
@@ -0,0 +1,164 @@
1
+ import { ENV } from "@effectstream/utils/node-env";
2
+ import { sleep, until, type Operation } from "effection";
3
+
4
+ export interface SnapshotRetentionConfig {
5
+ /** One per hour for last 24 h. Default: `true` */
6
+ lastDayHourly?: boolean;
7
+ /** One per 6 h for last 3 days. Default: `true` */
8
+ last3DaysSixHourly?: boolean;
9
+ /** Days of daily snapshots to keep. Default: `7` */
10
+ lastNDaysDaily?: number;
11
+ }
12
+
13
+ export interface SnapshotConfig {
14
+ /** Wall-clock seconds between snapshots. Default: `3600` (1 hour) */
15
+ intervalSeconds?: number;
16
+ /** Output directory. Default: `"./snapshots"` */
17
+ path?: string;
18
+ /** Time-based tiered retention (uses file mtime). */
19
+ retention?: SnapshotRetentionConfig;
20
+ }
21
+
22
+ /**
23
+ * Creates a database snapshot using `pg_dump` in custom format (`-F c`).
24
+ * Must be called AFTER releasing the DB mutex (pg_dump opens its own connection).
25
+ *
26
+ * @see docs/home/1000-effectstream-engine/1003-database-snapshots.md
27
+ */
28
+ export async function createSnapshot(
29
+ config?: SnapshotConfig,
30
+ ): Promise<void> {
31
+ // pg_dump queries pg_catalog directly; PGlite's catalog may not match the
32
+ // locally-installed pg_dump version, so skip snapshots under PGlite.
33
+ if (ENV.PGLITE) {
34
+ console.log(`[Snapshot] Skipping: pg_dump is not compatible with PGlite.`);
35
+ return;
36
+ }
37
+
38
+ const snapshotDir = config?.path ?? "./snapshots";
39
+ const timestamp = new Date().toISOString().replace(/:/g, "-").replace(/\.\d{3}/, "");
40
+ const snapshotPath = `${snapshotDir}/snapshot-${timestamp}.dump`;
41
+
42
+ await Deno.mkdir(snapshotDir, { recursive: true });
43
+ console.log(`[Snapshot] Creating snapshot at ${snapshotPath}...`);
44
+
45
+ const cmd = new Deno.Command("pg_dump", {
46
+ args: [
47
+ "-h", ENV.DB_HOST,
48
+ "-p", String(ENV.DB_PORT),
49
+ "-U", ENV.DB_USER,
50
+ "-d", ENV.DB_NAME,
51
+ "-F", "c",
52
+ "-f", snapshotPath,
53
+ ],
54
+ env: { ...Deno.env.toObject(), PGPASSWORD: ENV.DB_PW ?? "" },
55
+ stdout: "inherit",
56
+ stderr: "inherit",
57
+ });
58
+
59
+ const status = await cmd.output();
60
+ if (!status.success) {
61
+ throw new Error(`[Snapshot] pg_dump failed with exit code ${status.code}`);
62
+ }
63
+
64
+ console.log(`[Snapshot] Snapshot created: ${snapshotPath}`);
65
+ await applyRetentionPolicy(snapshotDir, config?.retention);
66
+ }
67
+
68
+ /**
69
+ * Long-lived fiber that fires snapshots on a fixed cadence, independent of
70
+ * block production. Spawn once at startup; cadence is `intervalSeconds`.
71
+ * Blocking on `createSnapshot` makes overlapping dumps structurally impossible.
72
+ */
73
+ export function* runSnapshotLoop(
74
+ snapshotConfig: SnapshotConfig,
75
+ ): Operation<void> {
76
+ const intervalMs = (snapshotConfig.intervalSeconds ?? 3600) * 1000;
77
+ console.log(`[Snapshot] Loop started (interval ${intervalMs / 1000}s, path ${snapshotConfig.path ?? "./snapshots"}, cwd ${Deno.cwd()})`);
78
+ while (true) {
79
+ console.log(`[Snapshot] Firing`);
80
+ try {
81
+ yield* until(createSnapshot(snapshotConfig));
82
+ console.log(`[Snapshot] Done; sleeping ${intervalMs / 1000}s`);
83
+ } catch (e) {
84
+ console.error("[Snapshot] Failed:", e);
85
+ }
86
+ yield* sleep(intervalMs);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Time-based tiered retention: keeps one file per bucket (newest wins),
92
+ * deletes everything beyond `lastNDaysDaily` days.
93
+ */
94
+ async function applyRetentionPolicy(
95
+ snapshotDir: string,
96
+ retention?: SnapshotRetentionConfig,
97
+ ): Promise<void> {
98
+ const lastDayHourly = retention?.lastDayHourly ?? true;
99
+ const last3DaysSixHourly = retention?.last3DaysSixHourly ?? true;
100
+ const lastNDaysDaily = retention?.lastNDaysDaily ?? 7;
101
+
102
+ const now = Date.now();
103
+ const MS_1H = 3_600_000;
104
+ const MS_24H = 24 * MS_1H;
105
+ const MS_3D = 3 * MS_24H;
106
+ const MS_ND = lastNDaysDaily * MS_24H;
107
+
108
+ const entries: { path: string; mtime: number }[] = [];
109
+
110
+ try {
111
+ for await (const entry of Deno.readDir(snapshotDir)) {
112
+ if (!entry.isFile || !entry.name.startsWith("snapshot-") || !entry.name.endsWith(".dump")) continue;
113
+ const filePath = `${snapshotDir}/${entry.name}`;
114
+ try {
115
+ const stat = await Deno.stat(filePath);
116
+ entries.push({ path: filePath, mtime: stat.mtime?.getTime() ?? 0 });
117
+ } catch { /* file vanished between readDir and stat */ }
118
+ }
119
+ } catch (e) {
120
+ console.warn("[Snapshot] Could not read snapshot directory for retention:", e);
121
+ return;
122
+ }
123
+
124
+ if (entries.length === 0) return;
125
+
126
+ // Newest first so the first entry per bucket is always the one we keep.
127
+ entries.sort((a, b) => b.mtime - a.mtime);
128
+
129
+ const keepers = new Set<string>();
130
+ const seenBuckets = new Set<string>();
131
+
132
+ for (const entry of entries) {
133
+ const age = now - entry.mtime;
134
+ if (age > MS_ND) continue;
135
+
136
+ let bucketKey: string;
137
+
138
+ if (age <= MS_24H) {
139
+ if (!lastDayHourly) { keepers.add(entry.path); continue; }
140
+ bucketKey = `h:${Math.floor(age / MS_1H)}`;
141
+ } else if (age <= MS_3D) {
142
+ if (!last3DaysSixHourly) { keepers.add(entry.path); continue; }
143
+ bucketKey = `6h:${Math.floor(age / (6 * MS_1H))}`;
144
+ } else {
145
+ bucketKey = `d:${Math.floor(age / MS_24H)}`;
146
+ }
147
+
148
+ if (!seenBuckets.has(bucketKey)) {
149
+ seenBuckets.add(bucketKey);
150
+ keepers.add(entry.path);
151
+ }
152
+ }
153
+
154
+ for (const entry of entries) {
155
+ if (!keepers.has(entry.path)) {
156
+ try {
157
+ await Deno.remove(entry.path);
158
+ console.log(`[Snapshot] Retention: deleted ${entry.path}`);
159
+ } catch (e) {
160
+ console.warn(`[Snapshot] Failed to delete ${entry.path}:`, e);
161
+ }
162
+ }
163
+ }
164
+ }