@meterbility/store-postgres 0.3.1
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/LICENSE +33 -0
- package/README.md +11 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +530 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meterbility authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
----------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
Note: This MIT license covers everything in this repository EXCEPT the
|
|
26
|
+
contents of the /ee directory (when present), which are licensed under the
|
|
27
|
+
Elastic License 2.0 (ELv2) — see /ee/LICENSE.
|
|
28
|
+
|
|
29
|
+
The /ee directory is reserved for Enterprise Edition modules: multi-tenant
|
|
30
|
+
fleet orchestration, SSO/RBAC, audit logs, and long-retention features.
|
|
31
|
+
|
|
32
|
+
Nothing under /ee today; the directory is created as a forward-compatible
|
|
33
|
+
marker so the licensing boundary is visible before any /ee code lands.
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @meterbility/store-postgres
|
|
2
|
+
|
|
3
|
+
Part of [Meterbility](https://github.com/HoneycombHairDevelopers/Meterbility) — the debugger for AI agents. Capture every run, inspect every decision, pause and inject live, fork from any step.
|
|
4
|
+
|
|
5
|
+
Postgres backend for single-operator multi-machine sync. Experimental subset of the SQLite store.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @meterbility/store-postgres
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
See the [Meterbility documentation](https://github.com/HoneycombHairDevelopers/Meterbility#readme) for the full guide. MIT licensed.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Client } from 'pg';
|
|
2
|
+
import { Run, Step } from '@meterbility/shared';
|
|
3
|
+
import { Store } from '@meterbility/collector';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Async, network-backed equivalent of the SQLite Store. Connects via
|
|
7
|
+
* the standard `pg` driver. Caller is responsible for `close()` when
|
|
8
|
+
* done.
|
|
9
|
+
*
|
|
10
|
+
* Connection URL precedence:
|
|
11
|
+
* 1. opts.url
|
|
12
|
+
* 2. process.env.METERBILITY_DB_URL
|
|
13
|
+
* 3. process.env.DATABASE_URL
|
|
14
|
+
* 4. throw
|
|
15
|
+
*/
|
|
16
|
+
declare class PostgresStore {
|
|
17
|
+
readonly client: Client;
|
|
18
|
+
private constructor();
|
|
19
|
+
static open(opts?: {
|
|
20
|
+
url?: string;
|
|
21
|
+
}): Promise<PostgresStore>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
/** Convenience for blob storage in Postgres mode (bytea-backed). */
|
|
24
|
+
putBlob(content: Buffer | string, ref: string): Promise<void>;
|
|
25
|
+
getBlob(ref: string): Promise<Buffer | undefined>;
|
|
26
|
+
getBlobString(ref: string): Promise<string | undefined>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Postgres schema mirroring `@meterbility/collector` (SQLite). Same column
|
|
31
|
+
* names, same semantics. Differences:
|
|
32
|
+
* - JSON columns use `jsonb` (queryable + indexable later).
|
|
33
|
+
* - Timestamps use `timestamptz` rather than free-form text.
|
|
34
|
+
* - Indexes are explicit; SQLite's `WAL` / `synchronous` pragmas have
|
|
35
|
+
* no Postgres equivalent and aren't applied.
|
|
36
|
+
*
|
|
37
|
+
* Designed for Meterbility's hosted/team tier (SPEC §15.3). Local mode keeps
|
|
38
|
+
* the SQLite store as default; this exists for the deployments where
|
|
39
|
+
* multiple operators share a project's run history.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Version history (mirrors `@meterbility/collector` SCHEMA_VERSION):
|
|
43
|
+
* v3 → v4 — file_change + baseline_tree tables, runs.baseline_tree_id,
|
|
44
|
+
* runs.probe_state. Per v0.3 §3.3, full enum coverage in
|
|
45
|
+
* CHECK constraints up front so v0.4 / v0.5 don't need
|
|
46
|
+
* migrations as new derived_from / op values come online.
|
|
47
|
+
* Additive-only per v0.2 §17.
|
|
48
|
+
*/
|
|
49
|
+
declare const POSTGRES_SCHEMA_VERSION = 4;
|
|
50
|
+
declare function ensurePostgresSchema(client: Client): Promise<void>;
|
|
51
|
+
|
|
52
|
+
declare function pgUpsertProject(store: PostgresStore, cwd: string, name?: string): Promise<{
|
|
53
|
+
project_id: string;
|
|
54
|
+
cwd: string;
|
|
55
|
+
name: string;
|
|
56
|
+
}>;
|
|
57
|
+
declare function pgUpsertAgent(store: PostgresStore, projectId: string, name: string): Promise<{
|
|
58
|
+
agent_id: string;
|
|
59
|
+
project_id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
}>;
|
|
62
|
+
declare function pgInsertRun(store: PostgresStore, run: Run): Promise<void>;
|
|
63
|
+
declare function pgInsertStep(store: PostgresStore, step: Step): Promise<void>;
|
|
64
|
+
declare function pgListRuns(store: PostgresStore, opts?: {
|
|
65
|
+
limit?: number;
|
|
66
|
+
projectId?: string;
|
|
67
|
+
}): Promise<Run[]>;
|
|
68
|
+
declare function pgGetRun(store: PostgresStore, runId: string): Promise<Run | undefined>;
|
|
69
|
+
declare function pgListSteps(store: PostgresStore, runId: string): Promise<Step[]>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Copy a local SQLite Store's data into a Postgres deployment. The
|
|
73
|
+
* intended use case (SPEC §15.3): a team operator wants to share a
|
|
74
|
+
* project's run history with teammates without giving up the local
|
|
75
|
+
* dogfood loop.
|
|
76
|
+
*
|
|
77
|
+
* Idempotent — re-running won't duplicate rows because every insert
|
|
78
|
+
* uses ON CONFLICT DO UPDATE.
|
|
79
|
+
*
|
|
80
|
+
* Sync order: projects → agents → runs → steps → blobs (referenced by
|
|
81
|
+
* step rows and run rows).
|
|
82
|
+
*/
|
|
83
|
+
interface SyncReport {
|
|
84
|
+
runs: number;
|
|
85
|
+
steps: number;
|
|
86
|
+
blobs: number;
|
|
87
|
+
bytes: number;
|
|
88
|
+
}
|
|
89
|
+
declare function syncSqliteToPostgres(sqlite: Store, postgres: PostgresStore, opts?: {
|
|
90
|
+
limitRuns?: number;
|
|
91
|
+
}): Promise<SyncReport>;
|
|
92
|
+
|
|
93
|
+
export { POSTGRES_SCHEMA_VERSION, PostgresStore, type SyncReport, ensurePostgresSchema, pgGetRun, pgInsertRun, pgInsertStep, pgListRuns, pgListSteps, pgUpsertAgent, pgUpsertProject, syncSqliteToPostgres };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// src/store.ts
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
|
|
4
|
+
// src/schema.ts
|
|
5
|
+
var POSTGRES_SCHEMA_VERSION = 4;
|
|
6
|
+
async function ensurePostgresSchema(client) {
|
|
7
|
+
await client.query(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
9
|
+
key TEXT PRIMARY KEY,
|
|
10
|
+
value TEXT NOT NULL
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
14
|
+
project_id TEXT PRIMARY KEY,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
cwd TEXT NOT NULL UNIQUE,
|
|
17
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
21
|
+
agent_id TEXT PRIMARY KEY,
|
|
22
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
23
|
+
name TEXT NOT NULL,
|
|
24
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
25
|
+
UNIQUE(project_id, name)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
29
|
+
run_id TEXT PRIMARY KEY,
|
|
30
|
+
agent_id TEXT NOT NULL REFERENCES agents(agent_id),
|
|
31
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
32
|
+
source_session_id TEXT,
|
|
33
|
+
source_runtime TEXT NOT NULL,
|
|
34
|
+
title TEXT,
|
|
35
|
+
status TEXT NOT NULL,
|
|
36
|
+
started_at TIMESTAMPTZ NOT NULL,
|
|
37
|
+
ended_at TIMESTAMPTZ,
|
|
38
|
+
git_branch TEXT,
|
|
39
|
+
cwd TEXT,
|
|
40
|
+
fork_origin_run_id TEXT REFERENCES runs(run_id),
|
|
41
|
+
fork_origin_step_id TEXT,
|
|
42
|
+
tokens_total_input BIGINT NOT NULL DEFAULT 0,
|
|
43
|
+
tokens_total_output BIGINT NOT NULL DEFAULT 0,
|
|
44
|
+
tokens_total_cached BIGINT NOT NULL DEFAULT 0,
|
|
45
|
+
cost_cents DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
46
|
+
step_count INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
tags JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_runs_project_started
|
|
51
|
+
ON runs(project_id, started_at DESC);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_runs_session
|
|
53
|
+
ON runs(source_session_id);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_runs_fork_origin
|
|
55
|
+
ON runs(fork_origin_run_id);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS steps (
|
|
58
|
+
step_id TEXT PRIMARY KEY,
|
|
59
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
60
|
+
parent_step_id TEXT,
|
|
61
|
+
fork_origin_id TEXT,
|
|
62
|
+
sequence INTEGER NOT NULL,
|
|
63
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
64
|
+
model TEXT NOT NULL,
|
|
65
|
+
context_snapshot_id TEXT NOT NULL,
|
|
66
|
+
decision_ref TEXT NOT NULL,
|
|
67
|
+
action JSONB NOT NULL,
|
|
68
|
+
outcome JSONB NOT NULL,
|
|
69
|
+
tokens_input BIGINT NOT NULL DEFAULT 0,
|
|
70
|
+
tokens_output BIGINT NOT NULL DEFAULT 0,
|
|
71
|
+
tokens_cached_read BIGINT NOT NULL DEFAULT 0,
|
|
72
|
+
tokens_cache_creation BIGINT NOT NULL DEFAULT 0,
|
|
73
|
+
tokens_cache_creation_1h BIGINT NOT NULL DEFAULT 0,
|
|
74
|
+
tokens_reasoning BIGINT,
|
|
75
|
+
latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
cost_cents DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
77
|
+
status TEXT NOT NULL,
|
|
78
|
+
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
79
|
+
UNIQUE(run_id, sequence)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_steps_run
|
|
83
|
+
ON steps(run_id, sequence);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_steps_context
|
|
85
|
+
ON steps(context_snapshot_id);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS context_snapshots (
|
|
88
|
+
snapshot_id TEXT PRIMARY KEY,
|
|
89
|
+
blob_ref TEXT NOT NULL,
|
|
90
|
+
component_count INTEGER NOT NULL DEFAULT 0,
|
|
91
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
-- Blob content is stored inline as bytea in Postgres mode (no
|
|
95
|
+
-- separate filesystem layer). Use BlobStore.put / .get on the store.
|
|
96
|
+
CREATE TABLE IF NOT EXISTS blobs (
|
|
97
|
+
blob_ref TEXT PRIMARY KEY,
|
|
98
|
+
content BYTEA NOT NULL,
|
|
99
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS forks (
|
|
103
|
+
fork_id TEXT PRIMARY KEY,
|
|
104
|
+
origin_run_id TEXT NOT NULL REFERENCES runs(run_id),
|
|
105
|
+
origin_step_id TEXT NOT NULL,
|
|
106
|
+
fork_run_id TEXT NOT NULL REFERENCES runs(run_id),
|
|
107
|
+
edit_type TEXT NOT NULL,
|
|
108
|
+
edit_payload JSONB NOT NULL,
|
|
109
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_forks_origin
|
|
113
|
+
ON forks(origin_run_id);
|
|
114
|
+
|
|
115
|
+
CREATE TABLE IF NOT EXISTS annotations (
|
|
116
|
+
annotation_id TEXT PRIMARY KEY,
|
|
117
|
+
target_kind TEXT NOT NULL,
|
|
118
|
+
target_id TEXT NOT NULL,
|
|
119
|
+
author TEXT NOT NULL,
|
|
120
|
+
verdict TEXT,
|
|
121
|
+
note TEXT,
|
|
122
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_annotations_target
|
|
126
|
+
ON annotations(target_kind, target_id);
|
|
127
|
+
|
|
128
|
+
CREATE TABLE IF NOT EXISTS regression_tests (
|
|
129
|
+
test_id TEXT PRIMARY KEY,
|
|
130
|
+
name TEXT NOT NULL UNIQUE,
|
|
131
|
+
description TEXT,
|
|
132
|
+
assertions JSONB NOT NULL,
|
|
133
|
+
canonical_run_id TEXT REFERENCES runs(run_id),
|
|
134
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
CREATE TABLE IF NOT EXISTS regression_results (
|
|
138
|
+
result_id TEXT PRIMARY KEY,
|
|
139
|
+
test_id TEXT NOT NULL REFERENCES regression_tests(test_id) ON DELETE CASCADE,
|
|
140
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
141
|
+
passed BOOLEAN NOT NULL,
|
|
142
|
+
details JSONB NOT NULL,
|
|
143
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_regression_results_test
|
|
147
|
+
ON regression_results(test_id, created_at DESC);
|
|
148
|
+
|
|
149
|
+
-- \u2500\u2500\u2500 v0.3 Track A: per-step file change capture (mirror) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
150
|
+
-- See packages/collector/src/schema.ts for the design rationale.
|
|
151
|
+
CREATE TABLE IF NOT EXISTS file_change (
|
|
152
|
+
file_change_id TEXT PRIMARY KEY,
|
|
153
|
+
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
|
|
154
|
+
step_id TEXT NOT NULL REFERENCES steps(step_id) ON DELETE CASCADE,
|
|
155
|
+
sequence INTEGER NOT NULL,
|
|
156
|
+
tool_call_id TEXT,
|
|
157
|
+
derived_from TEXT NOT NULL
|
|
158
|
+
CHECK (derived_from IN ('tool_call','filesystem_watch','git_diff')),
|
|
159
|
+
path TEXT NOT NULL,
|
|
160
|
+
old_path TEXT,
|
|
161
|
+
op TEXT NOT NULL
|
|
162
|
+
CHECK (op IN ('create','modify','delete','rename','chmod')),
|
|
163
|
+
before_blob_ref TEXT,
|
|
164
|
+
after_blob_ref TEXT,
|
|
165
|
+
partial_diff BOOLEAN NOT NULL DEFAULT FALSE,
|
|
166
|
+
gitignored BOOLEAN NOT NULL DEFAULT FALSE,
|
|
167
|
+
patch_text TEXT,
|
|
168
|
+
patch_format TEXT
|
|
169
|
+
CHECK (patch_format IN ('unified','binary','notebook_cell')
|
|
170
|
+
OR patch_format IS NULL),
|
|
171
|
+
encoding TEXT,
|
|
172
|
+
bom BOOLEAN NOT NULL DEFAULT FALSE,
|
|
173
|
+
line_endings TEXT,
|
|
174
|
+
mime TEXT,
|
|
175
|
+
language TEXT,
|
|
176
|
+
size_before BIGINT,
|
|
177
|
+
size_after BIGINT,
|
|
178
|
+
line_count_before INTEGER,
|
|
179
|
+
line_count_after INTEGER,
|
|
180
|
+
lines_added INTEGER NOT NULL DEFAULT 0,
|
|
181
|
+
lines_removed INTEGER NOT NULL DEFAULT 0,
|
|
182
|
+
mode_before INTEGER,
|
|
183
|
+
mode_after INTEGER,
|
|
184
|
+
source_tool_name TEXT,
|
|
185
|
+
source_tool_input JSONB,
|
|
186
|
+
redacted BOOLEAN NOT NULL DEFAULT FALSE,
|
|
187
|
+
normalizer_notes JSONB,
|
|
188
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
189
|
+
UNIQUE(step_id, sequence)
|
|
190
|
+
);
|
|
191
|
+
CREATE INDEX IF NOT EXISTS idx_fc_run_step
|
|
192
|
+
ON file_change(run_id, step_id);
|
|
193
|
+
CREATE INDEX IF NOT EXISTS idx_fc_run_path
|
|
194
|
+
ON file_change(run_id, path);
|
|
195
|
+
CREATE INDEX IF NOT EXISTS idx_fc_step
|
|
196
|
+
ON file_change(step_id);
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_fc_path_seq
|
|
198
|
+
ON file_change(run_id, path, step_id);
|
|
199
|
+
|
|
200
|
+
-- \u2500\u2500\u2500 v0.3 Track A: baseline working tree (mirror) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
201
|
+
CREATE TABLE IF NOT EXISTS baseline_tree (
|
|
202
|
+
baseline_tree_id TEXT PRIMARY KEY,
|
|
203
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
204
|
+
manifest_blob_ref TEXT NOT NULL,
|
|
205
|
+
git_head TEXT,
|
|
206
|
+
git_dirty BOOLEAN NOT NULL DEFAULT FALSE,
|
|
207
|
+
captured_at TIMESTAMPTZ NOT NULL
|
|
208
|
+
);
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_bt_project
|
|
210
|
+
ON baseline_tree(project_id);
|
|
211
|
+
CREATE INDEX IF NOT EXISTS idx_bt_git
|
|
212
|
+
ON baseline_tree(project_id, git_head);
|
|
213
|
+
`);
|
|
214
|
+
await client.query(
|
|
215
|
+
"ALTER TABLE steps ADD COLUMN IF NOT EXISTS tokens_cache_creation_1h BIGINT NOT NULL DEFAULT 0"
|
|
216
|
+
);
|
|
217
|
+
await client.query(
|
|
218
|
+
"ALTER TABLE runs ADD COLUMN IF NOT EXISTS baseline_tree_id TEXT"
|
|
219
|
+
);
|
|
220
|
+
await client.query(
|
|
221
|
+
"ALTER TABLE runs ADD COLUMN IF NOT EXISTS probe_state TEXT"
|
|
222
|
+
);
|
|
223
|
+
const versionRow = await client.query(
|
|
224
|
+
"SELECT value FROM meta WHERE key = 'schema_version'"
|
|
225
|
+
);
|
|
226
|
+
if (versionRow.rowCount === 0) {
|
|
227
|
+
await client.query(
|
|
228
|
+
"INSERT INTO meta(key,value) VALUES ($1,$2)",
|
|
229
|
+
["schema_version", String(POSTGRES_SCHEMA_VERSION)]
|
|
230
|
+
);
|
|
231
|
+
} else if (Number(versionRow.rows[0].value) < POSTGRES_SCHEMA_VERSION) {
|
|
232
|
+
await client.query(
|
|
233
|
+
"UPDATE meta SET value = $1 WHERE key = 'schema_version'",
|
|
234
|
+
[String(POSTGRES_SCHEMA_VERSION)]
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/store.ts
|
|
240
|
+
var PostgresStore = class _PostgresStore {
|
|
241
|
+
client;
|
|
242
|
+
constructor(client) {
|
|
243
|
+
this.client = client;
|
|
244
|
+
}
|
|
245
|
+
static async open(opts) {
|
|
246
|
+
const url = opts?.url ?? process.env.METERBILITY_DB_URL ?? process.env.DATABASE_URL;
|
|
247
|
+
if (!url) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
"PostgresStore requires a connection URL. Pass opts.url or set METERBILITY_DB_URL."
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const client = new pg.Client({ connectionString: url });
|
|
253
|
+
await client.connect();
|
|
254
|
+
await ensurePostgresSchema(client);
|
|
255
|
+
return new _PostgresStore(client);
|
|
256
|
+
}
|
|
257
|
+
async close() {
|
|
258
|
+
await this.client.end();
|
|
259
|
+
}
|
|
260
|
+
/** Convenience for blob storage in Postgres mode (bytea-backed). */
|
|
261
|
+
async putBlob(content, ref) {
|
|
262
|
+
const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : content;
|
|
263
|
+
await this.client.query(
|
|
264
|
+
`INSERT INTO blobs(blob_ref, content, created_at)
|
|
265
|
+
VALUES ($1, $2, NOW())
|
|
266
|
+
ON CONFLICT (blob_ref) DO NOTHING`,
|
|
267
|
+
[ref, buf]
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
async getBlob(ref) {
|
|
271
|
+
const r = await this.client.query(
|
|
272
|
+
"SELECT content FROM blobs WHERE blob_ref = $1",
|
|
273
|
+
[ref]
|
|
274
|
+
);
|
|
275
|
+
return r.rows[0]?.content;
|
|
276
|
+
}
|
|
277
|
+
async getBlobString(ref) {
|
|
278
|
+
const buf = await this.getBlob(ref);
|
|
279
|
+
return buf ? buf.toString("utf-8") : void 0;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// src/queries.ts
|
|
284
|
+
function rowToRun(r) {
|
|
285
|
+
return {
|
|
286
|
+
run_id: r.run_id,
|
|
287
|
+
agent_id: r.agent_id,
|
|
288
|
+
project_id: r.project_id,
|
|
289
|
+
source_session_id: r.source_session_id ?? void 0,
|
|
290
|
+
source_runtime: r.source_runtime,
|
|
291
|
+
title: r.title ?? void 0,
|
|
292
|
+
status: r.status,
|
|
293
|
+
started_at: r.started_at.toISOString(),
|
|
294
|
+
ended_at: r.ended_at?.toISOString() ?? void 0,
|
|
295
|
+
git_branch: r.git_branch ?? void 0,
|
|
296
|
+
cwd: r.cwd ?? void 0,
|
|
297
|
+
fork_origin_run_id: r.fork_origin_run_id ?? void 0,
|
|
298
|
+
fork_origin_step_id: r.fork_origin_step_id ?? void 0,
|
|
299
|
+
tokens_total_input: Number(r.tokens_total_input),
|
|
300
|
+
tokens_total_output: Number(r.tokens_total_output),
|
|
301
|
+
tokens_total_cached: Number(r.tokens_total_cached),
|
|
302
|
+
cost_cents: Number(r.cost_cents),
|
|
303
|
+
step_count: r.step_count,
|
|
304
|
+
tags: Array.isArray(r.tags) ? r.tags : []
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function rowToStep(r) {
|
|
308
|
+
return {
|
|
309
|
+
step_id: r.step_id,
|
|
310
|
+
run_id: r.run_id,
|
|
311
|
+
parent_step_id: r.parent_step_id ?? void 0,
|
|
312
|
+
fork_origin_id: r.fork_origin_id ?? void 0,
|
|
313
|
+
sequence: r.sequence,
|
|
314
|
+
timestamp: r.timestamp.toISOString(),
|
|
315
|
+
model: r.model,
|
|
316
|
+
context_snapshot_id: r.context_snapshot_id,
|
|
317
|
+
decision_ref: r.decision_ref,
|
|
318
|
+
action: r.action,
|
|
319
|
+
outcome: r.outcome,
|
|
320
|
+
tokens: {
|
|
321
|
+
input: Number(r.tokens_input),
|
|
322
|
+
output: Number(r.tokens_output),
|
|
323
|
+
cached_read: Number(r.tokens_cached_read),
|
|
324
|
+
cache_creation: Number(r.tokens_cache_creation),
|
|
325
|
+
cache_creation_1h: Number(r.tokens_cache_creation_1h ?? 0),
|
|
326
|
+
reasoning: r.tokens_reasoning != null ? Number(r.tokens_reasoning) : void 0
|
|
327
|
+
},
|
|
328
|
+
latency_ms: r.latency_ms,
|
|
329
|
+
cost_cents: Number(r.cost_cents),
|
|
330
|
+
tags: Array.isArray(r.tags) ? r.tags : [],
|
|
331
|
+
status: r.status
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
async function pgUpsertProject(store, cwd, name) {
|
|
335
|
+
const existing = await store.client.query("SELECT project_id, cwd, name FROM projects WHERE cwd = $1", [cwd]);
|
|
336
|
+
if (existing.rowCount && existing.rowCount > 0) return existing.rows[0];
|
|
337
|
+
const id = `prj_${cryptoRandom()}`;
|
|
338
|
+
const display = name ?? cwd.split("/").pop() ?? cwd;
|
|
339
|
+
await store.client.query(
|
|
340
|
+
"INSERT INTO projects(project_id, name, cwd, created_at) VALUES ($1,$2,$3,NOW())",
|
|
341
|
+
[id, display, cwd]
|
|
342
|
+
);
|
|
343
|
+
return { project_id: id, cwd, name: display };
|
|
344
|
+
}
|
|
345
|
+
async function pgUpsertAgent(store, projectId, name) {
|
|
346
|
+
const existing = await store.client.query(
|
|
347
|
+
"SELECT agent_id, project_id, name FROM agents WHERE project_id = $1 AND name = $2",
|
|
348
|
+
[projectId, name]
|
|
349
|
+
);
|
|
350
|
+
if (existing.rowCount && existing.rowCount > 0) return existing.rows[0];
|
|
351
|
+
const id = `agt_${cryptoRandom()}`;
|
|
352
|
+
await store.client.query(
|
|
353
|
+
"INSERT INTO agents(agent_id, project_id, name, created_at) VALUES ($1,$2,$3,NOW())",
|
|
354
|
+
[id, projectId, name]
|
|
355
|
+
);
|
|
356
|
+
return { agent_id: id, project_id: projectId, name };
|
|
357
|
+
}
|
|
358
|
+
async function pgInsertRun(store, run) {
|
|
359
|
+
await store.client.query(
|
|
360
|
+
`INSERT INTO runs(
|
|
361
|
+
run_id, agent_id, project_id, source_session_id, source_runtime,
|
|
362
|
+
title, status, started_at, ended_at, git_branch, cwd,
|
|
363
|
+
fork_origin_run_id, fork_origin_step_id,
|
|
364
|
+
tokens_total_input, tokens_total_output, tokens_total_cached,
|
|
365
|
+
cost_cents, step_count, tags
|
|
366
|
+
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19::jsonb)
|
|
367
|
+
ON CONFLICT (run_id) DO UPDATE SET
|
|
368
|
+
status = EXCLUDED.status,
|
|
369
|
+
ended_at = EXCLUDED.ended_at,
|
|
370
|
+
tokens_total_input = EXCLUDED.tokens_total_input,
|
|
371
|
+
tokens_total_output = EXCLUDED.tokens_total_output,
|
|
372
|
+
tokens_total_cached = EXCLUDED.tokens_total_cached,
|
|
373
|
+
cost_cents = EXCLUDED.cost_cents,
|
|
374
|
+
step_count = EXCLUDED.step_count,
|
|
375
|
+
tags = EXCLUDED.tags`,
|
|
376
|
+
[
|
|
377
|
+
run.run_id,
|
|
378
|
+
run.agent_id,
|
|
379
|
+
run.project_id,
|
|
380
|
+
run.source_session_id ?? null,
|
|
381
|
+
run.source_runtime,
|
|
382
|
+
run.title ?? null,
|
|
383
|
+
run.status,
|
|
384
|
+
run.started_at,
|
|
385
|
+
run.ended_at ?? null,
|
|
386
|
+
run.git_branch ?? null,
|
|
387
|
+
run.cwd ?? null,
|
|
388
|
+
run.fork_origin_run_id ?? null,
|
|
389
|
+
run.fork_origin_step_id ?? null,
|
|
390
|
+
run.tokens_total_input,
|
|
391
|
+
run.tokens_total_output,
|
|
392
|
+
run.tokens_total_cached,
|
|
393
|
+
run.cost_cents,
|
|
394
|
+
run.step_count,
|
|
395
|
+
JSON.stringify(run.tags ?? [])
|
|
396
|
+
]
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
async function pgInsertStep(store, step) {
|
|
400
|
+
await store.client.query(
|
|
401
|
+
`INSERT INTO steps(
|
|
402
|
+
step_id, run_id, parent_step_id, fork_origin_id, sequence, timestamp,
|
|
403
|
+
model, context_snapshot_id, decision_ref, action, outcome,
|
|
404
|
+
tokens_input, tokens_output, tokens_cached_read, tokens_cache_creation,
|
|
405
|
+
tokens_cache_creation_1h, tokens_reasoning, latency_ms, cost_cents,
|
|
406
|
+
status, tags
|
|
407
|
+
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21::jsonb)
|
|
408
|
+
ON CONFLICT (step_id) DO UPDATE SET
|
|
409
|
+
action = EXCLUDED.action,
|
|
410
|
+
outcome = EXCLUDED.outcome,
|
|
411
|
+
tokens_input = EXCLUDED.tokens_input,
|
|
412
|
+
tokens_output = EXCLUDED.tokens_output,
|
|
413
|
+
tokens_cached_read = EXCLUDED.tokens_cached_read,
|
|
414
|
+
tokens_cache_creation = EXCLUDED.tokens_cache_creation,
|
|
415
|
+
tokens_cache_creation_1h = EXCLUDED.tokens_cache_creation_1h,
|
|
416
|
+
tokens_reasoning = EXCLUDED.tokens_reasoning,
|
|
417
|
+
latency_ms = EXCLUDED.latency_ms,
|
|
418
|
+
cost_cents = EXCLUDED.cost_cents,
|
|
419
|
+
status = EXCLUDED.status,
|
|
420
|
+
tags = EXCLUDED.tags`,
|
|
421
|
+
[
|
|
422
|
+
step.step_id,
|
|
423
|
+
step.run_id,
|
|
424
|
+
step.parent_step_id ?? null,
|
|
425
|
+
step.fork_origin_id ?? null,
|
|
426
|
+
step.sequence,
|
|
427
|
+
step.timestamp,
|
|
428
|
+
step.model,
|
|
429
|
+
step.context_snapshot_id,
|
|
430
|
+
step.decision_ref,
|
|
431
|
+
JSON.stringify(step.action),
|
|
432
|
+
JSON.stringify(step.outcome),
|
|
433
|
+
step.tokens.input,
|
|
434
|
+
step.tokens.output,
|
|
435
|
+
step.tokens.cached_read,
|
|
436
|
+
step.tokens.cache_creation,
|
|
437
|
+
step.tokens.cache_creation_1h ?? 0,
|
|
438
|
+
step.tokens.reasoning ?? null,
|
|
439
|
+
step.latency_ms,
|
|
440
|
+
step.cost_cents,
|
|
441
|
+
step.status,
|
|
442
|
+
JSON.stringify(step.tags ?? [])
|
|
443
|
+
]
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
async function pgListRuns(store, opts = {}) {
|
|
447
|
+
const params = [];
|
|
448
|
+
let where = "1=1";
|
|
449
|
+
if (opts.projectId) {
|
|
450
|
+
params.push(opts.projectId);
|
|
451
|
+
where += ` AND project_id = $${params.length}`;
|
|
452
|
+
}
|
|
453
|
+
params.push(opts.limit ?? 50);
|
|
454
|
+
const r = await store.client.query(
|
|
455
|
+
`SELECT * FROM runs WHERE ${where} ORDER BY started_at DESC LIMIT $${params.length}`,
|
|
456
|
+
params
|
|
457
|
+
);
|
|
458
|
+
return r.rows.map(rowToRun);
|
|
459
|
+
}
|
|
460
|
+
async function pgGetRun(store, runId) {
|
|
461
|
+
const r = await store.client.query(
|
|
462
|
+
"SELECT * FROM runs WHERE run_id = $1 OR run_id LIKE $2 LIMIT 2",
|
|
463
|
+
[runId, `${runId}%`]
|
|
464
|
+
);
|
|
465
|
+
return r.rowCount === 1 ? rowToRun(r.rows[0]) : void 0;
|
|
466
|
+
}
|
|
467
|
+
async function pgListSteps(store, runId) {
|
|
468
|
+
const r = await store.client.query(
|
|
469
|
+
"SELECT * FROM steps WHERE run_id = $1 ORDER BY sequence ASC",
|
|
470
|
+
[runId]
|
|
471
|
+
);
|
|
472
|
+
return r.rows.map(rowToStep);
|
|
473
|
+
}
|
|
474
|
+
function cryptoRandom() {
|
|
475
|
+
const arr = new Uint8Array(8);
|
|
476
|
+
for (let i = 0; i < 8; i++) arr[i] = Math.floor(Math.random() * 256);
|
|
477
|
+
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/sync.ts
|
|
481
|
+
import { listRuns, listSteps, resolveSnapshotBlobRef } from "@meterbility/collector";
|
|
482
|
+
async function syncSqliteToPostgres(sqlite, postgres, opts = {}) {
|
|
483
|
+
const runs = listRuns(sqlite, { limit: opts.limitRuns ?? 1e3 });
|
|
484
|
+
const report = { runs: 0, steps: 0, blobs: 0, bytes: 0 };
|
|
485
|
+
for (const run of runs) {
|
|
486
|
+
const proj = await pgUpsertProject(
|
|
487
|
+
postgres,
|
|
488
|
+
run.cwd ?? "(unknown)",
|
|
489
|
+
run.cwd ?? void 0
|
|
490
|
+
);
|
|
491
|
+
const agent = await pgUpsertAgent(postgres, proj.project_id, "synced");
|
|
492
|
+
const remappedRun = {
|
|
493
|
+
...run,
|
|
494
|
+
project_id: proj.project_id,
|
|
495
|
+
agent_id: agent.agent_id
|
|
496
|
+
};
|
|
497
|
+
await pgInsertRun(postgres, remappedRun);
|
|
498
|
+
report.runs += 1;
|
|
499
|
+
const steps = listSteps(sqlite, run.run_id);
|
|
500
|
+
for (const step of steps) {
|
|
501
|
+
await pgInsertStep(postgres, { ...step, run_id: remappedRun.run_id });
|
|
502
|
+
report.steps += 1;
|
|
503
|
+
const refs = /* @__PURE__ */ new Set();
|
|
504
|
+
refs.add(resolveSnapshotBlobRef(sqlite, step.context_snapshot_id));
|
|
505
|
+
refs.add(step.decision_ref);
|
|
506
|
+
if (step.outcome.tool_result_ref) refs.add(step.outcome.tool_result_ref);
|
|
507
|
+
for (const r of refs) {
|
|
508
|
+
const buf = await sqlite.blobs.getBuffer(r).catch(() => void 0);
|
|
509
|
+
if (!buf) continue;
|
|
510
|
+
await postgres.putBlob(buf, r);
|
|
511
|
+
report.blobs += 1;
|
|
512
|
+
report.bytes += buf.length;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return report;
|
|
517
|
+
}
|
|
518
|
+
export {
|
|
519
|
+
POSTGRES_SCHEMA_VERSION,
|
|
520
|
+
PostgresStore,
|
|
521
|
+
ensurePostgresSchema,
|
|
522
|
+
pgGetRun,
|
|
523
|
+
pgInsertRun,
|
|
524
|
+
pgInsertStep,
|
|
525
|
+
pgListRuns,
|
|
526
|
+
pgListSteps,
|
|
527
|
+
pgUpsertAgent,
|
|
528
|
+
pgUpsertProject,
|
|
529
|
+
syncSqliteToPostgres
|
|
530
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meterbility/store-postgres",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@meterbility/shared": "^0.3.0",
|
|
15
|
+
"@meterbility/spec": "^0.3.0",
|
|
16
|
+
"pg": "^8.13.0",
|
|
17
|
+
"@meterbility/collector": "^0.3.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/pg": "^8.11.0"
|
|
21
|
+
},
|
|
22
|
+
"description": "Postgres backend for Meterbility (single-operator multi-machine sync)",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/HoneycombHairDevelopers/Meterbility.git",
|
|
26
|
+
"directory": "packages/store-postgres"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/HoneycombHairDevelopers/Meterbility#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/HoneycombHairDevelopers/Meterbility/issues"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20.6"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist"
|
|
40
|
+
],
|
|
41
|
+
"types": "dist/index.d.ts",
|
|
42
|
+
"tsup": {
|
|
43
|
+
"entry": [
|
|
44
|
+
"src/index.ts"
|
|
45
|
+
],
|
|
46
|
+
"format": [
|
|
47
|
+
"esm"
|
|
48
|
+
],
|
|
49
|
+
"dts": true,
|
|
50
|
+
"clean": true
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsup"
|
|
54
|
+
}
|
|
55
|
+
}
|