|
@@ -0,0 +1,196 @@
|
|
|
|
|
+using InvercargillSql.Migrations;
|
|
|
|
|
+using Sqlite;
|
|
|
|
|
+
|
|
|
|
|
+namespace ClankerSaddle.Data.Migrations {
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Goal 8: the incremental upgrade that un-flattens the schema.
|
|
|
|
|
+ *
|
|
|
|
|
+ * M0001 was historically treated as tear-down-and-recreate — every
|
|
|
|
|
+ * later change edited it in place. A deployed database (the live
|
|
|
|
|
+ * instance) carries `__migrations` row (1, "clanker-saddle", 1),
|
|
|
|
|
+ * so an in-place edit of M0001 never re-runs there: every page
|
|
|
|
|
+ * touching the new tables/columns 500s. This migration brings any
|
|
|
|
|
+ * serial-1 database up to the current M0001 shape IN PLACE.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Adds (exactly what the goal-8 tasks folded into M0001):
|
|
|
|
|
+ * - `stacks`, `stack_projects` (task 2 — reference-project bundles)
|
|
|
|
|
+ * - `notes` (task 3 — the dashboard scratchpad)
|
|
|
|
|
+ * - `saddles.prompt_note` (task 5 — per-saddle prompt preamble)
|
|
|
|
|
+ * - `saddles.workspace_root` (task 6 — per-saddle workspace paths)
|
|
|
|
|
+ *
|
|
|
|
|
+ * NOT altered: `tasks.project_id`. Goal 8 task 4 changed its M0001
|
|
|
|
|
+ * definition (dropped the projects FK + index, added DEFAULT 0) for
|
|
|
|
|
+ * standalone tasks. A live serial-1 database already has the column
|
|
|
|
|
+ * — NOT NULL, no default, with the FK — and SQLite cannot ALTER a
|
|
|
|
|
+ * column in place. The difference is semantically inert at runtime:
|
|
|
|
|
+ * foreign keys are not enforced (no `PRAGMA foreign_keys=ON` is
|
|
|
|
|
+ * ever set — see the connection setup in main.vala), and the ORM
|
|
|
|
|
+ * always writes project_id explicitly (0 for standalone tasks), so
|
|
|
|
|
+ * the column default is never exercised. Both shapes work; the
|
|
|
|
|
+ * constraint text in sqlite_master simply differs.
|
|
|
|
|
+ *
|
|
|
|
|
+ * IDEMPOTENCE: the runner applies each migration exactly once (the
|
|
|
|
|
+ * `__migrations` insert is the guard), so up() only ever runs on a
|
|
|
|
|
+ * database this migration has not touched. But it must succeed BOTH
|
|
|
|
|
+ * on a serial-1 database (live) and, defensively, on a fresh one
|
|
|
|
|
+ * where M0001 already created all of this (e.g. a database created
|
|
|
|
|
+ * by this build then handed to an older build, or a partially
|
|
|
|
|
+ * upgraded copy). The framework offers no guarded create/add, and
|
|
|
|
|
+ * up() cannot catch a failure itself: the builder merely QUEUES
|
|
|
|
|
+ * operations, which the runner executes later inside a single
|
|
|
|
|
+ * transaction (any duplicate would abort the whole migration). So
|
|
|
|
|
+ * up() introspects first — the runner invokes up() BEFORE opening
|
|
|
|
|
+ * that transaction (verified against the InvercargillSql runner
|
|
|
|
|
+ * source: migration.up(builder) runs, then begin_transaction, then
|
|
|
|
|
+ * builder.execute) — and only queues the operations the schema
|
|
|
|
|
+ * still needs. A fresh database queues nothing and the migration
|
|
|
|
|
+ * records as a no-op.
|
|
|
|
|
+ */
|
|
|
|
|
+ public class M0002_BatchImprovements : Migration {
|
|
|
|
|
+
|
|
|
|
|
+ public override string name { get { return "Goal 8 batch improvements: stacks, notes, per-saddle columns"; } }
|
|
|
|
|
+ public override uint64 serial { get { return 2; } }
|
|
|
|
|
+ public override string migration_namespace { get { return "clanker-saddle"; } }
|
|
|
|
|
+
|
|
|
|
|
+ // The database this migration is upgrading. The runner never
|
|
|
|
|
+ // hands up() a connection (the builder only queues SQL), so the
|
|
|
|
|
+ // schema is read through a separate read-only handle. SPRY_DB_PATH
|
|
|
|
|
+ // is the same variable main.vala resolves the connection string
|
|
|
|
|
+ // from, so this always targets the database actually being
|
|
|
|
|
+ // migrated.
|
|
|
|
|
+ private string db_path() {
|
|
|
|
|
+ var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
|
|
|
|
|
+ if (path.strip().length == 0) {
|
|
|
|
|
+ return "./clanker-saddle.sqlite";
|
|
|
|
|
+ }
|
|
|
|
|
+ return path;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private bool table_exists(string name) {
|
|
|
|
|
+ Database db;
|
|
|
|
|
+ int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
|
|
|
|
|
+ if (rc != Sqlite.OK) {
|
|
|
|
|
+ // Unopenable (locked, or missing — a database the runner
|
|
|
|
|
+ // is about to create): treat everything as absent and
|
|
|
|
|
+ // let the queued operations fail loudly rather than
|
|
|
|
|
+ // silently skipping an upgrade.
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ Statement statement;
|
|
|
|
|
+ rc = db.prepare_v2(
|
|
|
|
|
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
|
|
|
+ -1, out statement);
|
|
|
|
|
+ if (rc != Sqlite.OK) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ statement.bind_text(1, name, -1);
|
|
|
|
|
+ if (statement.step() != Sqlite.ROW) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return statement.column_int64(0) > 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private bool column_exists(string table, string column) {
|
|
|
|
|
+ Database db;
|
|
|
|
|
+ int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
|
|
|
|
|
+ if (rc != Sqlite.OK) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ Statement statement;
|
|
|
|
|
+ rc = db.prepare_v2(
|
|
|
|
|
+ "SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
|
|
|
+ -1, out statement);
|
|
|
|
|
+ if (rc != Sqlite.OK) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ statement.bind_text(1, table, -1);
|
|
|
|
|
+ statement.bind_text(2, column, -1);
|
|
|
|
|
+ if (statement.step() != Sqlite.ROW) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return statement.column_int64(0) > 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public override void up(MigrationBuilder b) {
|
|
|
|
|
+ // Stacks (goal 8 task 2) — same shapes M0001 now defines.
|
|
|
|
|
+ if (!table_exists("stacks")) {
|
|
|
|
|
+ b.create_table("stacks", (t) => {
|
|
|
|
|
+ t.column<int64?>("id")
|
|
|
|
|
+ .auto_increment()
|
|
|
|
|
+ .primary_key();
|
|
|
|
|
+ t.column<string>("name")
|
|
|
|
|
+ .not_null()
|
|
|
|
|
+ .unique();
|
|
|
|
|
+ t.column<string>("summary");
|
|
|
|
|
+ t.column<DateTime?>("created_at");
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!table_exists("stack_projects")) {
|
|
|
|
|
+ b.create_table("stack_projects", (t) => {
|
|
|
|
|
+ t.column<int64?>("id")
|
|
|
|
|
+ .auto_increment()
|
|
|
|
|
+ .primary_key();
|
|
|
|
|
+ t.column<int64?>("stack_id")
|
|
|
|
|
+ .not_null()
|
|
|
|
|
+ .references("stacks", "id")
|
|
|
|
|
+ .indexed();
|
|
|
|
|
+ t.column<int64?>("project_id")
|
|
|
|
|
+ .not_null()
|
|
|
|
|
+ .references("projects", "id")
|
|
|
|
|
+ .indexed();
|
|
|
|
|
+ t.column<string>("relationship")
|
|
|
|
|
+ .not_null()
|
|
|
|
|
+ .default_value<string>("reference");
|
|
|
|
|
+ t.column<DateTime?>("created_at");
|
|
|
|
|
+ t.unique("uq_stack_projects_pair", {"stack_id", "project_id"});
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Dashboard scratchpad notes (goal 8 task 3).
|
|
|
|
|
+ if (!table_exists("notes")) {
|
|
|
|
|
+ b.create_table("notes", (t) => {
|
|
|
|
|
+ t.column<int64?>("id")
|
|
|
|
|
+ .auto_increment()
|
|
|
|
|
+ .primary_key();
|
|
|
|
|
+ t.column<string>("body")
|
|
|
|
|
+ .not_null();
|
|
|
|
|
+ t.column<bool>("done")
|
|
|
|
|
+ .not_null()
|
|
|
|
|
+ .default_value<bool>(false);
|
|
|
|
|
+ t.column<DateTime?>("done_at");
|
|
|
|
|
+ t.column<int64?>("goal_id")
|
|
|
|
|
+ .references("goals", "id");
|
|
|
|
|
+ t.column<DateTime?>("created_at");
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Per-saddle columns (tasks 5 and 6).
|
|
|
|
|
+ if (!column_exists("saddles", "prompt_note")) {
|
|
|
|
|
+ b.alter_table("saddles", (t) => {
|
|
|
|
|
+ t.add_column<string>("prompt_note");
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!column_exists("saddles", "workspace_root")) {
|
|
|
|
|
+ b.alter_table("saddles", (t) => {
|
|
|
|
|
+ t.add_column<string>("workspace_root");
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public override void down(MigrationBuilder b) {
|
|
|
|
|
+ // Column drops first (the two saddles additions), then the
|
|
|
|
|
+ // three tables. drop_column is not universal across SQLite
|
|
|
|
|
+ // builds older than 3.35, but the runner's rollback path is
|
|
|
|
|
+ // unused by the app (nothing calls rollback); down() exists
|
|
|
|
|
+ // to satisfy the abstract class.
|
|
|
|
|
+ b.alter_table("saddles", (t) => {
|
|
|
|
|
+ t.drop_column("workspace_root");
|
|
|
|
|
+ t.drop_column("prompt_note");
|
|
|
|
|
+ });
|
|
|
|
|
+ b.drop_table("notes");
|
|
|
|
|
+ b.drop_table("stack_projects");
|
|
|
|
|
+ b.drop_table("stacks");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|