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/observe.js CHANGED
@@ -1,10 +1,16 @@
1
1
  /**
2
- * turbine-orm Observability module
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
- * to a dedicated _turbine_metrics table. Uses a separate 1-connection pool
7
- * so metrics writes never contend with the application pool.
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
- pool;
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
- this.pool = new pg.Pool({ connectionString: config.connectionString, max: 1 });
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.pool.query(SCHEMA_DDL);
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
- const p50 = percentile(sorted, 0.5);
113
- const p95 = percentile(sorted, 0.95);
114
- const p99 = percentile(sorted, 0.99);
115
- try {
116
- await this.pool.query(UPSERT_SQL, [bucket, model, action, count, avg, p50, p95, p99, entry.errors]);
117
- }
118
- catch {
119
- // Fire-and-forget — never throw from flush
120
- }
121
- }
122
- try {
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.pool.end();
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.46.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": {