turbine-orm 0.46.0 → 0.47.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.
- package/dist/cjs/cli/index.js +162 -10
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +219 -3
- package/dist/cjs/index.js +6 -2
- package/dist/cjs/observe.js +113 -27
- package/dist/cli/index.d.ts +9 -1
- package/dist/cli/index.js +164 -12
- package/dist/index-advisor.d.ts +19 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +127 -2
- package/dist/index-stats.js +215 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -0
- package/dist/observe.d.ts +88 -7
- package/dist/observe.js +110 -26
- package/package.json +1 -1
package/dist/observe.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* turbine-orm
|
|
2
|
+
* turbine-orm: Observability module
|
|
3
3
|
*
|
|
4
4
|
* Buffers query metrics in memory (keyed by model:action per minute bucket),
|
|
5
|
-
* then periodically flushes aggregates (count, avg, p50, p95, p99, errors)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* then periodically flushes aggregates (count, avg, p50, p95, p99, errors) to a
|
|
6
|
+
* pluggable {@link ObserveSink}. The default sink writes to a dedicated
|
|
7
|
+
* `_turbine_metrics` Postgres table over its own 1-connection pool, so metrics
|
|
8
|
+
* writes never contend with the application pool; alternative sinks (for example
|
|
9
|
+
* {@link HttpJsonSink}) can forward the same aggregates elsewhere.
|
|
10
|
+
*
|
|
11
|
+
* The aggregation privacy posture is deliberate: a batch carries only the
|
|
12
|
+
* model/action identity, the counts, and the latency percentiles. It never
|
|
13
|
+
* carries SQL text or bound parameter values.
|
|
8
14
|
*/
|
|
9
15
|
import pg from 'pg';
|
|
10
16
|
function floorToMinute(date) {
|
|
@@ -19,7 +25,7 @@ function percentile(sorted, p) {
|
|
|
19
25
|
return sorted[Math.max(0, idx)];
|
|
20
26
|
}
|
|
21
27
|
// ---------------------------------------------------------------------------
|
|
22
|
-
// Schema DDL
|
|
28
|
+
// Schema DDL + statements (default Postgres sink)
|
|
23
29
|
// ---------------------------------------------------------------------------
|
|
24
30
|
const SCHEMA_DDL = `
|
|
25
31
|
CREATE TABLE IF NOT EXISTS _turbine_metrics (
|
|
@@ -50,22 +56,102 @@ ON CONFLICT (bucket, model, action) DO UPDATE SET
|
|
|
50
56
|
error_count = _turbine_metrics.error_count + EXCLUDED.error_count
|
|
51
57
|
`;
|
|
52
58
|
const RETENTION_SQL = `DELETE FROM _turbine_metrics WHERE bucket < NOW() - INTERVAL '1 day' * $1`;
|
|
59
|
+
/**
|
|
60
|
+
* The default flush target: upserts each aggregate row into `_turbine_metrics`
|
|
61
|
+
* and prunes rows older than `retentionDays`. The SQL and per-row/retention
|
|
62
|
+
* ordering are byte-identical to the pre-sink `ObserveEngine.flush` writer.
|
|
63
|
+
*/
|
|
64
|
+
export class PgMetricsSink {
|
|
65
|
+
pool;
|
|
66
|
+
retentionDays;
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.pool = new pg.Pool({ connectionString: options.connectionString, max: 1 });
|
|
69
|
+
this.retentionDays = options.retentionDays ?? 30;
|
|
70
|
+
}
|
|
71
|
+
async init() {
|
|
72
|
+
await this.pool.query(SCHEMA_DDL);
|
|
73
|
+
}
|
|
74
|
+
async flush(batch) {
|
|
75
|
+
if (batch.rows.length === 0)
|
|
76
|
+
return;
|
|
77
|
+
for (const row of batch.rows) {
|
|
78
|
+
try {
|
|
79
|
+
await this.pool.query(UPSERT_SQL, [
|
|
80
|
+
row.bucket,
|
|
81
|
+
row.model,
|
|
82
|
+
row.action,
|
|
83
|
+
row.count,
|
|
84
|
+
row.avg,
|
|
85
|
+
row.p50,
|
|
86
|
+
row.p95,
|
|
87
|
+
row.p99,
|
|
88
|
+
row.errors,
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Fire-and-forget: never throw from flush
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
await this.pool.query(RETENTION_SQL, [this.retentionDays]);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Best effort
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async stop() {
|
|
103
|
+
await this.pool.end();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Forwards each aggregate batch to an HTTP endpoint as a JSON POST. Fire-and-
|
|
108
|
+
* forget: a failed request is swallowed and never throws, and there are no
|
|
109
|
+
* retries beyond the engine's next scheduled flush. Aggregates only: the body
|
|
110
|
+
* carries no SQL text and no parameter values.
|
|
111
|
+
*/
|
|
112
|
+
export class HttpJsonSink {
|
|
113
|
+
url;
|
|
114
|
+
headers;
|
|
115
|
+
fetchFn;
|
|
116
|
+
constructor(options) {
|
|
117
|
+
this.url = options.url;
|
|
118
|
+
this.headers = options.headers ?? {};
|
|
119
|
+
this.fetchFn = options.fetchFunction ?? fetch;
|
|
120
|
+
}
|
|
121
|
+
async flush(batch) {
|
|
122
|
+
if (batch.rows.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
try {
|
|
125
|
+
await this.fetchFn(this.url, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'content-type': 'application/json', ...this.headers },
|
|
128
|
+
body: JSON.stringify(batch),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Fire-and-forget: a failing collector never affects the application.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
53
136
|
// ---------------------------------------------------------------------------
|
|
54
137
|
// Observe engine
|
|
55
138
|
// ---------------------------------------------------------------------------
|
|
56
139
|
export class ObserveEngine {
|
|
57
|
-
|
|
140
|
+
sink;
|
|
58
141
|
buffer = new Map();
|
|
59
142
|
currentBucket;
|
|
60
143
|
flushIntervalMs;
|
|
61
|
-
retentionDays;
|
|
62
144
|
timer;
|
|
63
145
|
listener;
|
|
64
146
|
stopped = false;
|
|
65
147
|
constructor(config) {
|
|
66
|
-
|
|
148
|
+
if (!config.sink && !config.connectionString) {
|
|
149
|
+
throw new Error('ObserveEngine requires either a connectionString or a sink');
|
|
150
|
+
}
|
|
151
|
+
this.sink =
|
|
152
|
+
config.sink ??
|
|
153
|
+
new PgMetricsSink({ connectionString: config.connectionString, retentionDays: config.retentionDays ?? 30 });
|
|
67
154
|
this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
|
|
68
|
-
this.retentionDays = config.retentionDays ?? 30;
|
|
69
155
|
this.currentBucket = floorToMinute(new Date());
|
|
70
156
|
this.listener = (event) => {
|
|
71
157
|
if (this.stopped)
|
|
@@ -89,7 +175,7 @@ export class ObserveEngine {
|
|
|
89
175
|
return this.listener;
|
|
90
176
|
}
|
|
91
177
|
async init() {
|
|
92
|
-
await this.
|
|
178
|
+
await this.sink.init?.();
|
|
93
179
|
this.timer = setInterval(() => {
|
|
94
180
|
this.flush().catch(() => { });
|
|
95
181
|
}, this.flushIntervalMs);
|
|
@@ -104,34 +190,32 @@ export class ObserveEngine {
|
|
|
104
190
|
const bucket = this.currentBucket;
|
|
105
191
|
const entries = new Map(this.buffer);
|
|
106
192
|
this.buffer.clear();
|
|
193
|
+
const rows = [];
|
|
107
194
|
for (const [key, entry] of entries) {
|
|
108
195
|
const [model, action] = key.split(':');
|
|
109
196
|
const sorted = entry.durations.slice().sort((a, b) => a - b);
|
|
110
197
|
const count = sorted.length;
|
|
111
198
|
const avg = sorted.reduce((s, v) => s + v, 0) / count;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
await this.pool.query(RETENTION_SQL, [this.retentionDays]);
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
// Best effort
|
|
199
|
+
rows.push({
|
|
200
|
+
bucket,
|
|
201
|
+
model: model ?? '',
|
|
202
|
+
action: action ?? '',
|
|
203
|
+
count,
|
|
204
|
+
avg,
|
|
205
|
+
p50: percentile(sorted, 0.5),
|
|
206
|
+
p95: percentile(sorted, 0.95),
|
|
207
|
+
p99: percentile(sorted, 0.99),
|
|
208
|
+
errors: entry.errors,
|
|
209
|
+
});
|
|
127
210
|
}
|
|
211
|
+
await this.sink.flush({ rows });
|
|
128
212
|
}
|
|
129
213
|
async stop() {
|
|
130
214
|
this.stopped = true;
|
|
131
215
|
if (this.timer)
|
|
132
216
|
clearInterval(this.timer);
|
|
133
217
|
await this.flush();
|
|
134
|
-
await this.
|
|
218
|
+
await this.sink.stop?.();
|
|
135
219
|
}
|
|
136
220
|
}
|
|
137
221
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|