reviewzy 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/.env.example +30 -0
- package/LICENSE +661 -0
- package/README.md +170 -0
- package/bin/reviewzy +7 -0
- package/package.json +29 -0
- package/src/config.ts +76 -0
- package/src/daemon/app.ts +159 -0
- package/src/daemon/main.ts +103 -0
- package/src/daemon/origin.ts +60 -0
- package/src/dashboard/auth.ts +122 -0
- package/src/dashboard/mount.ts +373 -0
- package/src/dashboard/static/components.css +1116 -0
- package/src/dashboard/static/dashboard.css +182 -0
- package/src/dashboard/static/dashboard.js +169 -0
- package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/htmx.min.js +2 -0
- package/src/dashboard/static/tokens.css +213 -0
- package/src/dashboard/views.tsx +1388 -0
- package/src/db/human-save.ts +242 -0
- package/src/db/migrate.ts +71 -0
- package/src/db/migrations.ts +149 -0
- package/src/db/queries.ts +425 -0
- package/src/db/store.ts +83 -0
- package/src/db/style-guide.ts +236 -0
- package/src/db/sweep.ts +88 -0
- package/src/mcp/await-approved.ts +156 -0
- package/src/mcp/fetch-approved.ts +132 -0
- package/src/mcp/file-entries.ts +219 -0
- package/src/mcp/list-entries.ts +107 -0
- package/src/mcp/mark-applied.ts +103 -0
- package/src/mcp/route.ts +88 -0
- package/src/mcp/server.ts +59 -0
- package/src/mcp/style-guide-resource.ts +40 -0
- package/src/notify.ts +142 -0
- package/src/shim/daemon.ts +318 -0
- package/src/shim/lockfile.ts +153 -0
- package/src/shim/main.ts +172 -0
- package/src/shim/semver.ts +29 -0
- package/src/version.ts +4 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { ulid } from "ulid";
|
|
2
|
+
import type { EntryStatus } from "./queries.ts";
|
|
3
|
+
import type { Store } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
export type HumanSaveInput = {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Everything `saveHumanText` refuses, with the values the dashboard's message needs: `unknown`
|
|
12
|
+
* became a page's 404; `status` names the terminal status; `max_len` carries the limit and the
|
|
13
|
+
* length actually seen; `placeholder` names the first declared placeholder the text misses. The
|
|
14
|
+
* route layer formats these into user-facing messages; the store never touches prose.
|
|
15
|
+
*/
|
|
16
|
+
export type HumanSaveRefusal =
|
|
17
|
+
| { readonly kind: "unknown" }
|
|
18
|
+
| { readonly kind: "status"; readonly status: "applied" | "rejected" }
|
|
19
|
+
| { readonly kind: "max_len"; readonly maxLen: number; readonly length: number }
|
|
20
|
+
| { readonly kind: "placeholder"; readonly placeholder: string }
|
|
21
|
+
| { readonly kind: "empty" };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `saved` distinguishes a write from a no-op: text identical to the stored `human_text` writes
|
|
25
|
+
* nothing and returns the current state; anything else wrote exactly one revision row, approved
|
|
26
|
+
* the entry, and dispatched the status-change event.
|
|
27
|
+
*/
|
|
28
|
+
export type HumanSaveOutcome =
|
|
29
|
+
| { readonly ok: true; readonly saved: boolean; readonly status: EntryStatus; readonly text: string }
|
|
30
|
+
| { readonly ok: false; readonly refusal: HumanSaveRefusal };
|
|
31
|
+
|
|
32
|
+
/** One `entry_revisions` row as the history pane reads it, newest first. */
|
|
33
|
+
export type RevisionRow = {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly human_text: string;
|
|
36
|
+
readonly status: EntryStatus;
|
|
37
|
+
readonly created_at: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The constraints the editor enforces and displays. `max_len` and `placeholders` are enforced at
|
|
42
|
+
* save; `tone` and `notes` are shown only. `undefined`/absent means "no constraint": the stored
|
|
43
|
+
* JSON is foreign data and may be malformed, and an unparseable or shape-broken value must read as
|
|
44
|
+
* no constraint, never as a 500.
|
|
45
|
+
*/
|
|
46
|
+
export type Constraints = {
|
|
47
|
+
readonly maxLen: number | undefined;
|
|
48
|
+
readonly placeholders: readonly string[];
|
|
49
|
+
readonly tone: string | undefined;
|
|
50
|
+
readonly notes: string | undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const NO_CONSTRAINTS: Constraints = { maxLen: undefined, placeholders: [], tone: undefined, notes: undefined };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The tolerant parse for `entries.constraints`: malformed JSON, non-objects, and wrongly-typed
|
|
57
|
+
* members all fall back to "no constraint" (the same stance `style-guide.ts`'s glossary and banned
|
|
58
|
+
* words take). `max_len` must be a positive integer; `placeholders` keeps the non-empty strings in
|
|
59
|
+
* order, duplicates dropped, so the checklist never repeats a token.
|
|
60
|
+
*/
|
|
61
|
+
export function parseConstraints(json: string): Constraints {
|
|
62
|
+
let parsed: unknown;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(json);
|
|
65
|
+
} catch {
|
|
66
|
+
return NO_CONSTRAINTS;
|
|
67
|
+
}
|
|
68
|
+
if (typeof parsed !== "object" || parsed === null) return NO_CONSTRAINTS;
|
|
69
|
+
const raw = parsed as Record<string, unknown>;
|
|
70
|
+
const maxLen = typeof raw.max_len === "number" && Number.isInteger(raw.max_len) && raw.max_len > 0
|
|
71
|
+
? raw.max_len
|
|
72
|
+
: undefined;
|
|
73
|
+
const placeholders: string[] = [];
|
|
74
|
+
if (Array.isArray(raw.placeholders)) {
|
|
75
|
+
for (const placeholder of raw.placeholders) {
|
|
76
|
+
if (typeof placeholder === "string" && placeholder !== "" && !placeholders.includes(placeholder)) {
|
|
77
|
+
placeholders.push(placeholder);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const tone = typeof raw.tone === "string" ? raw.tone : undefined;
|
|
82
|
+
const notes = typeof raw.notes === "string" ? raw.notes : undefined;
|
|
83
|
+
return { maxLen, placeholders, tone, notes };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Everything an approve or reject refuses, with the value the dashboard's message needs: `unknown`
|
|
88
|
+
* became a page's gone-fragment, `status` names the status that blocked the move (never `draft`,
|
|
89
|
+
* which is the source state both transitions act on), `no_draft` the entry whose agent never
|
|
90
|
+
* produced text. The route layer formats these into user-facing messages; the store never touches
|
|
91
|
+
* prose.
|
|
92
|
+
*/
|
|
93
|
+
export type TransitionRefusal =
|
|
94
|
+
| { readonly kind: "unknown" }
|
|
95
|
+
| { readonly kind: "status"; readonly status: Exclude<EntryStatus, "draft"> }
|
|
96
|
+
| { readonly kind: "no_draft" };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `approveEntry`'s outcome. `ok` carries the status the entry now holds; a refusal means nothing
|
|
100
|
+
* was written, not even `updated_at`.
|
|
101
|
+
*/
|
|
102
|
+
export type ApproveOutcome = { readonly ok: true; readonly status: "approved" } | { readonly ok: false; readonly refusal: TransitionRefusal };
|
|
103
|
+
|
|
104
|
+
/** `rejectEntry`'s outcome, mirroring `ApproveOutcome`. */
|
|
105
|
+
export type RejectOutcome = { readonly ok: true; readonly status: "rejected" } | { readonly ok: false; readonly refusal: TransitionRefusal };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One entry's result in a batch approve, keyed by the id the caller passed. `ok` reports the entry
|
|
109
|
+
* approved; a refusal names why and carries `id` so the route can point at the entry without
|
|
110
|
+
* re-reading a row it was just told to leave alone.
|
|
111
|
+
*/
|
|
112
|
+
export type BatchApproveResult = { readonly id: string; readonly ok: true; readonly status: "approved" } | { readonly id: string; readonly ok: false; readonly refusal: TransitionRefusal };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The dashboard's one save path, owned here so no route decides a status change. The refusal order
|
|
116
|
+
* is deliberate: unknown id, then a terminal status (nothing is ever written on those), then empty
|
|
117
|
+
* text, then the identical-text no-op, then the constraint checks. A save that passes all of them
|
|
118
|
+
* inserts exactly one revision row recording the status the entry held at that moment (a draft save
|
|
119
|
+
* records "draft"), updates the entry to approved, and dispatches the status-change event after the
|
|
120
|
+
* write so a waiter's re-read sees the new status. The insert and the update are one transaction:
|
|
121
|
+
* a crash between them must not leave a revision without its approval. Old revision rows are never
|
|
122
|
+
* mutated; "undo" is restoring a revision's text, which writes a new row like any other save.
|
|
123
|
+
*/
|
|
124
|
+
export function saveHumanText(store: Store, input: HumanSaveInput): HumanSaveOutcome {
|
|
125
|
+
const row = store.db.query("SELECT status, human_text, constraints FROM entries WHERE id = ?").get(input.id) as
|
|
126
|
+
| { status: EntryStatus; human_text: string | null; constraints: string }
|
|
127
|
+
| null;
|
|
128
|
+
if (row === null) return { ok: false, refusal: { kind: "unknown" } };
|
|
129
|
+
|
|
130
|
+
// `draft` and `approved` are the two statuses the dashboard owns; the agents own the rest, so
|
|
131
|
+
// anything else is terminal from this side.
|
|
132
|
+
if (row.status !== "draft" && row.status !== "approved") {
|
|
133
|
+
return { ok: false, refusal: { kind: "status", status: row.status } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (input.text.trim() === "") {
|
|
137
|
+
return { ok: false, refusal: { kind: "empty" } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The no-op save: identical text writes nothing, returns the current state, and fires no event.
|
|
141
|
+
if (input.text === row.human_text) {
|
|
142
|
+
return { ok: true, saved: false, status: row.status, text: input.text };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const constraints = parseConstraints(row.constraints);
|
|
146
|
+
if (constraints.maxLen !== undefined && input.text.length > constraints.maxLen) {
|
|
147
|
+
return { ok: false, refusal: { kind: "max_len", maxLen: constraints.maxLen, length: input.text.length } };
|
|
148
|
+
}
|
|
149
|
+
for (const placeholder of constraints.placeholders) {
|
|
150
|
+
if (!input.text.includes(placeholder)) {
|
|
151
|
+
return { ok: false, refusal: { kind: "placeholder", placeholder } };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The revision's status is the entry's status at this moment, before the transition: the save
|
|
156
|
+
// that approves a draft records "draft", a re-save on an approved entry records "approved".
|
|
157
|
+
const recordedStatus = row.status;
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
store.db.transaction(() => {
|
|
160
|
+
store.db.run(
|
|
161
|
+
"INSERT INTO entry_revisions (id, entry_id, human_text, status, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
162
|
+
[ulid(), input.id, input.text, recordedStatus, now],
|
|
163
|
+
);
|
|
164
|
+
store.db.run("UPDATE entries SET human_text = ?, status = 'approved', updated_at = ? WHERE id = ?", [
|
|
165
|
+
input.text,
|
|
166
|
+
now,
|
|
167
|
+
input.id,
|
|
168
|
+
]);
|
|
169
|
+
})();
|
|
170
|
+
store.notifyStatusChange(input.id, "approved");
|
|
171
|
+
return { ok: true, saved: true, status: "approved", text: input.text };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Approves a draft: `human_text` takes the agent's draft verbatim, the entry becomes approved, and
|
|
176
|
+
* the status-change event fires after the write so a waiter's re-read sees the new status. The
|
|
177
|
+
* single UPDATE needs no explicit transaction. Refusals: unknown id, then a status that is not
|
|
178
|
+
* draft, then an entry whose agent never produced a draft; nothing is written on any of them, not
|
|
179
|
+
* even `updated_at`. Approving authors no prose, so it logs no revision row; the save that approves
|
|
180
|
+
* is the only revision writer.
|
|
181
|
+
*/
|
|
182
|
+
export function approveEntry(store: Store, id: string): ApproveOutcome {
|
|
183
|
+
const row = store.db.query("SELECT status, agent_draft FROM entries WHERE id = ?").get(id) as
|
|
184
|
+
| { status: EntryStatus; agent_draft: string | null }
|
|
185
|
+
| null;
|
|
186
|
+
if (row === null) return { ok: false, refusal: { kind: "unknown" } };
|
|
187
|
+
if (row.status !== "draft") return { ok: false, refusal: { kind: "status", status: row.status } };
|
|
188
|
+
if (row.agent_draft === null) return { ok: false, refusal: { kind: "no_draft" } };
|
|
189
|
+
|
|
190
|
+
store.db.run("UPDATE entries SET human_text = ?, status = 'approved', updated_at = ? WHERE id = ?", [
|
|
191
|
+
row.agent_draft,
|
|
192
|
+
Date.now(),
|
|
193
|
+
id,
|
|
194
|
+
]);
|
|
195
|
+
store.notifyStatusChange(id, "approved");
|
|
196
|
+
return { ok: true, status: "approved" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Rejects a draft or an approved entry: the entry becomes rejected, and the status-change event
|
|
201
|
+
* fires after the write. Rejection is terminal and erases nothing (the draft and any human text
|
|
202
|
+
* stay in the row; a new entry is the way to start again), so it logs no revision row. Refusals:
|
|
203
|
+
* unknown id, then a status that is not draft or approved; nothing is written on either.
|
|
204
|
+
*/
|
|
205
|
+
export function rejectEntry(store: Store, id: string): RejectOutcome {
|
|
206
|
+
const row = store.db.query("SELECT status FROM entries WHERE id = ?").get(id) as { status: EntryStatus } | null;
|
|
207
|
+
if (row === null) return { ok: false, refusal: { kind: "unknown" } };
|
|
208
|
+
if (row.status !== "draft" && row.status !== "approved") {
|
|
209
|
+
return { ok: false, refusal: { kind: "status", status: row.status } };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
store.db.run("UPDATE entries SET status = 'rejected', updated_at = ? WHERE id = ?", [Date.now(), id]);
|
|
213
|
+
store.notifyStatusChange(id, "rejected");
|
|
214
|
+
return { ok: true, status: "rejected" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The batch approve: each id goes through `approveEntry` independently, so one entry's refusal
|
|
219
|
+
* never blocks another's approval and a race can only refuse the entry it actually hit. Results
|
|
220
|
+
* come back in the ids' order; the caller reports per entry. An empty selection approves nothing.
|
|
221
|
+
*/
|
|
222
|
+
export function batchApproveEntries(store: Store, ids: readonly string[]): BatchApproveResult[] {
|
|
223
|
+
return ids.map((id) => {
|
|
224
|
+
const outcome = approveEntry(store, id);
|
|
225
|
+
if (outcome.ok) return { id, ok: true, status: "approved" };
|
|
226
|
+
return { id, ok: false, refusal: outcome.refusal };
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The history pane's read: the entry's revision rows, newest first, `rowid` as the same-millisecond
|
|
232
|
+
* tie-break. `id` cannot break that tie: a ulid's random bits never encode recency, so ordering on
|
|
233
|
+
* it shuffles a history saved within one millisecond (same trap as the task-11 batch walk).
|
|
234
|
+
* `rowid` is insertion order, which is exactly the recency the pane promises.
|
|
235
|
+
*/
|
|
236
|
+
export function listRevisions(store: Store, entryId: string): RevisionRow[] {
|
|
237
|
+
return store.db
|
|
238
|
+
.query(
|
|
239
|
+
"SELECT id, human_text, status, created_at FROM entry_revisions WHERE entry_id = ? ORDER BY created_at DESC, rowid DESC",
|
|
240
|
+
)
|
|
241
|
+
.all(entryId) as RevisionRow[];
|
|
242
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { MIGRATIONS, type Migration } from "./migrations.ts";
|
|
3
|
+
|
|
4
|
+
/** A migration's SQL (or its version bump) failed; the whole migration rolled back. */
|
|
5
|
+
export class MigrationError extends Error {
|
|
6
|
+
override readonly name = "MigrationError";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// sqlite's `user_version` is a signed 32-bit integer; a value at or above 2^31 cannot round-trip
|
|
10
|
+
// (it silently reads back as something else, and non-finite/non-integer values silently write 0).
|
|
11
|
+
const MAX_MIGRATION_VERSION = 2 ** 31 - 1;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Refuses the whole set before applying anything if any version is malformed, duplicated, or out
|
|
15
|
+
* of order, so a bad entry can never partially apply.
|
|
16
|
+
*/
|
|
17
|
+
function assertWellFormed(migrations: readonly Migration[]): void {
|
|
18
|
+
let previous = 0;
|
|
19
|
+
for (const migration of migrations) {
|
|
20
|
+
if (!Number.isInteger(migration.version) || migration.version < 1 || migration.version > MAX_MIGRATION_VERSION) {
|
|
21
|
+
throw new MigrationError(
|
|
22
|
+
`reviewzy: migration "${migration.name}" has an invalid version (${migration.version}); versions must be finite integers between 1 and ${MAX_MIGRATION_VERSION}.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (migration.version <= previous) {
|
|
26
|
+
throw new MigrationError(
|
|
27
|
+
`reviewzy: migration ${migration.version} (${migration.name}) is out of order or duplicates a version already seen; migrations must have strictly ascending version numbers.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
previous = migration.version;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Applies every migration newer than `PRAGMA user_version`, oldest first, each inside its own
|
|
36
|
+
* transaction so a failure rolls back cleanly and never leaves a half-applied schema. A
|
|
37
|
+
* migration already recorded in `user_version` is skipped, never re-run.
|
|
38
|
+
*/
|
|
39
|
+
export function runMigrations(db: Database, migrations: readonly Migration[] = MIGRATIONS): void {
|
|
40
|
+
assertWellFormed(migrations);
|
|
41
|
+
|
|
42
|
+
const current = (db.query("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
43
|
+
|
|
44
|
+
// A database written by a newer reviewzy carries a version this build has no migration for;
|
|
45
|
+
// booting anyway would silently run with no schema at all.
|
|
46
|
+
const highestKnown = migrations.at(-1)?.version ?? 0;
|
|
47
|
+
if (current > highestKnown) {
|
|
48
|
+
throw new MigrationError(
|
|
49
|
+
`reviewzy: this database is at schema version ${current}, newer than the highest migration this build knows (${highestKnown}); it was written by a newer reviewzy. Upgrade reviewzy, or point REVIEWZY_DB at a different file.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `assertWellFormed` already guarantees `migrations` is strictly ascending, so `filter` alone
|
|
54
|
+
// preserves application order without a separate sort.
|
|
55
|
+
const pending = migrations.filter((m) => m.version > current);
|
|
56
|
+
|
|
57
|
+
for (const migration of pending) {
|
|
58
|
+
const apply = db.transaction(() => {
|
|
59
|
+
db.run(migration.sql);
|
|
60
|
+
// PRAGMA does not accept bound parameters; `version` is validated above, never user input.
|
|
61
|
+
db.run(`PRAGMA user_version = ${migration.version}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
apply();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
68
|
+
throw new MigrationError(`reviewzy: migration ${migration.version} (${migration.name}) failed: ${detail}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export type Migration = {
|
|
2
|
+
readonly version: number;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly sql: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const STATUS_VALUES = "'draft', 'approved', 'applied', 'rejected'";
|
|
8
|
+
const STATUS_CHECK = `status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN (${STATUS_VALUES}))`;
|
|
9
|
+
const REVISION_STATUS_CHECK = `status TEXT NOT NULL CHECK (status IN (${STATUS_VALUES}))`;
|
|
10
|
+
|
|
11
|
+
// sqlite INTEGER affinity only converts a well-formed integer literal; an ISO string or a float
|
|
12
|
+
// stores as-is otherwise, so the "timestamps are integer ms" decision needs its own CHECK.
|
|
13
|
+
const tsRequired = (col: string) => `${col} INTEGER NOT NULL CHECK (typeof(${col}) = 'integer')`;
|
|
14
|
+
const tsNullable = (col: string) => `${col} INTEGER CHECK (${col} IS NULL OR typeof(${col}) = 'integer')`;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `docs/mcp-contract.md` "entry schema" and "identity key" own these columns; do not add,
|
|
18
|
+
* rename, or drop a field here without updating that frozen doc first.
|
|
19
|
+
*/
|
|
20
|
+
export const MIGRATIONS: Migration[] = [
|
|
21
|
+
{
|
|
22
|
+
version: 1,
|
|
23
|
+
name: "init",
|
|
24
|
+
sql: `
|
|
25
|
+
CREATE TABLE projects (
|
|
26
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
27
|
+
slug TEXT NOT NULL UNIQUE,
|
|
28
|
+
${tsRequired("created_at")}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE entries (
|
|
32
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
33
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
34
|
+
batch_id TEXT NOT NULL,
|
|
35
|
+
repo TEXT NOT NULL,
|
|
36
|
+
file TEXT NOT NULL,
|
|
37
|
+
anchor_text TEXT NOT NULL,
|
|
38
|
+
anchor_before TEXT NOT NULL,
|
|
39
|
+
anchor_after TEXT NOT NULL,
|
|
40
|
+
anchor_hash TEXT NOT NULL,
|
|
41
|
+
file_hash TEXT NOT NULL,
|
|
42
|
+
agent_draft TEXT,
|
|
43
|
+
human_text TEXT,
|
|
44
|
+
${STATUS_CHECK},
|
|
45
|
+
context TEXT NOT NULL,
|
|
46
|
+
constraints TEXT NOT NULL,
|
|
47
|
+
filed_by TEXT,
|
|
48
|
+
stale_note TEXT,
|
|
49
|
+
${tsRequired("created_at")},
|
|
50
|
+
${tsRequired("updated_at")},
|
|
51
|
+
${tsNullable("applied_at")},
|
|
52
|
+
${tsNullable("archived_at")},
|
|
53
|
+
UNIQUE (project_id, repo, file, anchor_hash)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- entry_id keeps a plain NO ACTION foreign key: a sweep that archives an entry without
|
|
57
|
+
-- first moving its revisions must fail loudly rather than orphan or destroy them
|
|
58
|
+
-- (cloudy, 2026-08-15; mcp-contract "three more tables").
|
|
59
|
+
CREATE TABLE entry_revisions (
|
|
60
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
61
|
+
entry_id TEXT NOT NULL REFERENCES entries(id),
|
|
62
|
+
human_text TEXT NOT NULL,
|
|
63
|
+
${REVISION_STATUS_CHECK},
|
|
64
|
+
${tsRequired("created_at")}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
-- entry_revisions has exactly one access path (by entry_id, task 12's history pane) and no
|
|
68
|
+
-- autoindex covers it.
|
|
69
|
+
CREATE INDEX entry_revisions_entry_id_idx ON entry_revisions(entry_id);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE entries_archive (
|
|
72
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
73
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
74
|
+
batch_id TEXT NOT NULL,
|
|
75
|
+
repo TEXT NOT NULL,
|
|
76
|
+
file TEXT NOT NULL,
|
|
77
|
+
anchor_text TEXT NOT NULL,
|
|
78
|
+
anchor_before TEXT NOT NULL,
|
|
79
|
+
anchor_after TEXT NOT NULL,
|
|
80
|
+
anchor_hash TEXT NOT NULL,
|
|
81
|
+
file_hash TEXT NOT NULL,
|
|
82
|
+
agent_draft TEXT,
|
|
83
|
+
human_text TEXT,
|
|
84
|
+
${STATUS_CHECK},
|
|
85
|
+
context TEXT NOT NULL,
|
|
86
|
+
constraints TEXT NOT NULL,
|
|
87
|
+
filed_by TEXT,
|
|
88
|
+
stale_note TEXT,
|
|
89
|
+
${tsRequired("created_at")},
|
|
90
|
+
${tsRequired("updated_at")},
|
|
91
|
+
${tsNullable("applied_at")},
|
|
92
|
+
${tsRequired("archived_at")}
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
-- Mirrors entry_revisions plus archived_at; a sweep moves an entry's revisions here in the
|
|
96
|
+
-- same transaction as the entry's own move, so the history has no expiry
|
|
97
|
+
-- (cloudy, 2026-08-15; docs/design.md "revisions").
|
|
98
|
+
CREATE TABLE entry_revisions_archive (
|
|
99
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
100
|
+
entry_id TEXT NOT NULL REFERENCES entries_archive(id),
|
|
101
|
+
human_text TEXT NOT NULL,
|
|
102
|
+
${REVISION_STATUS_CHECK},
|
|
103
|
+
${tsRequired("created_at")},
|
|
104
|
+
${tsRequired("archived_at")}
|
|
105
|
+
);
|
|
106
|
+
`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
version: 2,
|
|
110
|
+
name: "style-guides",
|
|
111
|
+
sql: `
|
|
112
|
+
-- One global row (project_id NULL, inherited by every project) plus one row per project;
|
|
113
|
+
-- the merge that serves both lives in src/db/style-guide.ts. The json columns are validated
|
|
114
|
+
-- at write time (the editor's boundary), so the read path must tolerate any stored string
|
|
115
|
+
-- instead of trusting the defaults.
|
|
116
|
+
CREATE TABLE style_guides (
|
|
117
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
118
|
+
project_id TEXT REFERENCES projects(id),
|
|
119
|
+
markdown TEXT NOT NULL DEFAULT '',
|
|
120
|
+
banned_words TEXT NOT NULL DEFAULT '[]',
|
|
121
|
+
glossary TEXT NOT NULL DEFAULT '{}',
|
|
122
|
+
${tsRequired("created_at")},
|
|
123
|
+
${tsRequired("updated_at")},
|
|
124
|
+
UNIQUE (project_id)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
-- sqlite's UNIQUE lets any number of NULL project_id rows through, and its unique indexes
|
|
128
|
+
-- treat NULLs as distinct from each other too, so the partial index cannot key on the bare
|
|
129
|
+
-- column: ON style_guides(project_id) WHERE project_id IS NULL would still admit unlimited
|
|
130
|
+
-- global rows (measured on a bare sqlite). Keying the partial index on the constant boolean
|
|
131
|
+
-- expression instead makes every global row share one index key, so the UNIQUE pins at most
|
|
132
|
+
-- one. Project rows never enter this index; UNIQUE(project_id) owns them.
|
|
133
|
+
CREATE UNIQUE INDEX style_guides_global_idx ON style_guides(project_id IS NULL) WHERE project_id IS NULL;
|
|
134
|
+
`,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
version: 3,
|
|
138
|
+
name: "mark-applied",
|
|
139
|
+
sql: `
|
|
140
|
+
-- mark_applied stores the hash of the text the agent applied; the contract's param table
|
|
141
|
+
-- listed applied_hash a day before the entry schema had a column to hold it (cloudy,
|
|
142
|
+
-- 2026-08-16), so the column lands now, with the doc edit in the same round.
|
|
143
|
+
ALTER TABLE entries ADD COLUMN applied_hash TEXT;
|
|
144
|
+
-- entries_archive mirrors entries column-for-column: the retention sweep copies applied
|
|
145
|
+
-- rows over whole, so it must not lose the new column.
|
|
146
|
+
ALTER TABLE entries_archive ADD COLUMN applied_hash TEXT;
|
|
147
|
+
`,
|
|
148
|
+
},
|
|
149
|
+
];
|