@aryasaatvik/motel 0.5.0 → 0.5.2

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.
@@ -407,6 +407,194 @@ describe("motel telemetry store", () => {
407
407
  }
408
408
  })
409
409
 
410
+
411
+ it("keeps operation search and mappings atomic across updates, legacy repair and ingest rollback", async () => {
412
+ const isolatedPath = join(tempDir, "operation-savepoint.sqlite")
413
+ const script = `
414
+ const assert = (await import('node:assert/strict')).default
415
+ const { Database } = await import('bun:sqlite')
416
+ const { Effect, ManagedRuntime } = await import('effect')
417
+ const { TelemetryStore, makeTelemetryStoreLayer } = await import('./src/services/TelemetryStore.ts')
418
+ const { repairSearchRows, installRetentionSchema, SPAN_SEARCH_MAPPING_READY_KEY } = await import('./src/services/retention.ts')
419
+ const runtime = ManagedRuntime.make(makeTelemetryStoreLayer({ readonly: false, runRetention: false }))
420
+ let db
421
+ const now = BigInt(Date.now()) * 1000000n
422
+ const span = (traceId, spanId, name) => ({ traceId, spanId, name, startTimeUnixNano: String(now), endTimeUnixNano: String(now + 1000000n) })
423
+ const ingest = (spans) => runtime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({ resourceSpans: [{ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'fts-regression' } }] }, scopeSpans: [{ spans }] }] })))
424
+ const names = (traceId) => db.query('SELECT operation_name FROM span_operation_fts WHERE trace_id = ? ORDER BY operation_name').all(traceId).map((row) => row.operation_name)
425
+ const ready = () => db.query('SELECT value FROM motel_maintenance WHERE key = ?').get(SPAN_SEARCH_MAPPING_READY_KEY)?.value
426
+ const mappingCounts = () => db.query('SELECT (SELECT count(*) FROM span_operation_fts) AS fts, (SELECT count(*) FROM span_search_rows) AS mapping, (SELECT count(*) FROM span_operation_fts f LEFT JOIN span_search_rows m ON m.id = f.rowid WHERE m.id IS NULL) AS unmapped').get()
427
+ try {
428
+ await ingest([span('normal', 'root', 'oldoperation')])
429
+ db = new Database(process.env.MOTEL_OTEL_DB_PATH)
430
+ assert.equal(ready(), 'complete')
431
+ await ingest([span('normal', 'root', 'newoperation')])
432
+ assert.deepEqual(names('normal'), ['newoperation'])
433
+ assert.equal((await runtime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.searchSpans({ operation: 'oldoperation' })))).length, 0)
434
+ assert.equal((await runtime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.searchSpans({ operation: 'newoperation' })))).length, 1)
435
+
436
+ await ingest(Array.from({ length: 32 }, (_, i) => span('mixed-' + i, 'root', 'mixedold')))
437
+ await ingest(Array.from({ length: 64 }, (_, i) => span('mixed-' + i, 'root', 'mixednew')))
438
+ assert.equal(db.query("SELECT count(*) AS n FROM span_operation_fts WHERE operation_name = 'mixedold'").get().n, 0)
439
+ assert.equal(db.query("SELECT count(*) AS n FROM span_operation_fts WHERE operation_name = 'mixednew'").get().n, 64)
440
+
441
+ await ingest([span('duplicate', 'root', 'first'), span('duplicate', 'root', 'second')])
442
+ assert.deepEqual(names('duplicate'), ['first', 'second'])
443
+ const acrossBoundary = [span('duplicate', 'root', 'early')]
444
+ for (let i = 1; i < 499; i++) acrossBoundary.push(span('filler-' + i, 'root', 'filler'))
445
+ acrossBoundary.push(span('duplicate', 'root', 'middle'), span('duplicate', 'root', 'lateone'), span('duplicate', 'root', 'latetwo'))
446
+ await ingest(acrossBoundary)
447
+ assert.deepEqual(names('duplicate'), ['lateone', 'latetwo'])
448
+ assert.equal(db.query("SELECT operation_name FROM spans WHERE trace_id = 'duplicate' AND span_id = 'root'").get().operation_name, 'latetwo')
449
+
450
+ // Simulate an upgraded database with unmapped historical FTS rows.
451
+ db.query("DELETE FROM motel_maintenance WHERE key IN (?, 'retention_repair_span_operation_fts')").run(SPAN_SEARCH_MAPPING_READY_KEY)
452
+ db.exec("DELETE FROM span_search_rows WHERE trace_id = 'normal'")
453
+ db.query("INSERT OR REPLACE INTO motel_maintenance VALUES ('retention_repair_span_operation_fts', '999999')").run()
454
+ installRetentionSchema(db)
455
+ await ingest([span('normal', 'root', 'legacyupdated')])
456
+ assert.deepEqual(names('normal'), ['legacyupdated'])
457
+ assert.match(ready(), /^repairing:[1-9][0-9]*$/)
458
+ for (let pass = 0; ready() !== 'complete' && pass < 20; pass++) repairSearchRows(db, 100)
459
+ assert.equal(ready(), 'complete')
460
+
461
+ // Fail mapping after FTS insertion; every chunk of the optional unit must roll back.
462
+ const before = names('normal')
463
+ const beforeCounts = mappingCounts()
464
+ db.exec("CREATE TRIGGER reject_mapping BEFORE INSERT ON span_search_rows WHEN NEW.trace_id = 'normal' BEGIN SELECT RAISE(ABORT, 'mapping failure'); END")
465
+ const failedChunks = Array.from({ length: 500 }, (_, i) => span('failed-chunk-' + i, 'root', 'mustrollback'))
466
+ failedChunks.push(span('normal', 'root', 'committedspan'))
467
+ await ingest(failedChunks)
468
+ assert.deepEqual(names('normal'), before)
469
+ assert.deepEqual(mappingCounts(), beforeCounts)
470
+ assert.equal(db.query("SELECT count(*) AS n FROM span_operation_fts WHERE operation_name = 'mustrollback'").get().n, 0)
471
+ assert.equal(db.query("SELECT operation_name FROM spans WHERE trace_id = 'normal' AND span_id = 'root'").get().operation_name, 'committedspan')
472
+ assert.equal(ready(), 'complete')
473
+ db.exec('DROP TRIGGER reject_mapping')
474
+ await ingest([span('normal', 'root', 'recovered')])
475
+ assert.deepEqual(names('normal'), ['recovered'])
476
+
477
+ const stableNames = names('normal')
478
+ const stableCounts = mappingCounts()
479
+ db.exec("CREATE TRIGGER reject_summary BEFORE INSERT ON trace_summaries WHEN NEW.trace_id = 'normal' BEGIN SELECT RAISE(ABORT, 'summary failure'); END")
480
+ await assert.rejects(ingest([span('normal', 'root', 'outerrollback')]))
481
+ assert.deepEqual(names('normal'), stableNames)
482
+ assert.deepEqual(mappingCounts(), stableCounts)
483
+ assert.equal(db.query("SELECT operation_name FROM spans WHERE trace_id = 'normal' AND span_id = 'root'").get().operation_name, 'recovered')
484
+ assert.equal(ready(), 'complete')
485
+ db.exec('DROP TRIGGER reject_summary')
486
+ } finally {
487
+ db?.close()
488
+ await runtime.dispose()
489
+ }
490
+ `
491
+ const child = Bun.spawn({
492
+ cmd: [process.execPath, "--no-env-file", "-e", script], cwd: process.cwd(),
493
+ env: { ...process.env, MOTEL_OTEL_DB_PATH: isolatedPath, MOTEL_OTEL_ENABLED: "false" },
494
+ stdout: "ignore", stderr: "pipe",
495
+ })
496
+ const stderr = await new Response(child.stderr).text()
497
+ expect(await child.exited, stderr).toBe(0)
498
+ })
499
+
500
+
501
+ it("repairs legacy span mappings at the pending cadence and idles after completion", async () => {
502
+ const isolatedPath = join(tempDir, "mapping-repair-pacing.sqlite")
503
+ const script = `
504
+ const assert = (await import('node:assert/strict')).default
505
+ const { Database } = await import('bun:sqlite')
506
+ const { Clock, Deferred, Effect, Layer, ManagedRuntime } = await import('effect')
507
+ const { TestClock } = await import('effect/testing')
508
+ const { TelemetryStore, makeTelemetryStoreLayer } = await import('./src/services/TelemetryStore.ts')
509
+ const { WriterDiagnostics } = await import('./src/ingestReadiness.ts')
510
+ const { SPAN_SEARCH_MAPPING_READY_KEY } = await import('./src/services/retention.ts')
511
+ const now = Date.now()
512
+ const nanos = BigInt(now) * 1000000n
513
+ const seed = ManagedRuntime.make(makeTelemetryStoreLayer({ readonly: false, runRetention: false }))
514
+ try {
515
+ await seed.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({
516
+ resourceSpans: [{
517
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'mapping-pacing' } }] },
518
+ scopeSpans: [{ spans: Array.from({ length: 250 }, (_, id) => ({
519
+ traceId: 'pacing-' + id, spanId: 'root', name: 'legacyoperation',
520
+ startTimeUnixNano: String(nanos), endTimeUnixNano: String(nanos + 1000000n),
521
+ })) }],
522
+ }],
523
+ })))
524
+ } finally { await seed.dispose() }
525
+ const db = new Database(process.env.MOTEL_OTEL_DB_PATH)
526
+ try {
527
+ db.exec('DELETE FROM span_search_rows')
528
+ db.query('DELETE FROM motel_maintenance WHERE key = ?').run(SPAN_SEARCH_MAPPING_READY_KEY)
529
+ db.exec("INSERT OR REPLACE INTO motel_maintenance VALUES ('retention_repair_span_operation_fts', '999999')")
530
+ await Effect.runPromise(Effect.scoped(Effect.gen(function* () {
531
+ const clock = yield* TestClock.make()
532
+ yield* clock.setTime(now + 2)
533
+ const firstPass = Deferred.makeUnsafe()
534
+ const passes = []
535
+ const publish = (event) => {
536
+ if (event._tag !== 'maintenance' || event.value.operation !== 'retention' || event.value.outcome === 'running') return
537
+ passes.push({
538
+ outcome: event.value.outcome,
539
+ mapped: db.query('SELECT count(*) AS n FROM span_search_rows').get().n,
540
+ state: db.query('SELECT value FROM motel_maintenance WHERE key = ?').get(SPAN_SEARCH_MAPPING_READY_KEY)?.value,
541
+ })
542
+ Deferred.doneUnsafe(firstPass, Effect.void)
543
+ }
544
+ const runtime = ManagedRuntime.make(makeTelemetryStoreLayer({ readonly: false, runRetention: true }).pipe(
545
+ Layer.provide(Layer.succeed(Clock.Clock, clock)),
546
+ Layer.provide(Layer.succeed(WriterDiagnostics, publish)),
547
+ ))
548
+ yield* Effect.addFinalizer(() => runtime.disposeEffect)
549
+ yield* Effect.promise(() => runtime.runPromise(Effect.asVoid(TelemetryStore)))
550
+ yield* Deferred.await(firstPass).pipe(Effect.timeout('5 seconds'))
551
+ assert.deepEqual(passes, [{ outcome: 'completed', mapped: 100, state: 'repairing:250' }])
552
+ yield* clock.adjust('100 millis')
553
+ assert.deepEqual(passes, [
554
+ { outcome: 'completed', mapped: 100, state: 'repairing:250' },
555
+ { outcome: 'completed', mapped: 200, state: 'repairing:250' },
556
+ ])
557
+ yield* clock.adjust('100 millis')
558
+ assert.deepEqual(passes, [
559
+ { outcome: 'completed', mapped: 100, state: 'repairing:250' },
560
+ { outcome: 'completed', mapped: 200, state: 'repairing:250' },
561
+ { outcome: 'completed', mapped: 250, state: 'complete' },
562
+ ])
563
+ yield* clock.adjust('100 millis')
564
+ assert.equal(passes.length, 3)
565
+ yield* clock.adjust('9900 millis')
566
+ assert.deepEqual(passes.at(-1), { outcome: 'completed', mapped: 250, state: 'complete' })
567
+ assert.equal(passes.length, 4)
568
+ yield* runtime.disposeEffect
569
+ yield* clock.adjust('30 seconds')
570
+ assert.equal(passes.length, 4)
571
+ })))
572
+ } finally { db.close() }
573
+ `
574
+ const child = Bun.spawn({
575
+ cmd: [process.execPath, "--no-env-file", "-e", script], cwd: process.cwd(),
576
+ env: {
577
+ ...process.env,
578
+ MOTEL_OTEL_DB_PATH: isolatedPath, MOTEL_OTEL_ENABLED: "false",
579
+ MOTEL_OTEL_RETENTION_ROW_BATCH: "1000", MOTEL_OTEL_RETENTION_INTERVAL_SECONDS: "10",
580
+ MOTEL_OTEL_RETENTION_TRACE_BATCH: "100", MOTEL_OTEL_RETENTION_HOURS: "168",
581
+ MOTEL_OTEL_MAX_DB_SIZE_MB: "1024",
582
+ },
583
+ stdout: "ignore", stderr: "pipe",
584
+ })
585
+ let timedOut = false
586
+ const deadline = setTimeout(() => { timedOut = true; child.kill() }, 15_000)
587
+ try {
588
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
589
+ expect(timedOut, stderr).toBe(false)
590
+ expect(exitCode, stderr).toBe(0)
591
+ } finally {
592
+ clearTimeout(deadline)
593
+ if (child.exitCode === null) child.kill()
594
+ await child.exited
595
+ }
596
+ }, 20_000)
597
+
410
598
  it("incrementally backfills historical AI FTS content", async () => {
411
599
  const historicalPath = join(tempDir, "historical-fts.sqlite")
412
600
  const seedScript = `
@@ -468,6 +656,42 @@ describe("motel telemetry store", () => {
468
656
  expect(result?.span.depth).toBe(1)
469
657
  })
470
658
 
659
+
660
+ it("opens and searches a read-only pre-retention database without creating mappings", async () => {
661
+ const legacyPath = join(tempDir, "readonly-pre-retention.sqlite")
662
+ const script = `
663
+ const assert = (await import('node:assert/strict')).default
664
+ const { Database } = await import('bun:sqlite')
665
+ const { Effect, ManagedRuntime } = await import('effect')
666
+ const { TelemetryStore, makeTelemetryStoreLayer, TelemetryStoreReadonly, TelemetryStoreReadonlyLive } = await import('./src/services/TelemetryStore.ts')
667
+ const writer = ManagedRuntime.make(makeTelemetryStoreLayer({ readonly: false, runRetention: false }))
668
+ try {
669
+ const now = BigInt(Date.now()) * 1000000n
670
+ await writer.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({ resourceSpans: [{ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'legacy-readonly' } }] }, scopeSpans: [{ spans: [{ traceId: 'legacy-read', spanId: 'root', name: 'legacyoperation', startTimeUnixNano: String(now), endTimeUnixNano: String(now + 1000000n) }] }] }] })))
671
+ } finally { await writer.dispose() }
672
+ const legacy = new Database(process.env.MOTEL_OTEL_DB_PATH)
673
+ legacy.exec('DROP VIEW retained_spans; DROP VIEW retained_logs; DROP VIEW retained_trace_summaries; DROP TABLE span_search_rows; DROP TABLE log_search_rows; DROP TABLE retention_traces; DROP TABLE retention_logs;')
674
+ const version = legacy.query('PRAGMA schema_version').get().schema_version
675
+ legacy.close()
676
+ const reader = ManagedRuntime.make(TelemetryStoreReadonlyLive)
677
+ try {
678
+ const trace = await reader.runPromise(Effect.flatMap(TelemetryStoreReadonly, (store) => store.getTrace('legacy-read')))
679
+ assert.equal(trace?.spans.length, 1)
680
+ const found = await reader.runPromise(Effect.flatMap(TelemetryStoreReadonly, (store) => store.searchSpans({ operation: 'legacyoperation' })))
681
+ assert.equal(found.length, 1)
682
+ assert.equal(found[0].traceId, 'legacy-read')
683
+ } finally { await reader.dispose() }
684
+ const verify = new Database(process.env.MOTEL_OTEL_DB_PATH, { readonly: true })
685
+ try {
686
+ assert.equal(verify.query('PRAGMA schema_version').get().schema_version, version)
687
+ assert.equal(verify.query("SELECT count(*) AS n FROM sqlite_master WHERE name IN ('span_search_rows', 'log_search_rows', 'retained_spans')").get().n, 0)
688
+ } finally { verify.close() }
689
+ `
690
+ const child = Bun.spawn({ cmd: [process.execPath, "--no-env-file", "-e", script], cwd: process.cwd(), env: { ...process.env, MOTEL_OTEL_DB_PATH: legacyPath }, stdout: "ignore", stderr: "pipe" })
691
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
692
+ expect(exitCode, stderr).toBe(0)
693
+ })
694
+
471
695
  it("returns trace details through the query worker when spans share empty event arrays", async () => {
472
696
  const workerDbPath = join(tempDir, "query-worker-shared-events.sqlite")
473
697
  const script = `
package/src/ui/aiState.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect"
2
- import * as Atom from "effect/unstable/reactivity/Atom"
2
+ import * as Atom from "effect/reactivity/Atom"
3
3
  import type { AiCallDetail } from "../domain.ts"
4
4
  import { queryRuntime } from "../runtime.ts"
5
5
  import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
package/src/ui/atoms.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as Atom from "effect/unstable/reactivity/Atom"
1
+ import * as Atom from "effect/reactivity/Atom"
2
2
  import { config, explicitServiceName } from "../config.ts"
3
3
  import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
4
4
  import type { ThemeName } from "./theme.ts"
@@ -20,7 +20,7 @@ describe("initial TUI service selection", () => {
20
20
 
21
21
  // Fresh processes exercise import-time config and persistence without module-cache leakage.
22
22
  const result = Bun.spawnSync([process.execPath, "--no-env-file", "--eval", `
23
- import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
23
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry"
24
24
  import { config } from "./src/config.ts"
25
25
  import { selectedTraceServiceAtom } from "./src/ui/atoms.ts"
26
26
  const registry = AtomRegistry.make()