@effectstream/db 0.100.13 → 0.100.15
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/README.md +103 -0
- package/package.json +6 -6
- package/src/mod.ts +6 -0
- package/src/snapshot-handler.test.ts +187 -0
- package/src/snapshot-handler.ts +81 -26
- package/src/sql/block-heights.queries.ts +51 -0
- package/src/sql/block-heights.sql +11 -0
- package/test/examples.test.ts +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @effectstream/db
|
|
2
|
+
|
|
3
|
+
The PostgreSQL (and PgLite) layer for EffectStream. Wraps `pg` with a
|
|
4
|
+
connection pool, ships every pgtyped-generated SQL query the runtime
|
|
5
|
+
needs, owns the snapshot loop, and provides a mutex needed for safe
|
|
6
|
+
single-threaded PgLite access.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bun add @effectstream/db
|
|
12
|
+
# or
|
|
13
|
+
npm install @effectstream/db
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Standalone usage
|
|
17
|
+
|
|
18
|
+
You can drop `@effectstream/db` into any Node service that needs a
|
|
19
|
+
pooled Postgres client plus EffectStream's pgtyped queries. The most
|
|
20
|
+
common things you'd reach for:
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import {
|
|
24
|
+
getConnection,
|
|
25
|
+
getBlockHeights,
|
|
26
|
+
getLatestProcessedBlockHeight,
|
|
27
|
+
} from "@effectstream/db";
|
|
28
|
+
|
|
29
|
+
const pool = getConnection({
|
|
30
|
+
host: process.env.PGHOST,
|
|
31
|
+
user: process.env.PGUSER,
|
|
32
|
+
password: process.env.PGPASSWORD,
|
|
33
|
+
database: process.env.PGDATABASE,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const client = await pool.connect();
|
|
37
|
+
try {
|
|
38
|
+
const heights = await getBlockHeights.run({ limit: 5 }, client);
|
|
39
|
+
console.log(heights);
|
|
40
|
+
} finally {
|
|
41
|
+
client.release();
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For PgLite (in-memory) the package ships `./start-pglite`,
|
|
46
|
+
`./apply-migrations`, `./db-wait`, `./pgtyped-update`, `./version` as
|
|
47
|
+
small executable subpaths — used directly by the orchestrator.
|
|
48
|
+
|
|
49
|
+
> **PgLite caveat:** PgLite is single-writer. Wrap PgLite-bound code in
|
|
50
|
+
> `acquireDBMutex(name)` / `releaseDBMutex(name)` so concurrent generators
|
|
51
|
+
> don't trample each other.
|
|
52
|
+
|
|
53
|
+
## Inside EffectStream
|
|
54
|
+
|
|
55
|
+
`@effectstream/db` is the canonical access path. The runtime gets a
|
|
56
|
+
pooled client through `getConnection()`, every state-transition function
|
|
57
|
+
yields against pgtyped queries exported here, and the snapshot loop
|
|
58
|
+
(`runSnapshotLoop`) emits versioned `pg_dump` artifacts so a fresh node
|
|
59
|
+
can rejoin without re-syncing from genesis.
|
|
60
|
+
|
|
61
|
+
## Key exports
|
|
62
|
+
|
|
63
|
+
Connection management (heavily used):
|
|
64
|
+
|
|
65
|
+
- `getConnection(creds?)` — pooled `Pool` singleton. The dominant entry point (~31 cross-package call sites).
|
|
66
|
+
- `acquireDBMutex(name, priority?)` / `releaseDBMutex(name)` — coordination for PgLite (~7 cross-package call sites each).
|
|
67
|
+
- `waitUntilFree()` — companion to the mutex.
|
|
68
|
+
- `getPersistentConnection(creds)` — a non-pooled `Client` for long-lived listeners.
|
|
69
|
+
|
|
70
|
+
Queries (pgtyped-generated, shipped under one umbrella):
|
|
71
|
+
|
|
72
|
+
- `getBlockHeights`, `getBlockByHash`, `blockHeightDone`, `saveLastBlock`, `getLatestProcessedBlockHeight` — block bookkeeping.
|
|
73
|
+
- `getAchievementProgress`, `setAchievementProgress` — achievements.
|
|
74
|
+
- Re-exports of `*.queries.ts` for statistics, nonces, rollup inputs, accounts, events, sync protocol pages, primitives, system, tables.
|
|
75
|
+
|
|
76
|
+
Snapshots:
|
|
77
|
+
|
|
78
|
+
- `runSnapshotLoop` — periodic `pg_dump` of synced state.
|
|
79
|
+
- `createSnapshot` — single-shot snapshot helper.
|
|
80
|
+
- `SnapshotConfig`, `SnapshotRetentionConfig` — config types.
|
|
81
|
+
|
|
82
|
+
Dynamic table / event helpers:
|
|
83
|
+
|
|
84
|
+
- `createDynamicTables` — register tables a primitive wants to own.
|
|
85
|
+
- `createIndexesForEvents`, `registerEventHandlers` — register pgtyped indexes for app events.
|
|
86
|
+
|
|
87
|
+
Subpath entry points (executable scripts):
|
|
88
|
+
|
|
89
|
+
- `@effectstream/db/start-pglite` — boot a PgLite instance.
|
|
90
|
+
- `@effectstream/db/apply-migrations` — apply SQL migrations against the active DB.
|
|
91
|
+
- `@effectstream/db/db-wait` — block until the DB accepts a connection.
|
|
92
|
+
- `@effectstream/db/pgtyped-update` — regenerate pgtyped types.
|
|
93
|
+
- `@effectstream/db/version` — print schema version.
|
|
94
|
+
|
|
95
|
+
## Examples
|
|
96
|
+
|
|
97
|
+
- Real connection round-trip: [`src/pg-connection.test.ts`](./src/pg-connection.test.ts).
|
|
98
|
+
- Runnable: [`test/examples.test.ts`](./test/examples.test.ts).
|
|
99
|
+
|
|
100
|
+
## Links
|
|
101
|
+
|
|
102
|
+
- Docs: https://effectstream.github.io/docs/packages/node/db
|
|
103
|
+
- Source: https://github.com/PaimaStudios/paima-engine/tree/main/packages/node-sdk/db
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/db",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.15",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -21,11 +21,11 @@
|
|
|
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.15",
|
|
25
|
+
"@effectstream/log": "0.100.15",
|
|
26
|
+
"@effectstream/runtime": "0.100.15",
|
|
27
|
+
"@effectstream/sync": "0.100.15",
|
|
28
|
+
"@effectstream/utils": "0.100.15",
|
|
29
29
|
"@electric-sql/pglite": "^0.3.14",
|
|
30
30
|
"@pgtyped/runtime": "2.4.2",
|
|
31
31
|
"@pgtyped/parser": "2.4.2",
|
package/src/mod.ts
CHANGED
|
@@ -16,21 +16,27 @@ export type {
|
|
|
16
16
|
// https://github.com/adelsz/pgtyped/issues/565
|
|
17
17
|
export {
|
|
18
18
|
blockHeightDone,
|
|
19
|
+
deleteEmptyBlocks,
|
|
19
20
|
getBlockByHash,
|
|
20
21
|
getBlockHeights,
|
|
21
22
|
getBlockSeeds,
|
|
23
|
+
getLastNonEmptyBlockHash,
|
|
22
24
|
getLatestProcessedBlockHeight,
|
|
23
25
|
saveLastBlock,
|
|
24
26
|
} from "./sql/block-heights.queries.ts";
|
|
25
27
|
export type {
|
|
26
28
|
IBlockHeightDoneParams,
|
|
27
29
|
IBlockHeightDoneResult,
|
|
30
|
+
IDeleteEmptyBlocksParams,
|
|
31
|
+
IDeleteEmptyBlocksResult,
|
|
28
32
|
IGetBlockByHashParams,
|
|
29
33
|
IGetBlockByHashResult,
|
|
30
34
|
IGetBlockHeightsParams,
|
|
31
35
|
IGetBlockHeightsResult,
|
|
32
36
|
IGetBlockSeedsParams,
|
|
33
37
|
IGetBlockSeedsResult,
|
|
38
|
+
IGetLastNonEmptyBlockHashParams,
|
|
39
|
+
IGetLastNonEmptyBlockHashResult,
|
|
34
40
|
IGetLatestProcessedBlockHeightParams,
|
|
35
41
|
IGetLatestProcessedBlockHeightResult,
|
|
36
42
|
ISaveLastBlockParams,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { equal as assertEquals, ok as assert } from "node:assert/strict";
|
|
2
|
+
import { test } from "@effectstream/utils/runtime";
|
|
3
|
+
import {
|
|
4
|
+
makeTempDir,
|
|
5
|
+
mkdirRecursive,
|
|
6
|
+
readDir,
|
|
7
|
+
remove,
|
|
8
|
+
setFileTimes,
|
|
9
|
+
writeTextFile,
|
|
10
|
+
} from "@effectstream/utils/fs";
|
|
11
|
+
import { applyRetentionPolicy } from "./snapshot-handler.ts";
|
|
12
|
+
|
|
13
|
+
const MS_1H = 3_600_000;
|
|
14
|
+
const MS_24H = 24 * MS_1H;
|
|
15
|
+
|
|
16
|
+
const DEFAULT_RETENTION = {
|
|
17
|
+
lastDayHourly: true,
|
|
18
|
+
last3DaysSixHourly: true,
|
|
19
|
+
lastNDaysDaily: 7,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function makeSnapshotDir(): Promise<string> {
|
|
23
|
+
return await makeTempDir({ prefix: "snapshot-retention-test-" });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Seeds `dir` with one file per hour for `hours` hours, mtimes descending
|
|
28
|
+
* from `now` (offset 0h, 1h, ..., hours-1h).
|
|
29
|
+
*/
|
|
30
|
+
async function seedHourlySnapshots(
|
|
31
|
+
dir: string,
|
|
32
|
+
hours: number,
|
|
33
|
+
now: number,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
for (let i = 0; i < hours; i++) {
|
|
36
|
+
const mtimeMs = now - i * MS_1H;
|
|
37
|
+
const iso = new Date(mtimeMs).toISOString().replace(/:/g, "-").replace(/\.\d{3}/, "");
|
|
38
|
+
const path = `${dir}/snapshot-${iso}.dump`;
|
|
39
|
+
await writeTextFile(path, "x");
|
|
40
|
+
await setFileTimes(path, mtimeMs, mtimeMs);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function listSnapshots(dir: string): Promise<string[]> {
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
for await (const e of readDir(dir)) {
|
|
47
|
+
if (e.isFile && e.name.startsWith("snapshot-") && e.name.endsWith(".dump")) out.push(e.name);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test("retention: single pass over 8 days populates all tiers", async () => {
|
|
53
|
+
const dir = await makeSnapshotDir();
|
|
54
|
+
// Align `now` to a 24h UTC boundary so bucket math is deterministic.
|
|
55
|
+
const now = Math.floor(Date.now() / MS_24H) * MS_24H;
|
|
56
|
+
await seedHourlySnapshots(dir, 8 * 24, now);
|
|
57
|
+
|
|
58
|
+
await applyRetentionPolicy(dir, DEFAULT_RETENTION, now);
|
|
59
|
+
|
|
60
|
+
const survivors = await listSnapshots(dir);
|
|
61
|
+
// Expected (boundary-inclusive math, `now` aligned to 24h):
|
|
62
|
+
// hourly tier i=0..24 (age ≤ 24h) → 25 distinct hour buckets → 25 files
|
|
63
|
+
// 6h tier i=25..72 (24h<age≤72h), mtimes span 48h → 8 distinct 6h bins → 8 files
|
|
64
|
+
// daily tier i=73..168 (72h<age≤7d), mtimes span 96h → 4 distinct day bins → 4 files
|
|
65
|
+
// deleted i=169..191 (age > 7d) → 0 files
|
|
66
|
+
assertEquals(survivors.length, 25 + 8 + 4);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("retention: bucket keys are stable across successive passes (regression)", async () => {
|
|
70
|
+
// The bug: age-relative bucket keys slide forward every call, so the file
|
|
71
|
+
// kept in "bucket 6h:4" gets replaced each tick by a newer file that just
|
|
72
|
+
// crossed 24h. After many iterations only ~hourly files survive.
|
|
73
|
+
//
|
|
74
|
+
// With mtime-absolute bucket keys a file's bucket is fixed at write time.
|
|
75
|
+
// Simulate 8 days of hourly snapshots with retention running each hour and
|
|
76
|
+
// confirm the survivor count stays consistent with multi-tier retention.
|
|
77
|
+
const dir = await makeSnapshotDir();
|
|
78
|
+
const baseNow = Math.floor(Date.now() / MS_24H) * MS_24H;
|
|
79
|
+
|
|
80
|
+
// Simulate 8 days * 24 ticks: each tick drops a new snapshot and runs retention.
|
|
81
|
+
const totalTicks = 8 * 24;
|
|
82
|
+
for (let tick = 0; tick < totalTicks; tick++) {
|
|
83
|
+
const now = baseNow + tick * MS_1H;
|
|
84
|
+
const iso = new Date(now).toISOString().replace(/:/g, "-").replace(/\.\d{3}/, "");
|
|
85
|
+
const path = `${dir}/snapshot-${iso}.dump`;
|
|
86
|
+
await writeTextFile(path, "x");
|
|
87
|
+
await setFileTimes(path, now, now);
|
|
88
|
+
await applyRetentionPolicy(dir, DEFAULT_RETENTION, now);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const survivors = await listSnapshots(dir);
|
|
92
|
+
// With the old (buggy) bucketing this collapses to ~26 (hourly tier plus
|
|
93
|
+
// a handful of in-flight files). With the fix we expect ~37.
|
|
94
|
+
assert(
|
|
95
|
+
survivors.length >= 32,
|
|
96
|
+
`expected multi-tier retention to keep ≥32 files after 8 days of ticks, got ${survivors.length}`,
|
|
97
|
+
);
|
|
98
|
+
assert(
|
|
99
|
+
survivors.length <= 42,
|
|
100
|
+
`expected ≤42 files after retention, got ${survivors.length}`,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("retention: lastDayHourly=false keeps all files in hourly tier", async () => {
|
|
105
|
+
const dir = await makeSnapshotDir();
|
|
106
|
+
const now = Math.floor(Date.now() / MS_24H) * MS_24H;
|
|
107
|
+
await seedHourlySnapshots(dir, 24, now);
|
|
108
|
+
|
|
109
|
+
await applyRetentionPolicy(
|
|
110
|
+
dir,
|
|
111
|
+
{ ...DEFAULT_RETENTION, lastDayHourly: false },
|
|
112
|
+
now,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const survivors = await listSnapshots(dir);
|
|
116
|
+
assertEquals(survivors.length, 24);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("retention: last3DaysSixHourly=false keeps all files in 24h–72h tier", async () => {
|
|
120
|
+
const dir = await makeSnapshotDir();
|
|
121
|
+
const now = Math.floor(Date.now() / MS_24H) * MS_24H;
|
|
122
|
+
// Seed 72 hourly files spanning 3 days.
|
|
123
|
+
await seedHourlySnapshots(dir, 72, now);
|
|
124
|
+
|
|
125
|
+
await applyRetentionPolicy(
|
|
126
|
+
dir,
|
|
127
|
+
{ ...DEFAULT_RETENTION, last3DaysSixHourly: false },
|
|
128
|
+
now,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const survivors = await listSnapshots(dir);
|
|
132
|
+
// Hourly tier (i=0..24) → 25 distinct-hour keepers
|
|
133
|
+
// 6h tier with short-circuit → all i=25..71 kept (47 files)
|
|
134
|
+
assertEquals(survivors.length, 25 + 47);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("retention: lastNDaysDaily=1 deletes everything past 24h", async () => {
|
|
138
|
+
const dir = await makeSnapshotDir();
|
|
139
|
+
const now = Math.floor(Date.now() / MS_24H) * MS_24H;
|
|
140
|
+
await seedHourlySnapshots(dir, 48, now);
|
|
141
|
+
|
|
142
|
+
await applyRetentionPolicy(
|
|
143
|
+
dir,
|
|
144
|
+
{ ...DEFAULT_RETENTION, lastNDaysDaily: 1 },
|
|
145
|
+
now,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const survivors = await listSnapshots(dir);
|
|
149
|
+
// Only i=0..24 survive (age ≤ 24h = MS_ND). 25 distinct hour buckets.
|
|
150
|
+
assertEquals(survivors.length, 25);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("retention: empty directory is a no-op", async () => {
|
|
154
|
+
const dir = await makeSnapshotDir();
|
|
155
|
+
await applyRetentionPolicy(dir, DEFAULT_RETENTION, Date.now());
|
|
156
|
+
const survivors = await listSnapshots(dir);
|
|
157
|
+
assertEquals(survivors.length, 0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("retention: missing directory is a no-op (warns, does not throw)", async () => {
|
|
161
|
+
const dir = await makeTempDir({ prefix: "snapshot-retention-missing-" });
|
|
162
|
+
await remove(dir, { recursive: true });
|
|
163
|
+
// Should not throw.
|
|
164
|
+
await applyRetentionPolicy(dir, DEFAULT_RETENTION, Date.now());
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("retention: leaves non-snapshot files untouched", async () => {
|
|
168
|
+
const dir = await makeSnapshotDir();
|
|
169
|
+
await mkdirRecursive(dir);
|
|
170
|
+
const now = Math.floor(Date.now() / MS_24H) * MS_24H;
|
|
171
|
+
// Seed an ancient snapshot that would be deleted by policy (age > 7d).
|
|
172
|
+
await seedHourlySnapshots(dir, 1, now - 30 * MS_24H);
|
|
173
|
+
// Seed unrelated files that must be left alone.
|
|
174
|
+
await writeTextFile(`${dir}/README.md`, "keep me");
|
|
175
|
+
await writeTextFile(`${dir}/snapshot-bad-name.txt`, "keep me");
|
|
176
|
+
await writeTextFile(`${dir}/other.dump`, "keep me");
|
|
177
|
+
|
|
178
|
+
await applyRetentionPolicy(dir, DEFAULT_RETENTION, now);
|
|
179
|
+
|
|
180
|
+
const remaining: string[] = [];
|
|
181
|
+
for await (const e of readDir(dir)) remaining.push(e.name);
|
|
182
|
+
assert(remaining.includes("README.md"));
|
|
183
|
+
assert(remaining.includes("snapshot-bad-name.txt"));
|
|
184
|
+
assert(remaining.includes("other.dump"));
|
|
185
|
+
// Ancient snapshot-*.dump should have been deleted.
|
|
186
|
+
assertEquals(remaining.filter((n) => n.startsWith("snapshot-") && n.endsWith(".dump")).length, 0);
|
|
187
|
+
});
|
package/src/snapshot-handler.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { ENV } from "@effectstream/utils/node-env";
|
|
2
|
+
import { cwd } from "@effectstream/utils/runtime";
|
|
3
|
+
import {
|
|
4
|
+
mkdirRecursive,
|
|
5
|
+
readDir,
|
|
6
|
+
remove,
|
|
7
|
+
statMtime,
|
|
8
|
+
} from "@effectstream/utils/fs";
|
|
9
|
+
import { spawnOutput } from "@effectstream/utils/runtime-spawn";
|
|
2
10
|
import { sleep, until, type Operation } from "effection";
|
|
3
11
|
|
|
12
|
+
/** `process.env` works under Node, Bun, and Deno (via node: compat). */
|
|
13
|
+
declare const process: { env: Record<string, string | undefined> };
|
|
14
|
+
|
|
4
15
|
export interface SnapshotRetentionConfig {
|
|
5
16
|
/** One per hour for last 24 h. Default: `true` */
|
|
6
17
|
lastDayHourly?: boolean;
|
|
@@ -19,6 +30,39 @@ export interface SnapshotConfig {
|
|
|
19
30
|
retention?: SnapshotRetentionConfig;
|
|
20
31
|
}
|
|
21
32
|
|
|
33
|
+
interface ResolvedSnapshotConfig {
|
|
34
|
+
intervalSeconds: number;
|
|
35
|
+
path: string;
|
|
36
|
+
retention: Required<SnapshotRetentionConfig>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Precedence: explicit config field > env var > hardcoded default.
|
|
41
|
+
* Env vars other than INTERVAL_SECONDS carry their hardcoded defaults inside
|
|
42
|
+
* `ENV.getConfig`, so `??` here only needs to cover INTERVAL_SECONDS (whose
|
|
43
|
+
* env `defaultValue` is intentionally `undefined`).
|
|
44
|
+
*/
|
|
45
|
+
function resolveSnapshotConfig(config?: SnapshotConfig): ResolvedSnapshotConfig {
|
|
46
|
+
return {
|
|
47
|
+
intervalSeconds:
|
|
48
|
+
config?.intervalSeconds
|
|
49
|
+
?? ENV.EFFECTSTREAM_SNAPSHOT_INTERVAL_SECONDS
|
|
50
|
+
?? 3600,
|
|
51
|
+
path: config?.path ?? ENV.EFFECTSTREAM_SNAPSHOT_PATH,
|
|
52
|
+
retention: {
|
|
53
|
+
lastDayHourly:
|
|
54
|
+
config?.retention?.lastDayHourly
|
|
55
|
+
?? ENV.EFFECTSTREAM_SNAPSHOT_LAST_DAY_HOURLY,
|
|
56
|
+
last3DaysSixHourly:
|
|
57
|
+
config?.retention?.last3DaysSixHourly
|
|
58
|
+
?? ENV.EFFECTSTREAM_SNAPSHOT_LAST_3_DAYS_SIX_HOURLY,
|
|
59
|
+
lastNDaysDaily:
|
|
60
|
+
config?.retention?.lastNDaysDaily
|
|
61
|
+
?? ENV.EFFECTSTREAM_SNAPSHOT_LAST_N_DAYS,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
22
66
|
/**
|
|
23
67
|
* Creates a database snapshot using `pg_dump` in custom format (`-F c`).
|
|
24
68
|
* Must be called AFTER releasing the DB mutex (pg_dump opens its own connection).
|
|
@@ -35,14 +79,22 @@ export async function createSnapshot(
|
|
|
35
79
|
return;
|
|
36
80
|
}
|
|
37
81
|
|
|
38
|
-
const
|
|
82
|
+
const resolved = resolveSnapshotConfig(config);
|
|
83
|
+
const snapshotDir = resolved.path;
|
|
39
84
|
const timestamp = new Date().toISOString().replace(/:/g, "-").replace(/\.\d{3}/, "");
|
|
40
85
|
const snapshotPath = `${snapshotDir}/snapshot-${timestamp}.dump`;
|
|
41
86
|
|
|
42
|
-
await
|
|
87
|
+
await mkdirRecursive(snapshotDir);
|
|
43
88
|
console.log(`[Snapshot] Creating snapshot at ${snapshotPath}...`);
|
|
44
89
|
|
|
45
|
-
|
|
90
|
+
// Inherit the ambient env and add PGPASSWORD. `process.env` is populated on
|
|
91
|
+
// Node/Bun natively and on Deno via node:compat when --allow-env is set.
|
|
92
|
+
const ambientEnv: Record<string, string> = {};
|
|
93
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
94
|
+
if (v !== undefined) ambientEnv[k] = v;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = await spawnOutput("pg_dump", {
|
|
46
98
|
args: [
|
|
47
99
|
"-h", ENV.DB_HOST,
|
|
48
100
|
"-p", String(ENV.DB_PORT),
|
|
@@ -51,18 +103,16 @@ export async function createSnapshot(
|
|
|
51
103
|
"-F", "c",
|
|
52
104
|
"-f", snapshotPath,
|
|
53
105
|
],
|
|
54
|
-
env: { ...
|
|
106
|
+
env: { ...ambientEnv, PGPASSWORD: ENV.DB_PW ?? "" },
|
|
55
107
|
stdout: "inherit",
|
|
56
108
|
stderr: "inherit",
|
|
57
109
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!status.success) {
|
|
61
|
-
throw new Error(`[Snapshot] pg_dump failed with exit code ${status.code}`);
|
|
110
|
+
if (!result.success) {
|
|
111
|
+
throw new Error(`[Snapshot] pg_dump failed with exit code ${result.code}`);
|
|
62
112
|
}
|
|
63
113
|
|
|
64
114
|
console.log(`[Snapshot] Snapshot created: ${snapshotPath}`);
|
|
65
|
-
await applyRetentionPolicy(snapshotDir,
|
|
115
|
+
await applyRetentionPolicy(snapshotDir, resolved.retention);
|
|
66
116
|
}
|
|
67
117
|
|
|
68
118
|
/**
|
|
@@ -73,33 +123,38 @@ export async function createSnapshot(
|
|
|
73
123
|
export function* runSnapshotLoop(
|
|
74
124
|
snapshotConfig: SnapshotConfig,
|
|
75
125
|
): Operation<void> {
|
|
76
|
-
const
|
|
77
|
-
|
|
126
|
+
const resolved = resolveSnapshotConfig(snapshotConfig);
|
|
127
|
+
const intervalMs = resolved.intervalSeconds * 1000;
|
|
128
|
+
console.log(`[Snapshot] Loop started (interval ${intervalMs / 1000}s, path ${resolved.path}, cwd ${cwd()})`);
|
|
78
129
|
while (true) {
|
|
130
|
+
yield* sleep(intervalMs);
|
|
79
131
|
console.log(`[Snapshot] Firing`);
|
|
80
132
|
try {
|
|
81
|
-
yield* until(createSnapshot(
|
|
133
|
+
yield* until(createSnapshot(resolved));
|
|
82
134
|
console.log(`[Snapshot] Done; sleeping ${intervalMs / 1000}s`);
|
|
83
135
|
} catch (e) {
|
|
84
136
|
console.error("[Snapshot] Failed:", e);
|
|
85
137
|
}
|
|
86
|
-
yield* sleep(intervalMs);
|
|
87
138
|
}
|
|
88
139
|
}
|
|
89
140
|
|
|
90
141
|
/**
|
|
91
142
|
* Time-based tiered retention: keeps one file per bucket (newest wins),
|
|
92
143
|
* deletes everything beyond `lastNDaysDaily` days.
|
|
144
|
+
*
|
|
145
|
+
* Bucket keys are derived from the file's wall-clock mtime, not from its
|
|
146
|
+
* age relative to `now`. A file's bucket is therefore fixed at write time
|
|
147
|
+
* and does not slide forward every tick, which is what lets files actually
|
|
148
|
+
* age into the 6-hourly and daily tiers.
|
|
93
149
|
*/
|
|
94
|
-
async function applyRetentionPolicy(
|
|
150
|
+
export async function applyRetentionPolicy(
|
|
95
151
|
snapshotDir: string,
|
|
96
|
-
retention
|
|
152
|
+
retention: Required<SnapshotRetentionConfig>,
|
|
153
|
+
nowMs: number = Date.now(),
|
|
97
154
|
): Promise<void> {
|
|
98
|
-
const lastDayHourly
|
|
99
|
-
const last3DaysSixHourly = retention?.last3DaysSixHourly ?? true;
|
|
100
|
-
const lastNDaysDaily = retention?.lastNDaysDaily ?? 7;
|
|
155
|
+
const { lastDayHourly, last3DaysSixHourly, lastNDaysDaily } = retention;
|
|
101
156
|
|
|
102
|
-
const now =
|
|
157
|
+
const now = nowMs;
|
|
103
158
|
const MS_1H = 3_600_000;
|
|
104
159
|
const MS_24H = 24 * MS_1H;
|
|
105
160
|
const MS_3D = 3 * MS_24H;
|
|
@@ -108,12 +163,12 @@ async function applyRetentionPolicy(
|
|
|
108
163
|
const entries: { path: string; mtime: number }[] = [];
|
|
109
164
|
|
|
110
165
|
try {
|
|
111
|
-
for await (const entry of
|
|
166
|
+
for await (const entry of readDir(snapshotDir)) {
|
|
112
167
|
if (!entry.isFile || !entry.name.startsWith("snapshot-") || !entry.name.endsWith(".dump")) continue;
|
|
113
168
|
const filePath = `${snapshotDir}/${entry.name}`;
|
|
114
169
|
try {
|
|
115
|
-
const
|
|
116
|
-
entries.push({ path: filePath, mtime
|
|
170
|
+
const mtime = await statMtime(filePath);
|
|
171
|
+
entries.push({ path: filePath, mtime });
|
|
117
172
|
} catch { /* file vanished between readDir and stat */ }
|
|
118
173
|
}
|
|
119
174
|
} catch (e) {
|
|
@@ -137,12 +192,12 @@ async function applyRetentionPolicy(
|
|
|
137
192
|
|
|
138
193
|
if (age <= MS_24H) {
|
|
139
194
|
if (!lastDayHourly) { keepers.add(entry.path); continue; }
|
|
140
|
-
bucketKey = `h:${Math.floor(
|
|
195
|
+
bucketKey = `h:${Math.floor(entry.mtime / MS_1H)}`;
|
|
141
196
|
} else if (age <= MS_3D) {
|
|
142
197
|
if (!last3DaysSixHourly) { keepers.add(entry.path); continue; }
|
|
143
|
-
bucketKey = `6h:${Math.floor(
|
|
198
|
+
bucketKey = `6h:${Math.floor(entry.mtime / (6 * MS_1H))}`;
|
|
144
199
|
} else {
|
|
145
|
-
bucketKey = `d:${Math.floor(
|
|
200
|
+
bucketKey = `d:${Math.floor(entry.mtime / MS_24H)}`;
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
if (!seenBuckets.has(bucketKey)) {
|
|
@@ -154,7 +209,7 @@ async function applyRetentionPolicy(
|
|
|
154
209
|
for (const entry of entries) {
|
|
155
210
|
if (!keepers.has(entry.path)) {
|
|
156
211
|
try {
|
|
157
|
-
await
|
|
212
|
+
await remove(entry.path);
|
|
158
213
|
console.log(`[Snapshot] Retention: deleted ${entry.path}`);
|
|
159
214
|
} catch (e) {
|
|
160
215
|
console.warn(`[Snapshot] Failed to delete ${entry.path}:`, e);
|
|
@@ -174,6 +174,34 @@ const saveLastBlockIR: any = {"usedParamSet":{"block_height":true,"ver":true,"ma
|
|
|
174
174
|
export const saveLastBlock = new PreparedQuery<ISaveLastBlockParams,ISaveLastBlockResult>(saveLastBlockIR);
|
|
175
175
|
|
|
176
176
|
|
|
177
|
+
/** 'GetLastNonEmptyBlockHash' parameters type */
|
|
178
|
+
export type IGetLastNonEmptyBlockHashParams = void;
|
|
179
|
+
|
|
180
|
+
/** 'GetLastNonEmptyBlockHash' return type */
|
|
181
|
+
export interface IGetLastNonEmptyBlockHashResult {
|
|
182
|
+
effectstream_block_hash: Buffer | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 'GetLastNonEmptyBlockHash' query type */
|
|
186
|
+
export interface IGetLastNonEmptyBlockHashQuery {
|
|
187
|
+
params: IGetLastNonEmptyBlockHashParams;
|
|
188
|
+
result: IGetLastNonEmptyBlockHashResult;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const getLastNonEmptyBlockHashIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT effectstream_block_hash FROM effectstream.effectstream_blocks\nWHERE effectstream_block_hash IS NOT NULL\n AND effectstream_block_hash != '\\x307830'::bytea\nORDER BY block_height DESC\nLIMIT 1"};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Query generated from SQL:
|
|
195
|
+
* ```
|
|
196
|
+
* SELECT effectstream_block_hash FROM effectstream.effectstream_blocks
|
|
197
|
+
* WHERE effectstream_block_hash IS NOT NULL
|
|
198
|
+
* AND effectstream_block_hash != '\x307830'::bytea
|
|
199
|
+
* ORDER BY block_height DESC
|
|
200
|
+
* LIMIT 1
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
export const getLastNonEmptyBlockHash = new PreparedQuery<IGetLastNonEmptyBlockHashParams,IGetLastNonEmptyBlockHashResult>(getLastNonEmptyBlockHashIR);
|
|
204
|
+
|
|
177
205
|
/** 'BlockHeightDone' parameters type */
|
|
178
206
|
export interface IBlockHeightDoneParams {
|
|
179
207
|
block_hash: Buffer;
|
|
@@ -189,6 +217,29 @@ export interface IBlockHeightDoneQuery {
|
|
|
189
217
|
result: IBlockHeightDoneResult;
|
|
190
218
|
}
|
|
191
219
|
|
|
220
|
+
/** 'DeleteEmptyBlocks' parameters type */
|
|
221
|
+
export type IDeleteEmptyBlocksParams = void;
|
|
222
|
+
|
|
223
|
+
/** 'DeleteEmptyBlocks' return type */
|
|
224
|
+
export type IDeleteEmptyBlocksResult = void;
|
|
225
|
+
|
|
226
|
+
/** 'DeleteEmptyBlocks' query type */
|
|
227
|
+
export interface IDeleteEmptyBlocksQuery {
|
|
228
|
+
params: IDeleteEmptyBlocksParams;
|
|
229
|
+
result: IDeleteEmptyBlocksResult;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const deleteEmptyBlocksIR: any = {"usedParamSet":{},"params":[],"statement":"DELETE FROM effectstream.effectstream_blocks\nWHERE effectstream_block_hash = '\\x307830'::bytea"};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Query generated from SQL:
|
|
236
|
+
* ```
|
|
237
|
+
* DELETE FROM effectstream.effectstream_blocks
|
|
238
|
+
* WHERE effectstream_block_hash = '\x307830'::bytea
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
export const deleteEmptyBlocks = new PreparedQuery<IDeleteEmptyBlocksParams,IDeleteEmptyBlocksResult>(deleteEmptyBlocksIR);
|
|
242
|
+
|
|
192
243
|
const blockHeightDoneIR: any = {"usedParamSet":{"block_hash":true,"block_height":true},"params":[{"name":"block_hash","required":true,"transform":{"type":"scalar"},"locs":[{"a":70,"b":81}]},{"name":"block_height","required":true,"transform":{"type":"scalar"},"locs":[{"a":104,"b":117}]}],"statement":"UPDATE effectstream.effectstream_blocks\nSET\neffectstream_block_hash = :block_hash!\nWHERE block_height = :block_height!"};
|
|
193
244
|
|
|
194
245
|
/**
|
|
@@ -38,6 +38,17 @@ seed = EXCLUDED.seed,
|
|
|
38
38
|
ms_timestamp = EXCLUDED.ms_timestamp,
|
|
39
39
|
effectstream_block_hash = EXCLUDED.effectstream_block_hash;
|
|
40
40
|
|
|
41
|
+
/* @name getLastNonEmptyBlockHash */
|
|
42
|
+
SELECT effectstream_block_hash FROM effectstream.effectstream_blocks
|
|
43
|
+
WHERE effectstream_block_hash IS NOT NULL
|
|
44
|
+
AND effectstream_block_hash != '\x307830'::bytea
|
|
45
|
+
ORDER BY block_height DESC
|
|
46
|
+
LIMIT 1;
|
|
47
|
+
|
|
48
|
+
/* @name deleteEmptyBlocks */
|
|
49
|
+
DELETE FROM effectstream.effectstream_blocks
|
|
50
|
+
WHERE effectstream_block_hash = '\x307830'::bytea;
|
|
51
|
+
|
|
41
52
|
/* @name blockHeightDone */
|
|
42
53
|
UPDATE effectstream.effectstream_blocks
|
|
43
54
|
SET
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Examples for the README — verify the public surface resolves without
|
|
2
|
+
// touching an actual Postgres or PgLite instance.
|
|
3
|
+
|
|
4
|
+
import { test, expect } from "bun:test";
|
|
5
|
+
import * as db from "../src/mod.ts";
|
|
6
|
+
|
|
7
|
+
test("README: connection helpers are exported", () => {
|
|
8
|
+
expect(typeof db.getConnection).toBe("function");
|
|
9
|
+
expect(typeof db.getPersistentConnection).toBe("function");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("README: PgLite mutex helpers are exported", () => {
|
|
13
|
+
expect(typeof db.acquireDBMutex).toBe("function");
|
|
14
|
+
expect(typeof db.releaseDBMutex).toBe("function");
|
|
15
|
+
expect(typeof db.waitUntilFree).toBe("function");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("README: snapshot helpers are exported", () => {
|
|
19
|
+
expect(typeof db.createSnapshot).toBe("function");
|
|
20
|
+
expect(typeof db.runSnapshotLoop).toBe("function");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("README: pgtyped query objects are exported", () => {
|
|
24
|
+
expect(db.getBlockHeights).toBeDefined();
|
|
25
|
+
expect(db.getBlockByHash).toBeDefined();
|
|
26
|
+
expect(db.getLatestProcessedBlockHeight).toBeDefined();
|
|
27
|
+
});
|