@interactive-inc/claude-funnel 0.10.0 → 0.10.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/bin.js +448 -448
- package/dist/connectors/slack.d.ts +1 -29
- package/dist/gateway/daemon.js +166 -166
- package/dist/index.d.ts +4 -11
- package/dist/index.js +133 -120
- package/dist/slack-event-processor-CS-bAit9.d.ts +43 -0
- package/package.json +1 -6
- package/dist/slack-connector-schema-D7zAHN8k.d.ts +0 -15
- package/lib/bin.ts +0 -3
- package/lib/cli/factory.ts +0 -10
- package/lib/cli/index.ts +0 -85
- package/lib/cli/router/query-to-cli-args.ts +0 -20
- package/lib/cli/router/to-request.ts +0 -113
- package/lib/cli/router/validator.ts +0 -27
- package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +0 -27
- package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +0 -40
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +0 -41
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +0 -22
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +0 -23
- package/lib/cli/routes/channels.$channel.connectors.$connector.ts +0 -26
- package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +0 -92
- package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +0 -22
- package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +0 -63
- package/lib/cli/routes/channels.$channel.connectors.ts +0 -26
- package/lib/cli/routes/channels.$channel.publish.ts +0 -52
- package/lib/cli/routes/channels.$channel.rename.$newName.ts +0 -22
- package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +0 -34
- package/lib/cli/routes/channels.$channel.ts +0 -34
- package/lib/cli/routes/channels.add.$channel.ts +0 -33
- package/lib/cli/routes/channels.remove.$channel.ts +0 -20
- package/lib/cli/routes/channels.ts +0 -39
- package/lib/cli/routes/claude.ts +0 -70
- package/lib/cli/routes/gateway.listeners.ts +0 -41
- package/lib/cli/routes/gateway.logs.ts +0 -123
- package/lib/cli/routes/gateway.restart.ts +0 -50
- package/lib/cli/routes/gateway.run.ts +0 -41
- package/lib/cli/routes/gateway.start.ts +0 -50
- package/lib/cli/routes/gateway.status.ts +0 -19
- package/lib/cli/routes/gateway.stop.ts +0 -32
- package/lib/cli/routes/gateway.ts +0 -55
- package/lib/cli/routes/index.ts +0 -219
- package/lib/cli/routes/profiles.$profile.as-default.ts +0 -22
- package/lib/cli/routes/profiles.$profile.rename.$newName.ts +0 -22
- package/lib/cli/routes/profiles.$profile.run.ts +0 -36
- package/lib/cli/routes/profiles.add.$profile.ts +0 -49
- package/lib/cli/routes/profiles.remove.$profile.ts +0 -20
- package/lib/cli/routes/profiles.set.$profile.ts +0 -45
- package/lib/cli/routes/profiles.ts +0 -40
- package/lib/cli/routes/status.ts +0 -93
- package/lib/cli/routes/update.ts +0 -27
- package/lib/connectors/connector-adapter.ts +0 -9
- package/lib/connectors/connector-config-schema.ts +0 -16
- package/lib/connectors/connector-factory.ts +0 -94
- package/lib/connectors/connector-listener.ts +0 -20
- package/lib/connectors/discord-adapter.ts +0 -51
- package/lib/connectors/discord-connector-schema.ts +0 -12
- package/lib/connectors/discord-event-processor.ts +0 -48
- package/lib/connectors/discord-listener.ts +0 -111
- package/lib/connectors/discord.ts +0 -4
- package/lib/connectors/gh-adapter.ts +0 -48
- package/lib/connectors/gh-connector-schema.ts +0 -12
- package/lib/connectors/gh-listener.ts +0 -137
- package/lib/connectors/gh.ts +0 -3
- package/lib/connectors/match-cron.ts +0 -78
- package/lib/connectors/schedule-connector-schema.ts +0 -33
- package/lib/connectors/schedule-listener.ts +0 -207
- package/lib/connectors/schedule-state-store.ts +0 -54
- package/lib/connectors/schedule.ts +0 -4
- package/lib/connectors/slack-adapter.ts +0 -36
- package/lib/connectors/slack-connector-schema.ts +0 -13
- package/lib/connectors/slack-event-processor.ts +0 -97
- package/lib/connectors/slack-listener.ts +0 -97
- package/lib/connectors/slack.ts +0 -4
- package/lib/engine/channels/channels.ts +0 -520
- package/lib/engine/claude/claude.ts +0 -205
- package/lib/engine/claude/gateway-controller.ts +0 -4
- package/lib/engine/fs/file-system.ts +0 -23
- package/lib/engine/fs/memory-file-system.ts +0 -102
- package/lib/engine/fs/node-file-system.ts +0 -68
- package/lib/engine/http/http-client.ts +0 -17
- package/lib/engine/http/memory-http-client.ts +0 -36
- package/lib/engine/http/node-http-client.ts +0 -23
- package/lib/engine/id/id-generator.ts +0 -7
- package/lib/engine/id/memory-id-generator.ts +0 -20
- package/lib/engine/id/node-id-generator.ts +0 -7
- package/lib/engine/logger/logger.ts +0 -11
- package/lib/engine/logger/memory-logger.ts +0 -28
- package/lib/engine/logger/node-logger.ts +0 -49
- package/lib/engine/logger/noop-logger.ts +0 -9
- package/lib/engine/mcp/channel-server.ts +0 -123
- package/lib/engine/mcp/channel-subscriber.ts +0 -82
- package/lib/engine/mcp/mcp.ts +0 -126
- package/lib/engine/mcp/read-channel-connectors.ts +0 -34
- package/lib/engine/mcp/read-gateway-token.ts +0 -16
- package/lib/engine/mcp/usage-hint-for-type.ts +0 -15
- package/lib/engine/process/memory-process-runner.ts +0 -88
- package/lib/engine/process/node-process-runner.ts +0 -91
- package/lib/engine/process/process-runner.ts +0 -33
- package/lib/engine/profiles/profile-channel-checker.ts +0 -7
- package/lib/engine/profiles/profiles.ts +0 -126
- package/lib/engine/settings/mock-settings-reader.ts +0 -27
- package/lib/engine/settings/settings-reader.ts +0 -6
- package/lib/engine/settings/settings-schema.ts +0 -48
- package/lib/engine/settings/settings-store.ts +0 -110
- package/lib/engine/time/clock.ts +0 -15
- package/lib/engine/time/memory-clock.ts +0 -26
- package/lib/engine/time/node-clock.ts +0 -7
- package/lib/funnel.ts +0 -294
- package/lib/gateway/auth-middleware.ts +0 -44
- package/lib/gateway/broadcaster.ts +0 -319
- package/lib/gateway/channel-publisher.ts +0 -67
- package/lib/gateway/daemon.ts +0 -47
- package/lib/gateway/factory.ts +0 -10
- package/lib/gateway/funnel-event-store.ts +0 -155
- package/lib/gateway/gateway-server.ts +0 -426
- package/lib/gateway/gateway-token.ts +0 -79
- package/lib/gateway/gateway.ts +0 -209
- package/lib/gateway/kill-competing-slack-gateways.ts +0 -56
- package/lib/gateway/listener-supervisor.ts +0 -339
- package/lib/gateway/listeners-client.ts +0 -128
- package/lib/gateway/publish-schema.ts +0 -27
- package/lib/gateway/resolve-daemon-script.ts +0 -26
- package/lib/gateway/routes/channels.connectors.call.ts +0 -39
- package/lib/gateway/routes/channels.publish.ts +0 -44
- package/lib/gateway/routes/health.ts +0 -13
- package/lib/gateway/routes/index.ts +0 -26
- package/lib/gateway/routes/listeners.list.ts +0 -6
- package/lib/gateway/routes/listeners.restart.ts +0 -15
- package/lib/gateway/routes/listeners.start.ts +0 -15
- package/lib/gateway/routes/listeners.stop.ts +0 -15
- package/lib/gateway/routes/route-deps.ts +0 -19
- package/lib/gateway/routes/status.ts +0 -15
- package/lib/gateway/routes/validator.ts +0 -17
- package/lib/index.ts +0 -67
- package/lib/logger/leuco-human-file-writer.ts +0 -65
- package/lib/logger/leuco-human-logger.ts +0 -98
- package/lib/logger/leuco-human-record.ts +0 -16
- package/lib/logger/leuco-human-stdout-writer.ts +0 -26
- package/lib/logger/leuco-human-writer.ts +0 -14
- package/lib/logger/leuco-logger-memory-sink.ts +0 -67
- package/lib/logger/leuco-logger-record.ts +0 -13
- package/lib/logger/leuco-logger-sink.ts +0 -33
- package/lib/logger/leuco-logger-sqlite-sink.ts +0 -355
- package/lib/logger/leuco-logger.ts +0 -135
- package/lib/tui/app.tsx +0 -357
- package/lib/tui/components/add-row.tsx +0 -18
- package/lib/tui/components/brand.tsx +0 -27
- package/lib/tui/components/card.tsx +0 -44
- package/lib/tui/components/detail-bar.tsx +0 -46
- package/lib/tui/components/editable-field.tsx +0 -33
- package/lib/tui/components/empty-state.tsx +0 -11
- package/lib/tui/components/gateway-status.tsx +0 -66
- package/lib/tui/components/keymap.tsx +0 -29
- package/lib/tui/components/menu-item.tsx +0 -73
- package/lib/tui/components/menu.tsx +0 -26
- package/lib/tui/components/panel-header.tsx +0 -22
- package/lib/tui/components/readonly-field.tsx +0 -18
- package/lib/tui/components/section-header.tsx +0 -25
- package/lib/tui/components/selection-accent.tsx +0 -32
- package/lib/tui/components/session-item.tsx +0 -33
- package/lib/tui/components/session-list.tsx +0 -33
- package/lib/tui/components/ui/hascii/accordion-item.tsx +0 -88
- package/lib/tui/components/ui/hascii/accordion.tsx +0 -96
- package/lib/tui/components/ui/hascii/alert-dialog.tsx +0 -43
- package/lib/tui/components/ui/hascii/badge.tsx +0 -51
- package/lib/tui/components/ui/hascii/breadcrumb.tsx +0 -58
- package/lib/tui/components/ui/hascii/button.tsx +0 -194
- package/lib/tui/components/ui/hascii/card-content.tsx +0 -14
- package/lib/tui/components/ui/hascii/card-description.tsx +0 -13
- package/lib/tui/components/ui/hascii/card-footer.tsx +0 -14
- package/lib/tui/components/ui/hascii/card-header.tsx +0 -14
- package/lib/tui/components/ui/hascii/card-title.tsx +0 -13
- package/lib/tui/components/ui/hascii/card.tsx +0 -27
- package/lib/tui/components/ui/hascii/checkbox.tsx +0 -65
- package/lib/tui/components/ui/hascii/command.tsx +0 -159
- package/lib/tui/components/ui/hascii/dialog-content.tsx +0 -14
- package/lib/tui/components/ui/hascii/dialog-description.tsx +0 -13
- package/lib/tui/components/ui/hascii/dialog-footer.tsx +0 -14
- package/lib/tui/components/ui/hascii/dialog-header.tsx +0 -14
- package/lib/tui/components/ui/hascii/dialog-title.tsx +0 -13
- package/lib/tui/components/ui/hascii/dialog.tsx +0 -27
- package/lib/tui/components/ui/hascii/file-tree.tsx +0 -142
- package/lib/tui/components/ui/hascii/focus-group.tsx +0 -62
- package/lib/tui/components/ui/hascii/form-item.tsx +0 -43
- package/lib/tui/components/ui/hascii/input-otp.tsx +0 -86
- package/lib/tui/components/ui/hascii/input.tsx +0 -130
- package/lib/tui/components/ui/hascii/pagination.tsx +0 -105
- package/lib/tui/components/ui/hascii/progress.tsx +0 -28
- package/lib/tui/components/ui/hascii/select.tsx +0 -131
- package/lib/tui/components/ui/hascii/separator.tsx +0 -35
- package/lib/tui/components/ui/hascii/sidebar-content.tsx +0 -23
- package/lib/tui/components/ui/hascii/sidebar-header.tsx +0 -14
- package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +0 -67
- package/lib/tui/components/ui/hascii/sidebar.tsx +0 -24
- package/lib/tui/components/ui/hascii/skeleton.tsx +0 -60
- package/lib/tui/components/ui/hascii/slider.tsx +0 -91
- package/lib/tui/components/ui/hascii/snackbar.tsx +0 -75
- package/lib/tui/components/ui/hascii/sparkline.tsx +0 -53
- package/lib/tui/components/ui/hascii/spinner.tsx +0 -47
- package/lib/tui/components/ui/hascii/stepper.tsx +0 -54
- package/lib/tui/components/ui/hascii/switch.tsx +0 -66
- package/lib/tui/components/ui/hascii/table.tsx +0 -95
- package/lib/tui/components/ui/hascii/tabs.tsx +0 -59
- package/lib/tui/components/ui/hascii/toggle-group-item.tsx +0 -45
- package/lib/tui/components/ui/hascii/toggle-group.tsx +0 -99
- package/lib/tui/components/ui/hascii/tree.tsx +0 -104
- package/lib/tui/components/view-shell.tsx +0 -44
- package/lib/tui/filter-input.tsx +0 -33
- package/lib/tui/hooks/hascii/use-pressable.ts +0 -54
- package/lib/tui/parse-comma-list.ts +0 -14
- package/lib/tui/profile-launcher.tsx +0 -61
- package/lib/tui/scrollbar-options.ts +0 -19
- package/lib/tui/sidebar.tsx +0 -50
- package/lib/tui/theme.ts +0 -40
- package/lib/tui/tui.tsx +0 -20
- package/lib/tui/types.ts +0 -38
- package/lib/tui/unique-name.ts +0 -18
- package/lib/tui/use-event-stream.ts +0 -133
- package/lib/tui/use-snapshot.ts +0 -99
- package/lib/tui/utils/hascii/form-item-context.tsx +0 -23
- package/lib/tui/utils/hascii/input-focus-context.tsx +0 -31
- package/lib/tui/utils/hascii/theme-context.tsx +0 -26
- package/lib/tui/utils/hascii/theme.ts +0 -176
- package/lib/tui/views/channels-view.tsx +0 -108
- package/lib/tui/views/connectors-view.tsx +0 -164
- package/lib/tui/views/events-view.tsx +0 -160
- package/lib/tui/views/listeners-view.tsx +0 -80
- package/lib/tui/views/profiles-view.tsx +0 -152
|
@@ -1,355 +0,0 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite"
|
|
2
|
-
import type { SQLQueryBindings, Statement } from "bun:sqlite"
|
|
3
|
-
import type { LeucoLoggerRecord } from "@/logger/leuco-logger-record"
|
|
4
|
-
import type { LeucoLoggerPrimarySink, LeucoLoggerSink } from "@/logger/leuco-logger-sink"
|
|
5
|
-
|
|
6
|
-
type IndexValues<I extends ReadonlyArray<string>> = Record<I[number], string | null>
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Constructor props. The shape narrows on `I`: when no indexes are
|
|
10
|
-
* declared (the default), `extractIndexes` is forbidden; when indexes
|
|
11
|
-
* are declared, both `indexes` and `extractIndexes` are required and
|
|
12
|
-
* `extractIndexes` is type-checked against the index keys.
|
|
13
|
-
*/
|
|
14
|
-
type Props<E, I extends ReadonlyArray<string>> = I extends readonly []
|
|
15
|
-
? {
|
|
16
|
-
path: string
|
|
17
|
-
maxRows?: number
|
|
18
|
-
maxAgeMs?: number
|
|
19
|
-
now?: () => number
|
|
20
|
-
indexes?: I
|
|
21
|
-
extractIndexes?: never
|
|
22
|
-
}
|
|
23
|
-
: {
|
|
24
|
-
path: string
|
|
25
|
-
maxRows?: number
|
|
26
|
-
maxAgeMs?: number
|
|
27
|
-
now?: () => number
|
|
28
|
-
indexes: I
|
|
29
|
-
extractIndexes: (event: E) => IndexValues<I>
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
type GetRecordsProps<I extends ReadonlyArray<string>> = {
|
|
33
|
-
/** Return only records with seq strictly greater than this. */
|
|
34
|
-
sinceSeq?: number
|
|
35
|
-
/** Filter by the top-level `event.type` discriminator. */
|
|
36
|
-
type?: string
|
|
37
|
-
/** Filter by indexed columns. Keys are constrained to the declared `indexes`. */
|
|
38
|
-
where?: Partial<IndexValues<I>>
|
|
39
|
-
/** Maximum rows returned. Default 1000. */
|
|
40
|
-
limit?: number
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
type EventRow = {
|
|
44
|
-
seq: number
|
|
45
|
-
ts: number
|
|
46
|
-
type: string | null
|
|
47
|
-
event: string
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
type CountRow = { n: number }
|
|
51
|
-
type MaxRow = { max: number }
|
|
52
|
-
type VersionRow = { user_version: number }
|
|
53
|
-
type ColumnRow = { name: string }
|
|
54
|
-
|
|
55
|
-
/** Conservative whitelist for column names interpolated into SQL. */
|
|
56
|
-
const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/
|
|
57
|
-
|
|
58
|
-
const RESERVED_COLUMNS: ReadonlySet<string> = new Set(["seq", "ts", "type", "event"])
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Schema versions. Each entry is the list of DDL statements that take the
|
|
62
|
-
* database from version i to version i + 1. Migrations run in a transaction
|
|
63
|
-
* so a partial failure rolls back. Adding a new version is append-only —
|
|
64
|
-
* never edit a published one. Caller-defined index columns are added
|
|
65
|
-
* dynamically on construct (independent of versioned migrations) because
|
|
66
|
-
* they are configuration, not schema evolution.
|
|
67
|
-
*/
|
|
68
|
-
const MIGRATIONS: ReadonlyArray<ReadonlyArray<string>> = [
|
|
69
|
-
[
|
|
70
|
-
"CREATE TABLE IF NOT EXISTS leuco_log (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
|
|
71
|
-
"CREATE INDEX IF NOT EXISTS idx_leuco_log_ts ON leuco_log (ts)",
|
|
72
|
-
"CREATE INDEX IF NOT EXISTS idx_leuco_log_type ON leuco_log (type)",
|
|
73
|
-
],
|
|
74
|
-
]
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* SQLite-backed sink built on `bun:sqlite`. Implements both primary and
|
|
78
|
-
* relay roles so the same instance can own seq generation for one bus and
|
|
79
|
-
* mirror records from another (e.g. cross-process replication, restore
|
|
80
|
-
* from a backup stream).
|
|
81
|
-
*
|
|
82
|
-
* Concurrency model: seq is `INTEGER PRIMARY KEY`, so SQLite assigns it
|
|
83
|
-
* atomically via `lastInsertRowid`. Two `LeucoLogger` instances pointed
|
|
84
|
-
* at the same database file therefore see one monotonically increasing
|
|
85
|
-
* seq stream without any bus-level coordination — the database itself is
|
|
86
|
-
* the synchronization point.
|
|
87
|
-
*
|
|
88
|
-
* Schema is version-managed via `PRAGMA user_version`. Migrations are
|
|
89
|
-
* append-only and run in a transaction on every construct so a partial
|
|
90
|
-
* upgrade rolls back cleanly. Caller-defined `indexes` are layered on top
|
|
91
|
-
* via `ALTER TABLE ADD COLUMN` + `CREATE INDEX IF NOT EXISTS`, so adding
|
|
92
|
-
* a new index to an existing database is a no-downtime operation.
|
|
93
|
-
*
|
|
94
|
-
* Type safety: the second generic parameter `I` is the literal tuple of
|
|
95
|
-
* index column names. `extractIndexes` and `getRecords({ where })` are
|
|
96
|
-
* both type-checked against this tuple, so a typo at the call site is a
|
|
97
|
-
* compile-time error rather than a silent miss at runtime.
|
|
98
|
-
*
|
|
99
|
-
* Retention is bounded by `maxRows` and/or `maxAgeMs`. Both run on every
|
|
100
|
-
* insert as a single indexed DELETE that no-ops below the cap.
|
|
101
|
-
*
|
|
102
|
-
* Bulk inserts use `insertMany`, which wraps the batch in one transaction
|
|
103
|
-
* for ~10–100x throughput at the cost of one fsync per batch instead of
|
|
104
|
-
* one per row.
|
|
105
|
-
*/
|
|
106
|
-
export class LeucoLoggerSqliteSink<E, const I extends ReadonlyArray<string> = readonly []>
|
|
107
|
-
implements LeucoLoggerPrimarySink<E>, LeucoLoggerSink<E>
|
|
108
|
-
{
|
|
109
|
-
private readonly db: Database
|
|
110
|
-
private readonly maxRows: number | null
|
|
111
|
-
private readonly maxAgeMs: number | null
|
|
112
|
-
private readonly now: () => number
|
|
113
|
-
private readonly indexes: I
|
|
114
|
-
private readonly extractIndexes: ((event: E) => IndexValues<I>) | null
|
|
115
|
-
private readonly insertStmt: Statement<unknown, SQLQueryBindings[]>
|
|
116
|
-
private readonly insertWithSeqStmt: Statement<unknown, SQLQueryBindings[]>
|
|
117
|
-
private readonly maxSeqStmt: Statement<MaxRow, []>
|
|
118
|
-
private readonly countStmt: Statement<CountRow, []>
|
|
119
|
-
private readonly trimRowsStmt: Statement<unknown, [number]>
|
|
120
|
-
private readonly trimAgeStmt: Statement<unknown, [number]>
|
|
121
|
-
|
|
122
|
-
constructor(props: Props<E, I>) {
|
|
123
|
-
this.db = new Database(props.path)
|
|
124
|
-
this.db.run("PRAGMA journal_mode = WAL")
|
|
125
|
-
this.migrate()
|
|
126
|
-
|
|
127
|
-
this.maxRows = props.maxRows ?? null
|
|
128
|
-
this.maxAgeMs = props.maxAgeMs ?? null
|
|
129
|
-
this.now = props.now ?? (() => Date.now())
|
|
130
|
-
|
|
131
|
-
// The conditional `Props<E, I>` type widens to a union when `I` is a
|
|
132
|
-
// generic, so TS can't narrow `props.indexes` back to `I` after the
|
|
133
|
-
// runtime check. One cast at this boundary brings it back; everything
|
|
134
|
-
// downstream stays I-typed.
|
|
135
|
-
this.indexes = (props.indexes ?? []) as unknown as I
|
|
136
|
-
|
|
137
|
-
if (this.indexes.length > 0) {
|
|
138
|
-
validateIndexNames(this.indexes)
|
|
139
|
-
this.extractIndexes = props.extractIndexes ?? null
|
|
140
|
-
this.syncIndexColumns()
|
|
141
|
-
} else {
|
|
142
|
-
this.extractIndexes = null
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const cols = ["ts", "type", "event", ...this.indexes]
|
|
146
|
-
const placeholders = cols.map(() => "?").join(", ")
|
|
147
|
-
this.insertStmt = this.db.prepare(
|
|
148
|
-
`INSERT INTO leuco_log (${cols.join(", ")}) VALUES (${placeholders})`,
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
const colsWithSeq = ["seq", ...cols]
|
|
152
|
-
const placeholdersWithSeq = colsWithSeq.map(() => "?").join(", ")
|
|
153
|
-
this.insertWithSeqStmt = this.db.prepare(
|
|
154
|
-
`INSERT INTO leuco_log (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`,
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM leuco_log")
|
|
158
|
-
this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log")
|
|
159
|
-
this.trimRowsStmt = this.db.prepare(
|
|
160
|
-
"DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)",
|
|
161
|
-
)
|
|
162
|
-
this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?")
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
insert(input: { ts: number; event: E }): LeucoLoggerRecord<E> | Error {
|
|
166
|
-
try {
|
|
167
|
-
const params = this.buildInsertParams(input.ts, input.event)
|
|
168
|
-
const result = this.insertStmt.run(...params)
|
|
169
|
-
const seq = Number(result.lastInsertRowid)
|
|
170
|
-
this.trim()
|
|
171
|
-
return { seq, ts: input.ts, event: input.event }
|
|
172
|
-
} catch (e) {
|
|
173
|
-
return e instanceof Error ? e : new Error(String(e))
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
insertMany(inputs: ReadonlyArray<{ ts: number; event: E }>): LeucoLoggerRecord<E>[] | Error {
|
|
178
|
-
if (inputs.length === 0) return []
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
const records: LeucoLoggerRecord<E>[] = []
|
|
182
|
-
const apply = this.db.transaction((batch: ReadonlyArray<{ ts: number; event: E }>) => {
|
|
183
|
-
for (const input of batch) {
|
|
184
|
-
const params = this.buildInsertParams(input.ts, input.event)
|
|
185
|
-
const result = this.insertStmt.run(...params)
|
|
186
|
-
records.push({
|
|
187
|
-
seq: Number(result.lastInsertRowid),
|
|
188
|
-
ts: input.ts,
|
|
189
|
-
event: input.event,
|
|
190
|
-
})
|
|
191
|
-
}
|
|
192
|
-
})
|
|
193
|
-
apply(inputs)
|
|
194
|
-
this.trim()
|
|
195
|
-
return records
|
|
196
|
-
} catch (e) {
|
|
197
|
-
return e instanceof Error ? e : new Error(String(e))
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
write(record: LeucoLoggerRecord<E>): void | Error {
|
|
202
|
-
try {
|
|
203
|
-
const params: SQLQueryBindings[] = [
|
|
204
|
-
record.seq,
|
|
205
|
-
...this.buildInsertParams(record.ts, record.event),
|
|
206
|
-
]
|
|
207
|
-
this.insertWithSeqStmt.run(...params)
|
|
208
|
-
this.trim()
|
|
209
|
-
} catch (e) {
|
|
210
|
-
return e instanceof Error ? e : new Error(String(e))
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
getMaxSeq(): number {
|
|
215
|
-
const row = this.maxSeqStmt.get()
|
|
216
|
-
return row ? row.max : 0
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
getRecords(props: GetRecordsProps<I> = {}): LeucoLoggerRecord<E>[] {
|
|
220
|
-
const conditions: string[] = ["seq > ?"]
|
|
221
|
-
const params: SQLQueryBindings[] = [props.sinceSeq ?? 0]
|
|
222
|
-
|
|
223
|
-
if (typeof props.type === "string") {
|
|
224
|
-
conditions.push("type = ?")
|
|
225
|
-
params.push(props.type)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (props.where) {
|
|
229
|
-
this.appendWhereConditions(props.where, conditions, params)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const limit = props.limit ?? 1000
|
|
233
|
-
params.push(limit)
|
|
234
|
-
|
|
235
|
-
const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ASC LIMIT ?`
|
|
236
|
-
const stmt = this.db.prepare<EventRow, SQLQueryBindings[]>(sql)
|
|
237
|
-
return stmt.all(...params).map(toRecord<E>)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Current schema version. Useful for diagnostics and for tests that want
|
|
242
|
-
* to verify migrations ran. Reads `PRAGMA user_version` once per call.
|
|
243
|
-
*/
|
|
244
|
-
getSchemaVersion(): number {
|
|
245
|
-
const row = this.db.prepare<VersionRow, []>("PRAGMA user_version").get()
|
|
246
|
-
return row?.user_version ?? 0
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
close(): void {
|
|
250
|
-
this.db.close()
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
private buildInsertParams(ts: number, event: E): SQLQueryBindings[] {
|
|
254
|
-
const type = extractType(event)
|
|
255
|
-
const json = JSON.stringify(event)
|
|
256
|
-
if (this.indexes.length === 0) return [ts, type, json]
|
|
257
|
-
|
|
258
|
-
// The user's typed Record<I[number], V> is structurally a string-keyed
|
|
259
|
-
// object at runtime; widen so we can index by `col: string` from the loop.
|
|
260
|
-
const values = this.extractIndexes
|
|
261
|
-
? (this.extractIndexes(event) as unknown as Record<string, string | null>)
|
|
262
|
-
: null
|
|
263
|
-
const indexParams = this.indexes.map((col) => values?.[col] ?? null)
|
|
264
|
-
return [ts, type, json, ...indexParams]
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
private appendWhereConditions(
|
|
268
|
-
where: Partial<IndexValues<I>>,
|
|
269
|
-
conditions: string[],
|
|
270
|
-
params: SQLQueryBindings[],
|
|
271
|
-
): void {
|
|
272
|
-
const widened = where as unknown as Partial<Record<string, string | null>>
|
|
273
|
-
for (const col of this.indexes) {
|
|
274
|
-
const value = widened[col]
|
|
275
|
-
if (value === undefined) continue
|
|
276
|
-
if (value === null) {
|
|
277
|
-
conditions.push(`${col} IS NULL`)
|
|
278
|
-
} else {
|
|
279
|
-
conditions.push(`${col} = ?`)
|
|
280
|
-
params.push(value)
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
private trim(): void {
|
|
286
|
-
if (this.maxRows !== null) {
|
|
287
|
-
const row = this.countStmt.get()
|
|
288
|
-
if (row && row.n > this.maxRows) this.trimRowsStmt.run(this.maxRows)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (this.maxAgeMs !== null) {
|
|
292
|
-
this.trimAgeStmt.run(this.now() - this.maxAgeMs)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
private syncIndexColumns(): void {
|
|
297
|
-
const existing = new Set(
|
|
298
|
-
this.db
|
|
299
|
-
.prepare<ColumnRow, []>("PRAGMA table_info(leuco_log)")
|
|
300
|
-
.all()
|
|
301
|
-
.map((r) => r.name),
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
for (const col of this.indexes) {
|
|
305
|
-
if (!existing.has(col)) {
|
|
306
|
-
this.db.run(`ALTER TABLE leuco_log ADD COLUMN ${col} TEXT`)
|
|
307
|
-
}
|
|
308
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS idx_leuco_log_${col} ON leuco_log (${col})`)
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private migrate(): void {
|
|
313
|
-
const row = this.db.prepare<VersionRow, []>("PRAGMA user_version").get()
|
|
314
|
-
const current = row?.user_version ?? 0
|
|
315
|
-
if (current >= MIGRATIONS.length) return
|
|
316
|
-
|
|
317
|
-
const pending = MIGRATIONS.slice(current)
|
|
318
|
-
let version = current
|
|
319
|
-
|
|
320
|
-
for (const stmts of pending) {
|
|
321
|
-
version += 1
|
|
322
|
-
const apply = this.db.transaction(() => {
|
|
323
|
-
for (const stmt of stmts) this.db.run(stmt)
|
|
324
|
-
this.db.run(`PRAGMA user_version = ${version}`)
|
|
325
|
-
})
|
|
326
|
-
apply()
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function validateIndexNames(names: ReadonlyArray<string>): void {
|
|
332
|
-
for (const name of names) {
|
|
333
|
-
if (!COLUMN_NAME_RE.test(name)) {
|
|
334
|
-
throw new Error(`invalid index column name: ${name}`)
|
|
335
|
-
}
|
|
336
|
-
if (RESERVED_COLUMNS.has(name)) {
|
|
337
|
-
throw new Error(`reserved index column name: ${name}`)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function extractType(event: unknown): string | null {
|
|
343
|
-
if (typeof event !== "object" || event === null) return null
|
|
344
|
-
if (!("type" in event)) return null
|
|
345
|
-
const t = event.type
|
|
346
|
-
return typeof t === "string" ? t : null
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function toRecord<E>(row: EventRow): LeucoLoggerRecord<E> {
|
|
350
|
-
return {
|
|
351
|
-
seq: row.seq,
|
|
352
|
-
ts: row.ts,
|
|
353
|
-
event: JSON.parse(row.event),
|
|
354
|
-
}
|
|
355
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import type { ZodType } from "zod"
|
|
2
|
-
import type { LeucoLoggerRecord } from "@/logger/leuco-logger-record"
|
|
3
|
-
import type { LeucoLoggerPrimarySink, LeucoLoggerSink } from "@/logger/leuco-logger-sink"
|
|
4
|
-
|
|
5
|
-
type Listener<E> = (record: LeucoLoggerRecord<E>) => void
|
|
6
|
-
|
|
7
|
-
type SinkErrorHandler<E> = (
|
|
8
|
-
error: Error,
|
|
9
|
-
record: LeucoLoggerRecord<E>,
|
|
10
|
-
sink: LeucoLoggerSink<E>,
|
|
11
|
-
) => void
|
|
12
|
-
|
|
13
|
-
type Props<E> = {
|
|
14
|
-
/** Zod schema for the event union. Validated on every `emit`. */
|
|
15
|
-
schema: ZodType<E>
|
|
16
|
-
/** Owns seq assignment + durability. Use `LeucoLoggerSqliteSink` for multi-process safety. */
|
|
17
|
-
primary: LeucoLoggerPrimarySink<E>
|
|
18
|
-
/** Optional fanout for already-sequenced records (memory ring, stdout, network mirror). */
|
|
19
|
-
relays?: ReadonlyArray<LeucoLoggerSink<E>>
|
|
20
|
-
/** Override for tests. Defaults to `Date.now`. */
|
|
21
|
-
now?: () => number
|
|
22
|
-
/** Observer for relay failures. Default: silently swallow. */
|
|
23
|
-
onSinkError?: SinkErrorHandler<E>
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Schema-validated event log bus. Three responsibilities and nothing else:
|
|
28
|
-
* validate the event, delegate seq + persistence to the primary sink, and
|
|
29
|
-
* fan the resulting record out to relays and live subscribers.
|
|
30
|
-
*
|
|
31
|
-
* Splitting "primary" from "relays" makes the seq invariant honest: there
|
|
32
|
-
* is exactly one source of truth (the primary's atomic insert). Two
|
|
33
|
-
* `LeucoLogger` instances pointed at the same SQLite file therefore see
|
|
34
|
-
* one monotonic stream without bus-level coordination. Relays mirror
|
|
35
|
-
* already-sequenced records, so they can be added or removed without
|
|
36
|
-
* affecting correctness.
|
|
37
|
-
*
|
|
38
|
-
* Failure isolation:
|
|
39
|
-
* - Primary failure short-circuits emit and is returned to the caller.
|
|
40
|
-
* - Relay failures never block the primary path — they surface via the
|
|
41
|
-
* optional `onSinkError` callback so the caller can observe without
|
|
42
|
-
* being interrupted.
|
|
43
|
-
* - A subscriber that throws is contained; the rest of the fanout
|
|
44
|
-
* completes normally.
|
|
45
|
-
*/
|
|
46
|
-
export class LeucoLogger<E> {
|
|
47
|
-
private readonly schema: ZodType<E>
|
|
48
|
-
private readonly primary: LeucoLoggerPrimarySink<E>
|
|
49
|
-
private readonly relays: ReadonlyArray<LeucoLoggerSink<E>>
|
|
50
|
-
private readonly now: () => number
|
|
51
|
-
private readonly onSinkError: SinkErrorHandler<E> | null
|
|
52
|
-
private readonly listeners = new Set<Listener<E>>()
|
|
53
|
-
|
|
54
|
-
constructor(props: Props<E>) {
|
|
55
|
-
this.schema = props.schema
|
|
56
|
-
this.primary = props.primary
|
|
57
|
-
this.relays = props.relays ?? []
|
|
58
|
-
this.now = props.now ?? (() => Date.now())
|
|
59
|
-
this.onSinkError = props.onSinkError ?? null
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
emit(event: E): LeucoLoggerRecord<E> | Error {
|
|
63
|
-
const parsed = this.schema.safeParse(event)
|
|
64
|
-
if (!parsed.success) return parsed.error
|
|
65
|
-
|
|
66
|
-
const result = this.callPrimary(parsed.data)
|
|
67
|
-
if (result instanceof Error) return result
|
|
68
|
-
|
|
69
|
-
this.fanOutToRelays(result)
|
|
70
|
-
this.fanOutToListeners(result)
|
|
71
|
-
|
|
72
|
-
return result
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
subscribe(listener: Listener<E>): () => void {
|
|
76
|
-
this.listeners.add(listener)
|
|
77
|
-
return () => {
|
|
78
|
-
this.listeners.delete(listener)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
getMaxSeq(): number {
|
|
83
|
-
return this.primary.getMaxSeq()
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
close(): void {
|
|
87
|
-
this.listeners.clear()
|
|
88
|
-
this.callClose(this.primary)
|
|
89
|
-
for (const relay of this.relays) this.callClose(relay)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private callPrimary(event: E): LeucoLoggerRecord<E> | Error {
|
|
93
|
-
try {
|
|
94
|
-
return this.primary.insert({ ts: this.now(), event })
|
|
95
|
-
} catch (e) {
|
|
96
|
-
return e instanceof Error ? e : new Error(String(e))
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private fanOutToRelays(record: LeucoLoggerRecord<E>): void {
|
|
101
|
-
for (const relay of this.relays) {
|
|
102
|
-
const error = this.callRelay(relay, record)
|
|
103
|
-
if (!error) continue
|
|
104
|
-
if (this.onSinkError) this.onSinkError(error, record, relay)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
private callRelay(relay: LeucoLoggerSink<E>, record: LeucoLoggerRecord<E>): Error | null {
|
|
109
|
-
try {
|
|
110
|
-
const outcome = relay.write(record)
|
|
111
|
-
return outcome instanceof Error ? outcome : null
|
|
112
|
-
} catch (e) {
|
|
113
|
-
return e instanceof Error ? e : new Error(String(e))
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private fanOutToListeners(record: LeucoLoggerRecord<E>): void {
|
|
118
|
-
for (const listener of this.listeners) {
|
|
119
|
-
try {
|
|
120
|
-
listener(record)
|
|
121
|
-
} catch {
|
|
122
|
-
// a faulty subscriber must not derail emission for everyone else
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
private callClose(sink: { close?(): void }): void {
|
|
128
|
-
if (!sink.close) return
|
|
129
|
-
try {
|
|
130
|
-
sink.close()
|
|
131
|
-
} catch {
|
|
132
|
-
// close failures are best-effort by definition
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|