Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"bun": "./src/filesystem/fff.bun.ts",
"node": "./src/filesystem/fff.node.ts",
"default": "./src/filesystem/fff.bun.ts"
},
"#usage-sidecar": {
"bun": "./src/usage/sidecar-store.bun.ts",
"node": "./src/usage/sidecar-store.node.ts",
"default": "./src/usage/sidecar-store.bun.ts"
}
},
"devDependencies": {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace"
import { Usage } from "../usage/usage"
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"

Expand Down Expand Up @@ -74,6 +75,8 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
}
}

let warnedAboutSidecar = false

function messageData(
info: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["info"],
): typeof MessageTable.$inferInsert.data {
Expand Down Expand Up @@ -269,6 +272,13 @@ const layer = Layer.effectDiscard(
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
.pipe(Effect.orDie)
// The usage sidecar mirrors the same row, minus the content: it is the file usage reporters read, and it
// outlives whatever stores the session itself (Usage.record swallows its own failures).
const mirrored = Usage.recordMessage({ id, sessionID, timeCreated: time_created, info: event.data.info })
if (mirrored === false && !warnedAboutSidecar) {
warnedAboutSidecar = true
yield* Effect.logWarning("usage sidecar disabled after a write failure", { error: Usage.lastError() })
}
}),
)
yield* events.project(SessionV1.Event.MessageRemoved, (event) =>
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/usage/sidecar-store.bun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Database } from "bun:sqlite"
import {
CREATE_MESSAGE_INDEX,
CREATE_MESSAGE_TABLE,
INSERT_PROJECT,
INSERT_SESSION,
UPSERT_MESSAGE,
type MessageRow,
type ProjectRow,
type SessionRow,
type SidecarStore,
} from "./sidecar-store"

/**
* `own` creates the message table; the fan-out target must never do that — the tables there belong to the other
* application, and creating them would mean inventing a schema it did not choose.
*/
export function open(filename: string, options: { own: boolean }): SidecarStore {
// `{ create }` alone is not a valid open mode in bun: the fan-out opens an existing file read-write, and only
// our own sidecar may bring a file into existence.
const database = options.own ? new Database(filename, { create: true }) : new Database(filename, { readwrite: true })
database.exec("PRAGMA busy_timeout = 5000")
if (options.own) {
database.exec("PRAGMA journal_mode = WAL")
database.exec("PRAGMA synchronous = NORMAL")
database.exec(CREATE_MESSAGE_TABLE)
database.exec(CREATE_MESSAGE_INDEX)
}
const message = database.query(UPSERT_MESSAGE)
const session = options.own ? undefined : database.query(INSERT_SESSION)
const project = options.own ? undefined : database.query(INSERT_PROJECT)
return {
message(row: MessageRow) {
message.run(row.id, row.sessionID, row.timeCreated, row.timeUpdated, row.data)
},
session(row: SessionRow) {
session?.run(row.id, row.projectID, row.slug, row.directory, row.title, row.version, row.timeCreated, row.timeUpdated)
},
project(row: ProjectRow) {
project?.run(row.id, row.worktree, row.timeCreated, row.timeUpdated)
},
close() {
database.close()
},
}
}
42 changes: 42 additions & 0 deletions packages/core/src/usage/sidecar-store.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { DatabaseSync } from "node:sqlite"
import {
CREATE_MESSAGE_INDEX,
CREATE_MESSAGE_TABLE,
INSERT_PROJECT,
INSERT_SESSION,
UPSERT_MESSAGE,
type MessageRow,
type ProjectRow,
type SessionRow,
type SidecarStore,
} from "./sidecar-store"

/** See the bun driver: `own` decides whether this process may create the schema. */
export function open(filename: string, options: { own: boolean }): SidecarStore {
// Only our own sidecar may create a file; the fan-out opens what is already there.
const database = new DatabaseSync(filename, { readOnly: false, ...(options.own ? {} : { open: true }) })
database.exec("PRAGMA busy_timeout = 5000")
if (options.own) {
database.exec("PRAGMA journal_mode = WAL")
database.exec("PRAGMA synchronous = NORMAL")
database.exec(CREATE_MESSAGE_TABLE)
database.exec(CREATE_MESSAGE_INDEX)
}
const message = database.prepare(UPSERT_MESSAGE)
const session = options.own ? undefined : database.prepare(INSERT_SESSION)
const project = options.own ? undefined : database.prepare(INSERT_PROJECT)
return {
message(row: MessageRow) {
message.run(row.id, row.sessionID, row.timeCreated, row.timeUpdated, row.data)
},
session(row: SessionRow) {
session?.run(row.id, row.projectID, row.slug, row.directory, row.title, row.version, row.timeCreated, row.timeUpdated)
},
project(row: ProjectRow) {
project?.run(row.id, row.worktree, row.timeCreated, row.timeUpdated)
},
close() {
database.close()
},
}
}
70 changes: 70 additions & 0 deletions packages/core/src/usage/sidecar-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* The shape both platform drivers implement. Two targets share it: our own sidecar, and the fan-out into
* OpenCode's database, which needs the session and project rows its foreign keys demand.
*/
export interface SidecarStore {
message(row: MessageRow): void
session(row: SessionRow): void
project(row: ProjectRow): void
close(): void
}

/** One usage row, in the column layout ccusage's OpenCode reader expects. */
export interface MessageRow {
readonly id: string
readonly sessionID: string
readonly timeCreated: number
readonly timeUpdated: number
readonly data: string
}

/** The session a usage row hangs off. OpenCode's `message.session_id` is a foreign key into this table. */
export interface SessionRow {
readonly id: string
readonly projectID: string
readonly slug: string
readonly directory: string
readonly title: string
readonly version: string
readonly timeCreated: number
readonly timeUpdated: number
}

/** The project a session hangs off — the other end of OpenCode's foreign-key chain. */
export interface ProjectRow {
readonly id: string
readonly worktree: string
readonly timeCreated: number
readonly timeUpdated: number
}

/**
* Our own sidecar carries the message table alone: nothing reads it through a foreign key, and a usage reporter
* only needs these four columns.
*/
export const CREATE_MESSAGE_TABLE = `CREATE TABLE IF NOT EXISTS message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
)`

export const CREATE_MESSAGE_INDEX = `CREATE INDEX IF NOT EXISTS message_time_created ON message (time_created)`

export const UPSERT_MESSAGE = `INSERT INTO message (id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, time_updated = excluded.time_updated`

/**
* The session and project rows exist for the fan-out target only, so they are written with the columns OpenCode's
* schema makes NOT NULL and nothing else — every other column there either allows null or carries a default.
* `INSERT OR IGNORE`: the row is written once and never overwrites what the other application owns.
*/
export const INSERT_SESSION = `INSERT OR IGNORE INTO session
(id, project_id, slug, directory, title, version, time_created, time_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`

export const INSERT_PROJECT = `INSERT OR IGNORE INTO project
(id, worktree, time_created, time_updated, sandboxes)
VALUES (?, ?, ?, ?, '[]')`
Loading
Loading