@obsunified/collector 2.0.1 → 2.1.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/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/index.d.ts +322 -26
- package/dist/index.js +1478 -415
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/package.json +5 -5
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +16 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +60 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/housekeeping.test.ts +73 -0
- package/src/lib/housekeeping.ts +93 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +64 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +165 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +14 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/eval-cases-routes.ts +8 -17
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
|
@@ -53,9 +53,9 @@ export async function manifestBySession(
|
|
|
53
53
|
.prepare(
|
|
54
54
|
`SELECT first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
55
55
|
FROM session_replay_metadata
|
|
56
|
-
WHERE session_id = ?`,
|
|
56
|
+
WHERE project_id = ? AND session_id = ?`,
|
|
57
57
|
)
|
|
58
|
-
.bind(sessionId)
|
|
58
|
+
.bind(projectId, sessionId)
|
|
59
59
|
.first<{
|
|
60
60
|
first_chunk_at: string;
|
|
61
61
|
last_chunk_at: string;
|
|
@@ -13,10 +13,13 @@ export async function manifestByUser(
|
|
|
13
13
|
const sessionLimit = Math.max(1, Math.min(opts.sessions ?? 5, 20));
|
|
14
14
|
|
|
15
15
|
// Step 1 — resolve user_id → visitor_id. user_profiles is the only
|
|
16
|
-
// table that owns the user_id ↔ visitor_id binding
|
|
16
|
+
// table that owns the user_id ↔ visitor_id binding, keyed per project
|
|
17
|
+
// (the same user_id can exist in several projects — migration 045).
|
|
17
18
|
const profile = await db
|
|
18
|
-
.prepare(
|
|
19
|
-
|
|
19
|
+
.prepare(
|
|
20
|
+
`SELECT visitor_id FROM user_profiles WHERE project_id = ? AND user_id = ? LIMIT 1`,
|
|
21
|
+
)
|
|
22
|
+
.bind(projectId, userId)
|
|
20
23
|
.first<{ visitor_id: string }>();
|
|
21
24
|
if (!profile) {
|
|
22
25
|
return {
|
|
@@ -105,10 +108,10 @@ export async function manifestByUser(
|
|
|
105
108
|
.prepare(
|
|
106
109
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
107
110
|
FROM session_replay_metadata
|
|
108
|
-
WHERE session_id IN (${placeholders})
|
|
111
|
+
WHERE project_id = ? AND session_id IN (${placeholders})
|
|
109
112
|
ORDER BY last_chunk_at DESC LIMIT 1`,
|
|
110
113
|
)
|
|
111
|
-
.bind(...sessionIds)
|
|
114
|
+
.bind(projectId, ...sessionIds)
|
|
112
115
|
.first<{
|
|
113
116
|
session_id: string;
|
|
114
117
|
first_chunk_at: string;
|
|
@@ -81,13 +81,12 @@ describe("IdentityIndex.bySession", () => {
|
|
|
81
81
|
eventsCount: 42,
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
// All five queries pin (project_id, session_id)
|
|
85
|
-
// just session_id since replay is global.
|
|
84
|
+
// All five queries pin (project_id, session_id).
|
|
86
85
|
const allCalls = db.calls.filter((c) => c.op !== "first" || c.binds.length);
|
|
87
86
|
const sessionQueries = allCalls.filter(
|
|
88
87
|
(c) => c.sql.includes("session_id") && c.binds.includes("proj-1"),
|
|
89
88
|
);
|
|
90
|
-
expect(sessionQueries.length).
|
|
89
|
+
expect(sessionQueries.length).toBe(5);
|
|
91
90
|
});
|
|
92
91
|
|
|
93
92
|
it("returns null replay when session_replay_metadata is empty", async () => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two ingest batches that both see a brand-new metric identity used to race
|
|
3
|
+
* on `UNIQUE(project_id, identity)`: the loser's whole batch failed and its
|
|
4
|
+
* points were lost. The insert is now conflict-tolerant and the loser adopts
|
|
5
|
+
* the winner's series id. Executed against the real migrated schema.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import type { DecodedMetricPoint } from "../otlp/decode";
|
|
10
|
+
import { MetricsStore } from "./metrics-store";
|
|
11
|
+
import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
|
|
12
|
+
|
|
13
|
+
const point = (over: Partial<DecodedMetricPoint> = {}): DecodedMetricPoint => ({
|
|
14
|
+
name: "http.server.duration",
|
|
15
|
+
description: null,
|
|
16
|
+
unit: "ms",
|
|
17
|
+
type: "gauge",
|
|
18
|
+
isMonotonic: null,
|
|
19
|
+
temporality: null,
|
|
20
|
+
scopeName: "test",
|
|
21
|
+
scopeVersion: "1",
|
|
22
|
+
serviceName: "checkout",
|
|
23
|
+
resourceAttrsJson: "{}",
|
|
24
|
+
attributesJson: "{}",
|
|
25
|
+
identity: "identity-shared",
|
|
26
|
+
tsNs: "1800000000000000000",
|
|
27
|
+
startTsNs: null,
|
|
28
|
+
value: 1,
|
|
29
|
+
count: null,
|
|
30
|
+
sum: null,
|
|
31
|
+
min: null,
|
|
32
|
+
max: null,
|
|
33
|
+
boundsJson: null,
|
|
34
|
+
bucketCountsJson: null,
|
|
35
|
+
extraJson: null,
|
|
36
|
+
exemplarsJson: null,
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("MetricsStore series resolution under concurrency", () => {
|
|
41
|
+
it("lets concurrent first-sight ingests share one series row", async () => {
|
|
42
|
+
const db = createMigratedSqliteDb();
|
|
43
|
+
const store = new MetricsStore(db);
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
const later = new Date(Date.now() + 3_600_000).toISOString();
|
|
46
|
+
|
|
47
|
+
// Both calls run their lookup (miss) before either inserts.
|
|
48
|
+
await Promise.all([
|
|
49
|
+
store.ingestBatch({
|
|
50
|
+
projectId: "alpha",
|
|
51
|
+
points: [point({ value: 1 })],
|
|
52
|
+
receivedAt: now,
|
|
53
|
+
expiresAt: later,
|
|
54
|
+
}),
|
|
55
|
+
store.ingestBatch({
|
|
56
|
+
projectId: "alpha",
|
|
57
|
+
points: [point({ value: 2 })],
|
|
58
|
+
receivedAt: now,
|
|
59
|
+
expiresAt: later,
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const series = await db
|
|
64
|
+
.prepare(`SELECT id FROM metric_series WHERE project_id = ?`)
|
|
65
|
+
.bind("alpha")
|
|
66
|
+
.all<{ id: string }>();
|
|
67
|
+
expect(series.results).toHaveLength(1);
|
|
68
|
+
const points = await db
|
|
69
|
+
.prepare(
|
|
70
|
+
`SELECT series_id, value FROM metric_point WHERE project_id = ? ORDER BY value`,
|
|
71
|
+
)
|
|
72
|
+
.bind("alpha")
|
|
73
|
+
.all<{ series_id: string; value: number }>();
|
|
74
|
+
expect(points.results.map((p) => p.value)).toEqual([1, 2]);
|
|
75
|
+
expect(new Set(points.results.map((p) => p.series_id))).toEqual(
|
|
76
|
+
new Set([series.results[0]?.id]),
|
|
77
|
+
);
|
|
78
|
+
db.close();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps series separate per project", async () => {
|
|
82
|
+
const db = createMigratedSqliteDb();
|
|
83
|
+
const store = new MetricsStore(db);
|
|
84
|
+
const now = new Date().toISOString();
|
|
85
|
+
for (const projectId of ["alpha", "beta"]) {
|
|
86
|
+
await store.ingestBatch({
|
|
87
|
+
projectId,
|
|
88
|
+
points: [point()],
|
|
89
|
+
receivedAt: now,
|
|
90
|
+
expiresAt: now,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const rows = await db
|
|
94
|
+
.prepare(`SELECT project_id FROM metric_series ORDER BY project_id`)
|
|
95
|
+
.all<{ project_id: string }>();
|
|
96
|
+
expect(rows.results.map((r) => r.project_id)).toEqual(["alpha", "beta"]);
|
|
97
|
+
db.close();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { DecodedMetricPoint } from "../otlp/decode";
|
|
3
3
|
import { MetricsStore } from "./metrics-store";
|
|
4
|
+
import { postgresDialect } from "./sql-db";
|
|
4
5
|
import { MemSqlDb } from "./test-utils/mem-sql-db";
|
|
5
6
|
|
|
6
7
|
const point = (
|
|
@@ -33,6 +34,28 @@ const point = (
|
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
describe("MetricsStore", () => {
|
|
37
|
+
it("purges expired exemplars and points with the dialect's text-rendered now()", async () => {
|
|
38
|
+
const sqlite = new MemSqlDb({ run: () => ({ changes: 2 }) });
|
|
39
|
+
expect(await new MetricsStore(sqlite).purgeExpired()).toBe(4);
|
|
40
|
+
expect(sqlite.calls.map((c) => c.sql)).toEqual([
|
|
41
|
+
"DELETE FROM metric_exemplars WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
|
42
|
+
"DELETE FROM metric_point WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// metric_exemplars.received_at / expires_at are TEXT ISO on Postgres
|
|
46
|
+
// too (035 for fresh installs, 043 for databases created with the
|
|
47
|
+
// TIMESTAMPTZ version), so the comparison must be text < text.
|
|
48
|
+
const postgres = Object.assign(
|
|
49
|
+
new MemSqlDb({ run: () => ({ changes: 1 }) }),
|
|
50
|
+
{ dialect: postgresDialect },
|
|
51
|
+
);
|
|
52
|
+
expect(await new MetricsStore(postgres).purgeExpired()).toBe(2);
|
|
53
|
+
expect(postgres.calls.map((c) => c.sql)).toEqual([
|
|
54
|
+
`DELETE FROM metric_exemplars WHERE expires_at < to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
|
|
55
|
+
`DELETE FROM metric_point WHERE expires_at < to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
36
59
|
it("indexes metric exemplars by trace/span during ingest", async () => {
|
|
37
60
|
const db = new MemSqlDb({
|
|
38
61
|
all: (sql, binds) => {
|
package/src/lib/metrics-store.ts
CHANGED
|
@@ -272,20 +272,25 @@ export class MetricsStore {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
// Insert missing series.
|
|
275
|
+
// Insert missing series. Two concurrent batches can both miss the
|
|
276
|
+
// lookup for a brand-new identity; UNIQUE(project_id, identity) would
|
|
277
|
+
// then fail the loser's whole batch. INSERT OR IGNORE lets the loser
|
|
278
|
+
// proceed, and the re-select below swaps in the winner's series id.
|
|
276
279
|
const insertStmt = this.db.prepare(`
|
|
277
|
-
INSERT INTO metric_series (
|
|
280
|
+
INSERT OR IGNORE INTO metric_series (
|
|
278
281
|
id, project_id, name, description, unit, type,
|
|
279
282
|
is_monotonic, temporality, scope_name, scope_version, service_name,
|
|
280
283
|
resource_attrs_json, attributes_json, identity, created_at
|
|
281
284
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
282
285
|
`);
|
|
283
286
|
const toInsert: SqlStatement[] = [];
|
|
287
|
+
const attemptedIds = new Set<string>();
|
|
284
288
|
const createdAt = new Date().toISOString();
|
|
285
289
|
for (const [identity, p] of byIdentity.entries()) {
|
|
286
290
|
if (existing.has(identity)) continue;
|
|
287
291
|
const id = crypto.randomUUID();
|
|
288
292
|
existing.set(identity, { id, identity });
|
|
293
|
+
attemptedIds.add(identity);
|
|
289
294
|
toInsert.push(
|
|
290
295
|
insertStmt.bind(
|
|
291
296
|
id,
|
|
@@ -306,7 +311,26 @@ export class MetricsStore {
|
|
|
306
311
|
),
|
|
307
312
|
);
|
|
308
313
|
}
|
|
309
|
-
if (toInsert.length)
|
|
314
|
+
if (toInsert.length === 0) return existing;
|
|
315
|
+
await this.db.batch(toInsert);
|
|
316
|
+
|
|
317
|
+
// Re-read the identities we tried to insert: an ignored insert means
|
|
318
|
+
// another writer won, and points must reference the stored id.
|
|
319
|
+
const attempted = identities.filter((identity) =>
|
|
320
|
+
attemptedIds.has(identity),
|
|
321
|
+
);
|
|
322
|
+
for (let i = 0; i < attempted.length; i += 50) {
|
|
323
|
+
const chunk = attempted.slice(i, i + 50);
|
|
324
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
325
|
+
const rows = await this.db
|
|
326
|
+
.prepare(
|
|
327
|
+
`SELECT id, identity FROM metric_series
|
|
328
|
+
WHERE project_id = ? AND identity IN (${placeholders})`,
|
|
329
|
+
)
|
|
330
|
+
.bind(projectId, ...chunk)
|
|
331
|
+
.all<SeriesRow>();
|
|
332
|
+
for (const row of rows.results) existing.set(row.identity, row);
|
|
333
|
+
}
|
|
310
334
|
return existing;
|
|
311
335
|
}
|
|
312
336
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sha256Hex } from "./hash";
|
|
3
|
+
import { ProjectsStore } from "./projects-store";
|
|
4
|
+
import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
|
|
5
|
+
|
|
6
|
+
describe("ProjectsStore.revokeKey", () => {
|
|
7
|
+
it("only revokes a key through its own project", async () => {
|
|
8
|
+
const db = createMigratedSqliteDb();
|
|
9
|
+
const store = new ProjectsStore(db);
|
|
10
|
+
const alpha = await store.createProject({ name: "Alpha", slug: "alpha" });
|
|
11
|
+
const beta = await store.createProject({ name: "Beta", slug: "beta" });
|
|
12
|
+
const betaKey = await store.createKey(beta.id, "ci");
|
|
13
|
+
const hash = await sha256Hex(betaKey.key);
|
|
14
|
+
|
|
15
|
+
// Alpha's admin cannot revoke beta's key by guessing its id.
|
|
16
|
+
expect(await store.revokeKey(betaKey.id, alpha.id)).toBe(false);
|
|
17
|
+
expect((await store.findByKeyHash(hash))?.projectId).toBe(beta.id);
|
|
18
|
+
|
|
19
|
+
expect(await store.revokeKey(betaKey.id, beta.id)).toBe(true);
|
|
20
|
+
expect(await store.findByKeyHash(hash)).toBeNull();
|
|
21
|
+
// Already revoked: a second revoke reports nothing changed.
|
|
22
|
+
expect(await store.revokeKey(betaKey.id, beta.id)).toBe(false);
|
|
23
|
+
db.close();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -158,12 +158,13 @@ export class ProjectsStore {
|
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
async revokeKey(keyId: string): Promise<boolean> {
|
|
161
|
+
async revokeKey(keyId: string, projectId: string): Promise<boolean> {
|
|
162
162
|
const result = await this.db
|
|
163
163
|
.prepare(
|
|
164
|
-
`UPDATE ingest_keys SET revoked_at = ?
|
|
164
|
+
`UPDATE ingest_keys SET revoked_at = ?
|
|
165
|
+
WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
|
|
165
166
|
)
|
|
166
|
-
.bind(new Date().toISOString(), keyId)
|
|
167
|
+
.bind(new Date().toISOString(), keyId, projectId)
|
|
167
168
|
.run();
|
|
168
169
|
return (result.meta?.changes ?? 0) > 0;
|
|
169
170
|
}
|
|
@@ -4,16 +4,25 @@ import { postgresDialect, sqliteDialect } from "./sql-db";
|
|
|
4
4
|
describe("SqlDialect", () => {
|
|
5
5
|
it("renders native SQLite expressions", () => {
|
|
6
6
|
expect(sqliteDialect.sinceHours("?")).toBe(
|
|
7
|
-
"
|
|
7
|
+
"strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' hours')",
|
|
8
8
|
);
|
|
9
9
|
expect(
|
|
10
10
|
sqliteDialect.jsonText("attributes_json", '$."llm.cost.total_usd"'),
|
|
11
11
|
).toBe(`json_extract(attributes_json, '$."llm.cost.total_usd"')`);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
it("renders
|
|
14
|
+
it("renders Postgres time windows as ISO text (timestamp columns are TEXT)", () => {
|
|
15
|
+
// `received_at` / `expires_at` are TEXT on Postgres too, and Postgres
|
|
16
|
+
// refuses `text >= timestamptz`, so windows must come back as text in
|
|
17
|
+
// the same `YYYY-MM-DDTHH:MM:SS.mmmZ` shape `Date#toISOString` writes.
|
|
15
18
|
expect(postgresDialect.sinceHours("$1")).toBe(
|
|
16
|
-
|
|
19
|
+
`to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - ($1::text || ' hours')::interval, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
|
|
20
|
+
);
|
|
21
|
+
expect(postgresDialect.sinceMinutes("$2")).toBe(
|
|
22
|
+
`to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - ($2::text || ' minutes')::interval, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
|
|
23
|
+
);
|
|
24
|
+
expect(postgresDialect.now()).toBe(
|
|
25
|
+
`to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
|
|
17
26
|
);
|
|
18
27
|
expect(
|
|
19
28
|
postgresDialect.jsonText("attributes_json", '$."llm.cost.total_usd"'),
|