@catalyst-cloud/schema 0.1.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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@catalyst-cloud/schema",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Typed Drizzle schema = single source of truth for the per-tenant Mirror DO SQLite store (CTC-13 / ADR-0002). Shared by the mirror Worker, the host-sync replica, and the browser OPFS replica.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/coalesce-labs/catalyst-cloud.git",
10
+ "directory": "packages/schema"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "scripts": {
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "vitest run",
24
+ "test:coverage": "vitest run --coverage",
25
+ "generate": "drizzle-kit generate",
26
+ "build:bundle": "bun scripts/build-bundle.ts && prettier --write src/migrations.generated.ts"
27
+ },
28
+ "dependencies": {
29
+ "drizzle-orm": "0.44.7"
30
+ },
31
+ "devDependencies": {
32
+ "@catalyst-cloud/typescript-config": "workspace:*",
33
+ "@types/node": "^25.9.3",
34
+ "drizzle-kit": "^0.31.0",
35
+ "prettier": "^3.8.4",
36
+ "typescript": "^5.6.3",
37
+ "vitest": "^2.1.8"
38
+ }
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,116 @@
1
+ // @catalyst-cloud/schema — the typed Drizzle SSOT for the Mirror DO store (CTC-13 / ADR-0002).
2
+ //
3
+ // Re-exports every table plus two consumption surfaces:
4
+ // • `mirrorSchema` — all tables, to pass as `drizzle(storage, { schema: mirrorSchema })` in the DO
5
+ // (and `drizzle(bunSqlite, …)` in the host-sync replica, `drizzle(sqliteProxy, …)` in the browser).
6
+ // • `MIRROR_TABLE_META` — per-ENTITY `{ pk, softDelete }` DERIVED from the schema via getTableConfig.
7
+ // This is what packages/replicate's applyDelta consumes to fold a change_log delta into a replica
8
+ // — replacing the hand-maintained `TABLES` map in apps/host-sync/src/apply.ts (the ADR-0002 "4th
9
+ // hand-copy" this schema deletes). Because it is derived, it can never drift from the table defs.
10
+
11
+ import { getTableConfig, type SQLiteTable } from "drizzle-orm/sqlite-core";
12
+ import {
13
+ issues,
14
+ labels,
15
+ users,
16
+ issue_labels,
17
+ relations,
18
+ issue_history,
19
+ projects,
20
+ cycles,
21
+ initiatives,
22
+ project_initiatives,
23
+ comments,
24
+ pull_requests,
25
+ check_runs,
26
+ commit_statuses,
27
+ reviews,
28
+ processed_events,
29
+ change_log,
30
+ sync_cursors,
31
+ } from "./mirror";
32
+
33
+ export * from "./mirror";
34
+
35
+ // The committed cross-surface migration bundle + the shared runner (CTC-41). Re-exported here so
36
+ // every consumer imports from the one package entrypoint: `applyMigrations(adapter, MIRROR_MIGRATIONS)`.
37
+ export { MIRROR_MIGRATIONS } from "./migrations.generated";
38
+ export {
39
+ applyMigrations,
40
+ type MigrationDb,
41
+ type MigrationBundle,
42
+ type MigrationJournalEntry,
43
+ } from "./migrate";
44
+
45
+ /** Every table in the Mirror DO store — pass as `drizzle(storage, { schema: mirrorSchema })`. */
46
+ export const mirrorSchema = {
47
+ issues,
48
+ labels,
49
+ users,
50
+ issue_labels,
51
+ relations,
52
+ issue_history,
53
+ projects,
54
+ cycles,
55
+ initiatives,
56
+ project_initiatives,
57
+ comments,
58
+ pull_requests,
59
+ check_runs,
60
+ commit_statuses,
61
+ reviews,
62
+ processed_events,
63
+ change_log,
64
+ sync_cursors,
65
+ } as const;
66
+
67
+ /**
68
+ * The domain-entity tables that flow over the change-feed (the keys are the wire `entity` names). The
69
+ * infra tables — processed_events / change_log / sync_cursors — are deliberately excluded: they never
70
+ * ride a delta.
71
+ */
72
+ export const mirrorEntityTables = {
73
+ issues,
74
+ labels,
75
+ users,
76
+ issue_labels,
77
+ relations,
78
+ issue_history,
79
+ projects,
80
+ cycles,
81
+ initiatives,
82
+ project_initiatives,
83
+ comments,
84
+ pull_requests,
85
+ check_runs,
86
+ commit_statuses,
87
+ reviews,
88
+ } as const;
89
+
90
+ /** The wire `entity` name of a change-feed-carried table (keys of mirrorEntityTables). */
91
+ export type MirrorEntityName = keyof typeof mirrorEntityTables;
92
+
93
+ /** Per-table replication metadata — the PK columns and whether deletes are soft (removed_at) vs hard. */
94
+ export interface TableMeta {
95
+ /** PK column SQL names, in declared order (composite PKs yield more than one). */
96
+ pk: string[];
97
+ /** True iff the table carries a soft-delete `removed_at` column (delete sets it; else hard-delete). */
98
+ softDelete: boolean;
99
+ }
100
+
101
+ /** Derive a table's PK columns + soft-delete posture from the Drizzle definition (no hand-maintenance). */
102
+ export function tableMeta(table: SQLiteTable): TableMeta {
103
+ const cfg = getTableConfig(table);
104
+ // A single-column PK (`.primaryKey()`) marks the column `.primary`; a composite PK
105
+ // (`primaryKey({ columns })`) instead lands in cfg.primaryKeys. Cover both.
106
+ const singlePk = cfg.columns.filter((c) => c.primary).map((c) => c.name);
107
+ const compositePk = cfg.primaryKeys.flatMap((pk) => pk.columns.map((c) => c.name));
108
+ const pk = singlePk.length > 0 ? singlePk : compositePk;
109
+ const softDelete = cfg.columns.some((c) => c.name === "removed_at");
110
+ return { pk, softDelete };
111
+ }
112
+
113
+ /** Schema-derived replication metadata for every change-feed entity, keyed by wire `entity` name. */
114
+ export const MIRROR_TABLE_META: Record<MirrorEntityName, TableMeta> = Object.fromEntries(
115
+ Object.entries(mirrorEntityTables).map(([name, table]) => [name, tableMeta(table)]),
116
+ ) as Record<MirrorEntityName, TableMeta>;
package/src/migrate.ts ADDED
@@ -0,0 +1,147 @@
1
+ // migrate.ts — the ONE shared, runtime-agnostic migration runner for every SQLite read surface
2
+ // (CTC-41). Pure and synchronous: it takes a thin `MigrationDb` adapter over a native SQL handle and
3
+ // applies the committed bundle (src/migrations.generated.ts) idempotently.
4
+ //
5
+ // Each surface provides a ~3-line adapter over its own handle:
6
+ // • DO: { exec: s => ctx.storage.sql.exec(s), query: s => ctx.storage.sql.exec(s).toArray() }
7
+ // • bun daemon: { exec: s => db.exec(s), query: s => db.query(s).all() }
8
+ // • browser: same shape over sqlocal / sqlite-wasm
9
+ //
10
+ // Idempotency: a `__drizzle_migrations` ledger records each applied journal tag. On every boot we read
11
+ // the applied tags and run only the journal entries (in `idx` order) not yet recorded — so a fresh DB
12
+ // gets the full set, a live DB (already at the baseline schema) records the baseline tag as a no-op
13
+ // (the baseline is `CREATE TABLE IF NOT EXISTS`), and a re-run applies nothing.
14
+ //
15
+ // CONTRACT for migration authors (the ledger key is the journal TAG, not drizzle-kit's content hash):
16
+ // 1. The BASELINE (0000) MUST be `CREATE TABLE/INDEX IF NOT EXISTS` so it no-ops on an already-live
17
+ // DB that has the tables but no ledger row (existing DOs/daemons adopt the baseline cleanly).
18
+ // 2. Every later migration runs EXACTLY ONCE per DB (it's recorded by tag the first time it runs), so
19
+ // a plain `ALTER TABLE ADD COLUMN` is correct as-is. But because the run-once guard is the only
20
+ // safety net, a migration that could be re-attempted on a partially-migrated DB should still be
21
+ // written defensively. Keep each migration small and independently replay-safe.
22
+ // 3. Do NOT pre-seed a drizzle-kit-NATIVE `__drizzle_migrations` (which stores SHA256 hashes) — this
23
+ // runner keys on tags, so native-hash rows would be unrecognized and the baseline would re-run
24
+ // (safe only while the baseline stays IF NOT EXISTS). This runner owns the ledger end to end.
25
+
26
+ import { MIRROR_MIGRATIONS } from "./migrations.generated";
27
+
28
+ /** The minimal native SQLite surface the runner needs — one exec + one row-returning query. */
29
+ export interface MigrationDb {
30
+ /** Execute one or more SQL statements for side effect (no rows returned). */
31
+ exec(sql: string): void;
32
+ /** Run a query and return its rows as plain objects (column name → value). */
33
+ query(sql: string): Array<Record<string, unknown>>;
34
+ }
35
+
36
+ /** A journal entry as emitted by drizzle-kit into meta/_journal.json (and our committed bundle). */
37
+ export interface MigrationJournalEntry {
38
+ idx: number;
39
+ version: string;
40
+ when: number;
41
+ tag: string;
42
+ breakpoints: boolean;
43
+ }
44
+
45
+ /** The committed migration bundle shape: the drizzle journal + tag → SQL string map. */
46
+ export interface MigrationBundle {
47
+ journal: {
48
+ version: string;
49
+ dialect: string;
50
+ entries: readonly MigrationJournalEntry[];
51
+ };
52
+ migrations: Readonly<Record<string, string>>;
53
+ }
54
+
55
+ /** The breakpoint marker drizzle-kit inserts between statements within one migration file. */
56
+ const STATEMENT_BREAKPOINT = "--> statement-breakpoint";
57
+
58
+ const LEDGER_DDL =
59
+ "CREATE TABLE IF NOT EXISTS __drizzle_migrations (" +
60
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)";
61
+
62
+ /**
63
+ * Apply every not-yet-applied migration in `bundle` to `db`, in journal `idx` order. Records each
64
+ * applied tag in `__drizzle_migrations` (the tag is the `hash` value). Pure, synchronous, and
65
+ * runtime-agnostic — every SQLite surface drives it through a thin `MigrationDb` adapter.
66
+ *
67
+ * @param now Optional timestamp (epoch ms) recorded as `created_at`. Defaults to `Date.now()` AT CALL
68
+ * TIME (never at import time), so the module can be imported into a Worker without a clock read.
69
+ * @returns the tags applied during THIS call (empty if the DB was already up to date).
70
+ */
71
+ export function applyMigrations(
72
+ db: MigrationDb,
73
+ bundle: MigrationBundle = MIRROR_MIGRATIONS,
74
+ now: number = Date.now(),
75
+ ): { appliedTags: string[] } {
76
+ // 1) Ensure the ledger exists.
77
+ db.exec(LEDGER_DDL);
78
+
79
+ // 2) Read already-applied tags (stored in the `hash` column).
80
+ const appliedRows = db.query("SELECT hash FROM __drizzle_migrations");
81
+ const applied = new Set<string>(
82
+ appliedRows.map((r) => String(r.hash)).filter((h) => h.length > 0),
83
+ );
84
+
85
+ // 3) Apply each journal entry not yet recorded, in idx order.
86
+ const entries = [...bundle.journal.entries].sort((a, b) => a.idx - b.idx);
87
+ const appliedTags: string[] = [];
88
+
89
+ for (const entry of entries) {
90
+ if (applied.has(entry.tag)) continue;
91
+
92
+ const sql = bundle.migrations[entry.tag];
93
+ if (sql === undefined) {
94
+ throw new Error(`applyMigrations: journal tag "${entry.tag}" has no SQL in the bundle`);
95
+ }
96
+
97
+ // Split on the breakpoint marker and exec each non-empty statement individually. (The DO's
98
+ // ctx.storage.sql.exec and bun:sqlite both accept multi-statement strings, but splitting keeps
99
+ // the runner portable to surfaces that don't, and matches drizzle-kit's own migrator.)
100
+ for (const raw of sql.split(STATEMENT_BREAKPOINT)) {
101
+ const stmt = raw.trim();
102
+ if (stmt.length === 0) continue;
103
+ execReplaySafe(db, stmt);
104
+ }
105
+
106
+ // Record the tag (as the hash). A parameterless INSERT keeps the adapter surface to exec-only.
107
+ db.exec(
108
+ `INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('${escapeSqlLiteral(entry.tag)}', ${now})`,
109
+ );
110
+ appliedTags.push(entry.tag);
111
+ }
112
+
113
+ return { appliedTags };
114
+ }
115
+
116
+ /**
117
+ * Execute one migration statement, tolerating the three errors that make an additive migration
118
+ * non-replay-safe when the run-once ledger guard is bypassed — e.g. a crash between a statement
119
+ * succeeding and the tag being recorded, which reruns the whole migration on the next boot:
120
+ * • "duplicate column name" on `ALTER TABLE ... ADD COLUMN` — the column is already present.
121
+ * • "index ... already exists" on `CREATE INDEX` — the index is already present (drizzle-kit emits
122
+ * bare `CREATE INDEX`, not `IF NOT EXISTS`, so the runner makes the replay idempotent here).
123
+ * • "table ... already exists" on `CREATE TABLE` — a NEW entity table added in a LATER migration
124
+ * (CTC-USERS): drizzle-kit emits a bare `CREATE TABLE` (no IF NOT EXISTS), so a live DB whose
125
+ * tables were already created from a newer `schema.sql` (which carries the new table) would throw
126
+ * on adoption. Swallowing the duplicate-table makes the fresh CREATE TABLE adopt as a no-op,
127
+ * exactly mirroring the duplicate-column / duplicate-index cases below.
128
+ * All three are exactly the defensive replay-safety the CONTRACT (point #2) calls for. A live DB whose
129
+ * tables were created from a newer `schema.sql` likewise adopts these additive tags as no-ops rather
130
+ * than throwing. Only these idempotent cases are swallowed; any other SQL error still propagates.
131
+ */
132
+ function execReplaySafe(db: MigrationDb, stmt: string): void {
133
+ try {
134
+ db.exec(stmt);
135
+ } catch (err) {
136
+ const msg = err instanceof Error ? err.message : String(err);
137
+ if (/duplicate column name/i.test(msg) && /\bADD\b/i.test(stmt)) return;
138
+ if (/already exists/i.test(msg) && /\bCREATE\s+INDEX\b/i.test(stmt)) return;
139
+ if (/already exists/i.test(msg) && /\bCREATE\s+TABLE\b/i.test(stmt)) return;
140
+ throw err;
141
+ }
142
+ }
143
+
144
+ /** Escape a string for use inside a single-quoted SQL literal (double any embedded single quote). */
145
+ function escapeSqlLiteral(value: string): string {
146
+ return value.replace(/'/g, "''");
147
+ }
@@ -0,0 +1,90 @@
1
+ // AUTO-GENERATED by scripts/build-bundle.ts — DO NOT EDIT BY HAND.
2
+ //
3
+ // The committed Mirror migration bundle (CTC-41): the drizzle-kit journal + every migration SQL as a
4
+ // string literal (NO `.sql`-as-import), so the SAME module imports cleanly in the DO (Workers), the
5
+ // bun daemon, node tests, and the browser. Regenerate via `bun run --cwd packages/schema build:bundle`
6
+ // (or the root `migration:generate`). The CI drift gate (`migration:check`) fails if this file is
7
+ // stale vs drizzle/.
8
+
9
+ export const MIRROR_MIGRATIONS = {
10
+ journal: {
11
+ version: "7",
12
+ dialect: "sqlite",
13
+ entries: [
14
+ {
15
+ idx: 0,
16
+ version: "6",
17
+ when: 1782051267561,
18
+ tag: "0000_baseline",
19
+ breakpoints: true,
20
+ },
21
+ {
22
+ idx: 1,
23
+ version: "6",
24
+ when: 1782110588498,
25
+ tag: "0001_short_glorian",
26
+ breakpoints: true,
27
+ },
28
+ {
29
+ idx: 2,
30
+ version: "6",
31
+ when: 1782179798312,
32
+ tag: "0002_many_captain_marvel",
33
+ breakpoints: true,
34
+ },
35
+ {
36
+ idx: 3,
37
+ version: "6",
38
+ when: 1782182056315,
39
+ tag: "0003_brainy_silver_samurai",
40
+ breakpoints: true,
41
+ },
42
+ {
43
+ idx: 4,
44
+ version: "6",
45
+ when: 1782183082087,
46
+ tag: "0004_fat_mindworm",
47
+ breakpoints: true,
48
+ },
49
+ {
50
+ idx: 5,
51
+ version: "6",
52
+ when: 1782184123941,
53
+ tag: "0005_blue_black_queen",
54
+ breakpoints: true,
55
+ },
56
+ {
57
+ idx: 6,
58
+ version: "6",
59
+ when: 1782186590417,
60
+ tag: "0006_lively_vector",
61
+ breakpoints: true,
62
+ },
63
+ {
64
+ idx: 7,
65
+ version: "6",
66
+ when: 1782229820186,
67
+ tag: "0007_rapid_logan",
68
+ breakpoints: true,
69
+ },
70
+ ],
71
+ },
72
+ migrations: {
73
+ "0000_baseline":
74
+ "CREATE TABLE IF NOT EXISTS `change_log` (\n\t`seq` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`entity` text,\n\t`entity_id` text,\n\t`op` text,\n\t`payload` text,\n\t`ts` integer\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `check_runs` (\n\t`repo_id` text NOT NULL,\n\t`check_run_id` text PRIMARY KEY NOT NULL,\n\t`head_sha` text,\n\t`name` text,\n\t`status` text,\n\t`conclusion` text,\n\t`check_suite_id` text,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS `idx_check_runs_sha` ON `check_runs` (`head_sha`);--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `comments` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`issue_id` text,\n\t`body` text,\n\t`updated_at` integer,\n\t`removed_at` integer\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `commit_statuses` (\n\t`repo_id` text NOT NULL,\n\t`sha` text NOT NULL,\n\t`context` text NOT NULL,\n\t`state` text,\n\t`target_url` text,\n\t`updated_at` integer,\n\tPRIMARY KEY(`repo_id`, `sha`, `context`)\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `cycles` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`number` integer,\n\t`starts_at` integer,\n\t`ends_at` integer,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `issue_labels` (\n\t`issue_id` text NOT NULL,\n\t`label_id` text NOT NULL,\n\tPRIMARY KEY(`issue_id`, `label_id`)\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `issues` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`identifier` text,\n\t`title` text,\n\t`state` text,\n\t`assignee` text,\n\t`priority` integer,\n\t`estimate` real,\n\t`project_id` text,\n\t`cycle_id` text,\n\t`team_id` text,\n\t`updated_at` integer,\n\t`archived_at` integer,\n\t`removed_at` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS `idx_issues_team` ON `issues` (`team_id`);--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `labels` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`name` text,\n\t`updated_at` integer,\n\t`removed_at` integer\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `processed_events` (\n\t`delivery_id` text PRIMARY KEY NOT NULL,\n\t`source` text,\n\t`event_type` text,\n\t`received_at` integer\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `projects` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`name` text,\n\t`state` text,\n\t`updated_at` integer,\n\t`removed_at` integer\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `pull_requests` (\n\t`repo_id` text NOT NULL,\n\t`number` integer NOT NULL,\n\t`node_id` text,\n\t`state` text,\n\t`draft` integer,\n\t`merged` integer,\n\t`merged_at` integer,\n\t`head_sha` text,\n\t`base_ref` text,\n\t`mergeable` integer,\n\t`mergeable_state` text,\n\t`auto_merge` integer,\n\t`updated_at` integer,\n\t`synced_at` integer,\n\tPRIMARY KEY(`repo_id`, `number`)\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `relations` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`type` text,\n\t`issue_identifier` text,\n\t`related_identifier` text,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS `idx_relations_issue` ON `relations` (`issue_identifier`);--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `reviews` (\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`review_id` text PRIMARY KEY NOT NULL,\n\t`user_id` text,\n\t`state` text,\n\t`submitted_at` integer\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS `sync_cursors` (\n\t`source` text NOT NULL,\n\t`entity` text NOT NULL,\n\t`cursor` text,\n\t`last_run_at` integer,\n\tPRIMARY KEY(`source`, `entity`)\n);\n",
75
+ "0001_short_glorian":
76
+ "ALTER TABLE `comments` ADD `author_id` text;--> statement-breakpoint\nALTER TABLE `comments` ADD `author_name` text;--> statement-breakpoint\nALTER TABLE `comments` ADD `author_avatar_url` text;--> statement-breakpoint\nALTER TABLE `comments` ADD `is_bot` integer;--> statement-breakpoint\nALTER TABLE `comments` ADD `parent_id` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `description` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `delegate_id` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `delegate_name` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `bot_actor_name` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `bot_actor_type` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `bot_actor_sub_type` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `parent_id` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `parent_identifier` text;--> statement-breakpoint\nALTER TABLE `labels` ADD `color` text;",
77
+ "0002_many_captain_marvel":
78
+ "CREATE INDEX `idx_comments_issue` ON `comments` (`issue_id`,`removed_at`,`updated_at`);--> statement-breakpoint\nCREATE INDEX `idx_issues_updated` ON `issues` (`removed_at`,`updated_at`);--> statement-breakpoint\nCREATE INDEX `idx_issues_identifier` ON `issues` (`identifier`);--> statement-breakpoint\nCREATE INDEX `idx_pulls_updated` ON `pull_requests` (`updated_at`);",
79
+ "0003_brainy_silver_samurai":
80
+ "ALTER TABLE `pull_requests` ADD `title` text;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `body` text;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `author_login` text;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `author_avatar_url` text;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `created_at` integer;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `closed_at` integer;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `comment_count` integer;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `milestone_title` text;",
81
+ "0004_fat_mindworm":
82
+ "ALTER TABLE `comments` ADD `created_at` integer;--> statement-breakpoint\nALTER TABLE `issues` ADD `started_at` integer;--> statement-breakpoint\nALTER TABLE `issues` ADD `completed_at` integer;--> statement-breakpoint\nALTER TABLE `issues` ADD `canceled_at` integer;--> statement-breakpoint\nALTER TABLE `issues` ADD `created_at` integer;--> statement-breakpoint\nALTER TABLE `issues` ADD `due_date` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `snoozed_until_at` integer;--> statement-breakpoint\nALTER TABLE `issues` ADD `url` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `priority_label` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `sort_order` real;--> statement-breakpoint\nALTER TABLE `issues` ADD `branch_name` text;--> statement-breakpoint\nCREATE INDEX `idx_issues_sort_order` ON `issues` (`sort_order`);",
83
+ "0005_blue_black_queen":
84
+ "CREATE TABLE `users` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`name` text,\n\t`display_name` text,\n\t`avatar_url` text,\n\t`type` text,\n\t`source` text,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nALTER TABLE `issues` ADD `assignee_id` text;",
85
+ "0006_lively_vector":
86
+ "CREATE TABLE `initiatives` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`name` text,\n\t`description` text,\n\t`status` text,\n\t`target_date` text,\n\t`owner_id` text,\n\t`updated_at` integer\n);\n--> statement-breakpoint\nCREATE TABLE `project_initiatives` (\n\t`project_id` text NOT NULL,\n\t`initiative_id` text NOT NULL,\n\tPRIMARY KEY(`project_id`, `initiative_id`)\n);\n--> statement-breakpoint\nALTER TABLE `cycles` ADD `name` text;--> statement-breakpoint\nALTER TABLE `cycles` ADD `progress` real;--> statement-breakpoint\nALTER TABLE `cycles` ADD `completed_scope` real;--> statement-breakpoint\nALTER TABLE `cycles` ADD `scope` real;--> statement-breakpoint\nALTER TABLE `cycles` ADD `completed_issue_count` integer;--> statement-breakpoint\nALTER TABLE `cycles` ADD `issue_count` integer;--> statement-breakpoint\nALTER TABLE `projects` ADD `description` text;--> statement-breakpoint\nALTER TABLE `projects` ADD `lead_id` text;--> statement-breakpoint\nALTER TABLE `projects` ADD `target_date` text;--> statement-breakpoint\nALTER TABLE `projects` ADD `start_date` text;--> statement-breakpoint\nALTER TABLE `projects` ADD `progress` real;--> statement-breakpoint\nALTER TABLE `projects` ADD `health` text;--> statement-breakpoint\nALTER TABLE `projects` ADD `icon` text;--> statement-breakpoint\nALTER TABLE `projects` ADD `color` text;",
87
+ "0007_rapid_logan":
88
+ "CREATE TABLE `issue_history` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`issue_id` text NOT NULL,\n\t`actor_id` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`from_state` text,\n\t`to_state` text,\n\t`from_assignee_id` text,\n\t`to_assignee_id` text,\n\t`from_priority` integer,\n\t`to_priority` integer,\n\t`from_estimate` real,\n\t`to_estimate` real,\n\t`from_title` text,\n\t`to_title` text,\n\t`from_cycle_id` text,\n\t`to_cycle_id` text,\n\t`from_project_id` text,\n\t`to_project_id` text,\n\t`from_parent_id` text,\n\t`to_parent_id` text,\n\t`from_team_id` text,\n\t`to_team_id` text,\n\t`from_due_date` text,\n\t`to_due_date` text,\n\t`added_label_ids` text,\n\t`removed_label_ids` text,\n\t`updated_description` integer,\n\t`archived` integer,\n\t`auto_archived` integer,\n\t`auto_closed` integer,\n\t`trashed` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_issue_history_issue_created` ON `issue_history` (`issue_id`,`created_at`);",
89
+ },
90
+ } as const;
package/src/mirror.ts ADDED
@@ -0,0 +1,368 @@
1
+ // The per-tenant Mirror Durable Object SQLite store, as typed Drizzle tables — the SINGLE SOURCE OF
2
+ // TRUTH for the mirror schema (CTC-13 / ADR-0002). One DO per tenant, so the DO instance IS the
3
+ // partition: there is NO tenant_id column. This mirrors apps/mirror/src/do/schema.sql column-for-
4
+ // column; the fidelity test (test/schema.test.ts) pins them together so a drift is a test failure.
5
+ //
6
+ // COLUMN NAMING: the TS keys are snake_case and identical to the SQL column names. That is deliberate
7
+ // — the change-feed wire rows, the /snapshot rows, and the read-model select() output are all
8
+ // snake_case (apps/mirror/src/do/{changefeed,views}.ts), so a Drizzle select() over these tables
9
+ // yields the SAME row shape the wire contract already uses, with no DTO/remap layer (the ADR-0002
10
+ // "no mapping" payoff).
11
+ //
12
+ // LOAD-BEARING FOOTGUNS encoded here as types (so a wrong reference fails `tsc`, not a prod 500):
13
+ // • Soft-delete is PER-TABLE: only issues / labels / projects / comments carry `removed_at`. The
14
+ // other tables have none — filtering them on it throws "no such column" on the real DO.
15
+ // • `relations` join on issues.identifier (issue_identifier), NOT issues.id.
16
+ // • `check_runs` join a PR by head_sha, NOT PR number.
17
+ // • PR mergeable / mergeable_state are nullable until a reconcile resolves them.
18
+
19
+ import { sqliteTable, text, integer, real, primaryKey, index } from "drizzle-orm/sqlite-core";
20
+
21
+ // ── Linear ───────────────────────────────────────────────────────────────────────────────────────
22
+
23
+ export const issues = sqliteTable(
24
+ "issues",
25
+ {
26
+ id: text("id").primaryKey(),
27
+ identifier: text("identifier"),
28
+ title: text("title"),
29
+ description: text("description"),
30
+ state: text("state"),
31
+ assignee: text("assignee"),
32
+ // CTC-USERS: the assignee's user id (FK → users.id), so the row JOINs to a name + avatar. The
33
+ // legacy `assignee` name string is KEPT for back-compat (the SSE list row still carries it).
34
+ assignee_id: text("assignee_id"),
35
+ priority: integer("priority"),
36
+ estimate: real("estimate"),
37
+ project_id: text("project_id"),
38
+ cycle_id: text("cycle_id"),
39
+ team_id: text("team_id"),
40
+ delegate_id: text("delegate_id"),
41
+ delegate_name: text("delegate_name"),
42
+ bot_actor_name: text("bot_actor_name"),
43
+ bot_actor_type: text("bot_actor_type"),
44
+ bot_actor_sub_type: text("bot_actor_sub_type"),
45
+ parent_id: text("parent_id"),
46
+ parent_identifier: text("parent_identifier"),
47
+ // Ticket field enrichment (CTC-TICKET-FIELDS): lifecycle timestamps + due date + snooze + url +
48
+ // human priority label + manual drag-rank + suggested branch. The DateTime fields are ms epochs
49
+ // (isoToMs, same as updated_at/archived_at). due_date is the ONE TEXT date — Linear `dueDate` is a
50
+ // TimelessDate ("YYYY-MM-DD", no time), stored AS-IS (NEVER through isoToMs — that's the trap).
51
+ started_at: integer("started_at"),
52
+ completed_at: integer("completed_at"),
53
+ canceled_at: integer("canceled_at"),
54
+ created_at: integer("created_at"),
55
+ due_date: text("due_date"),
56
+ snoozed_until_at: integer("snoozed_until_at"),
57
+ url: text("url"),
58
+ priority_label: text("priority_label"),
59
+ sort_order: real("sort_order"),
60
+ branch_name: text("branch_name"),
61
+ updated_at: integer("updated_at"),
62
+ archived_at: integer("archived_at"),
63
+ removed_at: integer("removed_at"),
64
+ raw: text("raw"),
65
+ },
66
+ (t) => [
67
+ index("idx_issues_team").on(t.team_id),
68
+ // CTC-59: the list query — `WHERE removed_at IS NULL ORDER BY updated_at DESC` (buildIssuesView).
69
+ // The composite lets SQLite seek the live rows then walk updated_at in order (no temp b-tree).
70
+ index("idx_issues_updated").on(t.removed_at, t.updated_at),
71
+ // CTC-59: the detail lookup — `WHERE identifier = ?` (buildIssueDetail).
72
+ index("idx_issues_identifier").on(t.identifier),
73
+ // CTC-TICKET-FIELDS: the manual-rank sort path (a future ORDER BY sort_order). Index lives in the
74
+ // Drizzle SSOT ONLY (NOT schema.sql) — migrate.ts swallows a duplicate CREATE INDEX on replay
75
+ // (CTC-59), so the generated `CREATE INDEX` is replay-safe; schema.sql would break the adoption test.
76
+ index("idx_issues_sort_order").on(t.sort_order),
77
+ ],
78
+ );
79
+
80
+ export const labels = sqliteTable("labels", {
81
+ id: text("id").primaryKey(),
82
+ name: text("name"),
83
+ color: text("color"),
84
+ updated_at: integer("updated_at"),
85
+ removed_at: integer("removed_at"),
86
+ });
87
+
88
+ // Teammate identities (CTC-USERS): resolves issue assignees + delegate AGENTS (and comment/PR
89
+ // authors/reviewers) to a name + avatar so the UI never renders a gray "Unknown" chip. AGENT
90
+ // identities are first-class: `type` is human | agent | bot | app. Populated three ways — a ROSTER
91
+ // bootstrap leg (Linear org users + GitHub repo collaborators), upsert-from-ingest (assignee /
92
+ // delegate / botActor / comment author / PR author), and (Linear has no User webhook) the reconcile.
93
+ //
94
+ // HARD-UPSERT, no soft delete: there is intentionally NO removed_at — a stale identity is overwritten
95
+ // in place, never tombstoned (an avatar/name that goes missing just stops being referenced). `id` is
96
+ // the source-native id: Linear's user node id, the bot actor id, or a `github:<login>` synthetic id.
97
+ export const users = sqliteTable("users", {
98
+ id: text("id").primaryKey(),
99
+ name: text("name"),
100
+ display_name: text("display_name"),
101
+ avatar_url: text("avatar_url"),
102
+ type: text("type"), // human | agent | bot | app
103
+ source: text("source"), // linear | github
104
+ updated_at: integer("updated_at"),
105
+ });
106
+
107
+ export const issue_labels = sqliteTable(
108
+ "issue_labels",
109
+ {
110
+ issue_id: text("issue_id").notNull(),
111
+ label_id: text("label_id").notNull(),
112
+ },
113
+ (t) => [primaryKey({ columns: [t.issue_id, t.label_id] })],
114
+ );
115
+
116
+ // FULL-poll-and-diff every reconcile pass (Linear fires NO issueRelations webhooks). Delete-absent is
117
+ // load-bearing — and there is intentionally NO removed_at: a stale edge is HARD-deleted, never soft.
118
+ export const relations = sqliteTable(
119
+ "relations",
120
+ {
121
+ id: text("id").primaryKey(),
122
+ type: text("type"), // blocks | duplicate | related
123
+ issue_identifier: text("issue_identifier"),
124
+ related_identifier: text("related_identifier"),
125
+ updated_at: integer("updated_at"),
126
+ },
127
+ (t) => [index("idx_relations_issue").on(t.issue_identifier)],
128
+ );
129
+
130
+ // Linear IssueHistory events (CTC-14) — the per-issue change timeline. Captured INLINE on the issues
131
+ // delta sweep (a nested history(last:100) connection), NOT a separate leg and NOT a webhook path. Each
132
+ // node is IMMUTABLE with a stable `id`; HARD upsert keyed on `id` (NO removed_at — a history row never
133
+ // changes and never vanishes). created_at is the timeline sort key; updated_at = created_at and drives
134
+ // the last-write-wins guard. from_state/to_state are workflow state NAMES (issues store state as a
135
+ // name — no states table). from_due_date/to_due_date are TimelessDate "YYYY-MM-DD" stored AS-IS as
136
+ // TEXT (the trap), never a timestamp. added_label_ids/removed_label_ids are JSON array strings; the
137
+ // boolean columns are the mirror's 0/1 ints. raw is the full node JSON (full fidelity / forward-compat).
138
+ export const issue_history = sqliteTable(
139
+ "issue_history",
140
+ {
141
+ id: text("id").primaryKey(),
142
+ issue_id: text("issue_id").notNull(), // FK → issues.id
143
+ actor_id: text("actor_id"), // FK → users.id; nullable (system events have no actor)
144
+ created_at: integer("created_at"), // event time (ms epoch) — the timeline sort key
145
+ updated_at: integer("updated_at"), // = created_at (immutable) — drives the upsert guard
146
+ from_state: text("from_state"),
147
+ to_state: text("to_state"), // workflow state NAMES (no states table)
148
+ from_assignee_id: text("from_assignee_id"),
149
+ to_assignee_id: text("to_assignee_id"),
150
+ from_priority: integer("from_priority"),
151
+ to_priority: integer("to_priority"),
152
+ from_estimate: real("from_estimate"), // match issues.estimate (real)
153
+ to_estimate: real("to_estimate"),
154
+ from_title: text("from_title"),
155
+ to_title: text("to_title"),
156
+ from_cycle_id: text("from_cycle_id"),
157
+ to_cycle_id: text("to_cycle_id"),
158
+ from_project_id: text("from_project_id"),
159
+ to_project_id: text("to_project_id"),
160
+ from_parent_id: text("from_parent_id"),
161
+ to_parent_id: text("to_parent_id"),
162
+ from_team_id: text("from_team_id"),
163
+ to_team_id: text("to_team_id"),
164
+ from_due_date: text("from_due_date"), // TimelessDate AS-IS (the trap) — NEVER through isoToMs
165
+ to_due_date: text("to_due_date"), // TimelessDate AS-IS (the trap) — NEVER through isoToMs
166
+ added_label_ids: text("added_label_ids"), // JSON array string
167
+ removed_label_ids: text("removed_label_ids"), // JSON array string
168
+ updated_description: integer("updated_description"), // 0/1 boolean
169
+ archived: integer("archived"), // 0/1
170
+ auto_archived: integer("auto_archived"), // 0/1
171
+ auto_closed: integer("auto_closed"), // 0/1
172
+ trashed: integer("trashed"), // 0/1
173
+ raw: text("raw"), // JSON.stringify(node) — full fidelity / forward-compat (like issues.raw)
174
+ },
175
+ // The timeline index lives in the Drizzle SSOT ONLY (NOT schema.sql) — migrate.ts makes the generated
176
+ // CREATE INDEX replay-safe (same as the issues read-path indexes); putting it in schema.sql would
177
+ // break the adoption test.
178
+ (t) => [index("idx_issue_history_issue_created").on(t.issue_id, t.created_at)],
179
+ );
180
+
181
+ // CTC-HIERARCHY enriches the project row with the lead (FK → users.id), description, lifecycle dates,
182
+ // progress, health, and presentation (icon/color). `target_date` / `start_date` are Linear
183
+ // TimelessDates ("YYYY-MM-DD") — stored AS-IS as TEXT, NEVER through isoToMs (the same trap as
184
+ // issues.due_date). `progress` is a Float (0..1).
185
+ export const projects = sqliteTable("projects", {
186
+ id: text("id").primaryKey(),
187
+ name: text("name"),
188
+ state: text("state"),
189
+ description: text("description"),
190
+ lead_id: text("lead_id"), // FK → users.id (CTC-HIERARCHY); upserted from the project lead.
191
+ target_date: text("target_date"), // TimelessDate "YYYY-MM-DD" — TEXT AS-IS (the trap), not a ts.
192
+ start_date: text("start_date"), // TimelessDate "YYYY-MM-DD" — TEXT AS-IS (the trap), not a ts.
193
+ progress: real("progress"),
194
+ health: text("health"), // onTrack | atRisk | offTrack (null until a project update reports it).
195
+ icon: text("icon"),
196
+ color: text("color"),
197
+ updated_at: integer("updated_at"),
198
+ removed_at: integer("removed_at"),
199
+ });
200
+
201
+ // CTC-HIERARCHY enriches the cycle row with its display name + progress + current scope/issue counts.
202
+ // Linear exposes scope/issue counts only as weekly history arrays (completedScopeHistory etc.); we
203
+ // store the CURRENT value (the last array element) as a scalar — not the whole history.
204
+ export const cycles = sqliteTable("cycles", {
205
+ id: text("id").primaryKey(),
206
+ number: integer("number"),
207
+ name: text("name"),
208
+ progress: real("progress"),
209
+ completed_scope: real("completed_scope"), // last(completedScopeHistory) — current completed points.
210
+ scope: real("scope"), // last(scopeHistory) — current total scope points.
211
+ completed_issue_count: integer("completed_issue_count"), // last(completedIssueCountHistory).
212
+ issue_count: integer("issue_count"), // last(issueCountHistory).
213
+ starts_at: integer("starts_at"),
214
+ ends_at: integer("ends_at"),
215
+ updated_at: integer("updated_at"),
216
+ });
217
+
218
+ // Linear initiatives (CTC-HIERARCHY) — the top of the HIERARCHY (initiative → project → cycle →
219
+ // issue). PK `id` (Linear node id). `status` is the InitiativeStatus enum string; `target_date` is a
220
+ // TimelessDate ("YYYY-MM-DD") stored AS-IS as TEXT (the trap). `owner_id` is the FK → users.id.
221
+ // HARD upsert — NO removed_at (a vanished initiative just stops being referenced by the join table).
222
+ export const initiatives = sqliteTable("initiatives", {
223
+ id: text("id").primaryKey(),
224
+ name: text("name"),
225
+ description: text("description"),
226
+ status: text("status"),
227
+ target_date: text("target_date"), // TimelessDate "YYYY-MM-DD" — TEXT AS-IS (the trap), not a ts.
228
+ owner_id: text("owner_id"), // FK → users.id (CTC-HIERARCHY); upserted from the initiative owner.
229
+ updated_at: integer("updated_at"),
230
+ });
231
+
232
+ // project↔initiative join (CTC-HIERARCHY) — Linear models this as many:many (a project belongs to
233
+ // many initiatives), so it's an edge table modeled on issue_labels: composite PK, no updated_at, full
234
+ // poll-and-diff per project leg in the reconcile (Linear fires no membership webhook for it).
235
+ export const project_initiatives = sqliteTable(
236
+ "project_initiatives",
237
+ {
238
+ project_id: text("project_id").notNull(),
239
+ initiative_id: text("initiative_id").notNull(),
240
+ },
241
+ (t) => [primaryKey({ columns: [t.project_id, t.initiative_id] })],
242
+ );
243
+
244
+ export const comments = sqliteTable(
245
+ "comments",
246
+ {
247
+ id: text("id").primaryKey(),
248
+ issue_id: text("issue_id"),
249
+ body: text("body"),
250
+ author_id: text("author_id"),
251
+ author_name: text("author_name"),
252
+ author_avatar_url: text("author_avatar_url"),
253
+ is_bot: integer("is_bot"),
254
+ parent_id: text("parent_id"),
255
+ // CTC-TICKET-FIELDS: when Linear created the comment (DateTime → ms, isoToMs).
256
+ created_at: integer("created_at"),
257
+ updated_at: integer("updated_at"),
258
+ removed_at: integer("removed_at"),
259
+ },
260
+ // CTC-59: the detail comments fetch — `WHERE issue_id = ? AND removed_at IS NULL ORDER BY updated_at`.
261
+ // Composite covers the filter AND the sort, so SQLite seeks instead of building a temp b-tree.
262
+ (t) => [index("idx_comments_issue").on(t.issue_id, t.removed_at, t.updated_at)],
263
+ );
264
+
265
+ // ── GitHub ───────────────────────────────────────────────────────────────────────────────────────
266
+
267
+ export const pull_requests = sqliteTable(
268
+ "pull_requests",
269
+ {
270
+ repo_id: text("repo_id").notNull(),
271
+ number: integer("number").notNull(),
272
+ node_id: text("node_id"),
273
+ // PR data tier 1 (CTC-PR-TIER1): high-value fields already riding the fetched PR list/object —
274
+ // ZERO extra GitHub API calls. `title` is the #1 complaint; body/author/dates/comments/milestone
275
+ // round out the card. All nullable (a pre-backfill row may lack them).
276
+ title: text("title"),
277
+ body: text("body"),
278
+ author_login: text("author_login"),
279
+ author_avatar_url: text("author_avatar_url"),
280
+ state: text("state"),
281
+ draft: integer("draft"),
282
+ merged: integer("merged"),
283
+ merged_at: integer("merged_at"),
284
+ head_sha: text("head_sha"),
285
+ base_ref: text("base_ref"),
286
+ mergeable: integer("mergeable"),
287
+ mergeable_state: text("mergeable_state"),
288
+ auto_merge: integer("auto_merge"),
289
+ // ms-epoch convention (same isoToMs-style parse as merged_at/updated_at). closed_at nullable.
290
+ created_at: integer("created_at"),
291
+ closed_at: integer("closed_at"),
292
+ comment_count: integer("comment_count"),
293
+ milestone_title: text("milestone_title"),
294
+ updated_at: integer("updated_at"),
295
+ synced_at: integer("synced_at"),
296
+ },
297
+ (t) => [
298
+ primaryKey({ columns: [t.repo_id, t.number] }),
299
+ // CTC-59: the pulls list — `ORDER BY updated_at DESC` (buildPullsView).
300
+ index("idx_pulls_updated").on(t.updated_at),
301
+ ],
302
+ );
303
+
304
+ export const check_runs = sqliteTable(
305
+ "check_runs",
306
+ {
307
+ repo_id: text("repo_id").notNull(),
308
+ check_run_id: text("check_run_id").primaryKey(),
309
+ head_sha: text("head_sha"),
310
+ name: text("name"),
311
+ status: text("status"),
312
+ conclusion: text("conclusion"),
313
+ check_suite_id: text("check_suite_id"),
314
+ updated_at: integer("updated_at"),
315
+ },
316
+ (t) => [index("idx_check_runs_sha").on(t.head_sha)],
317
+ );
318
+
319
+ export const commit_statuses = sqliteTable(
320
+ "commit_statuses",
321
+ {
322
+ repo_id: text("repo_id").notNull(),
323
+ sha: text("sha").notNull(),
324
+ context: text("context").notNull(),
325
+ state: text("state"),
326
+ target_url: text("target_url"),
327
+ updated_at: integer("updated_at"),
328
+ },
329
+ (t) => [primaryKey({ columns: [t.repo_id, t.sha, t.context] })],
330
+ );
331
+
332
+ export const reviews = sqliteTable("reviews", {
333
+ repo_id: text("repo_id").notNull(),
334
+ pr_number: integer("pr_number").notNull(),
335
+ review_id: text("review_id").primaryKey(),
336
+ user_id: text("user_id"),
337
+ state: text("state"),
338
+ submitted_at: integer("submitted_at"),
339
+ });
340
+
341
+ // ── Mirror infra (not domain entities — never ride the change-feed) ─────────────────────────────────
342
+
343
+ export const processed_events = sqliteTable("processed_events", {
344
+ delivery_id: text("delivery_id").primaryKey(),
345
+ source: text("source"),
346
+ event_type: text("event_type"),
347
+ received_at: integer("received_at"),
348
+ });
349
+
350
+ export const change_log = sqliteTable("change_log", {
351
+ seq: integer("seq").primaryKey({ autoIncrement: true }),
352
+ entity: text("entity"),
353
+ entity_id: text("entity_id"),
354
+ op: text("op"), // upsert | delete
355
+ payload: text("payload"),
356
+ ts: integer("ts"),
357
+ });
358
+
359
+ export const sync_cursors = sqliteTable(
360
+ "sync_cursors",
361
+ {
362
+ source: text("source").notNull(),
363
+ entity: text("entity").notNull(),
364
+ cursor: text("cursor"),
365
+ last_run_at: integer("last_run_at"),
366
+ },
367
+ (t) => [primaryKey({ columns: [t.source, t.entity] })],
368
+ );