From f86882bde5c59f36ab8d8d8bf6537c6a993386ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 23 Mar 2026 10:57:27 -0600 Subject: [PATCH] =?UTF-8?q?fix(S04/T01):=20Add=20schema=20v9=20migration?= =?UTF-8?q?=20with=20sequence=20column=20on=20slices/ta=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/resources/extensions/gsd/gsd-db.ts - src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts - .gsd/milestones/M001/slices/S04/S04-PLAN.md - .gsd/milestones/M001/slices/S04/tasks/T01-PLAN.md --- .gsd/milestones/M001/slices/S04/S04-PLAN.md | 3 +- .../M001/slices/S04/tasks/T01-PLAN.md | 8 + .../M001/slices/S04/tasks/T01-SUMMARY.md | 62 ++++++ src/resources/extensions/gsd/gsd-db.ts | 38 +++- .../gsd/tests/schema-v9-sequence.test.ts | 176 ++++++++++++++++++ 5 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 .gsd/milestones/M001/slices/S04/tasks/T01-SUMMARY.md create mode 100644 src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts diff --git a/.gsd/milestones/M001/slices/S04/S04-PLAN.md b/.gsd/milestones/M001/slices/S04/S04-PLAN.md index 7e5e374d1..208a5173c 100644 --- a/.gsd/milestones/M001/slices/S04/S04-PLAN.md +++ b/.gsd/milestones/M001/slices/S04/S04-PLAN.md @@ -27,6 +27,7 @@ - `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/planning-crossval.test.ts` — DB↔rendered parity - `rg 'parseRoadmapSlices|parseRoadmap|parsePlan' src/resources/extensions/gsd/dispatch-guard.ts src/resources/extensions/gsd/auto-verification.ts src/resources/extensions/gsd/parallel-eligibility.ts` returns no matches (parser imports removed from migrated files) - `rg 'parseRoadmap' src/resources/extensions/gsd/auto-dispatch.ts` returns no matches (parser import narrowed) +- Diagnostic: `node -e "const{openDatabase,getMilestoneSlices}=require('./src/resources/extensions/gsd/gsd-db.ts');openDatabase(':memory:');console.log(getMilestoneSlices('NONEXISTENT'))"` — returns empty array `[]` (no crash on missing milestone, observable failure state) ## Observability / Diagnostics @@ -42,7 +43,7 @@ ## Tasks -- [ ] **T01: Add schema v9 migration with sequence column and fix ORDER BY queries** `est:30m` +- [x] **T01: Add schema v9 migration with sequence column and fix ORDER BY queries** `est:30m` - Why: R016 requires sequence-aware ordering. All caller migrations and cross-validation depend on correct query ordering. - Files: `src/resources/extensions/gsd/gsd-db.ts`, `src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts` - Do: Add `sequence INTEGER DEFAULT 0` to slices and tasks tables in a `currentVersion < 9` migration block. Bump `SCHEMA_VERSION` to 9. Update `SliceRow` and `TaskRow` interfaces to include `sequence: number`. Change all 6 `ORDER BY id` queries to `ORDER BY sequence, id`. Add `insertSlicePlanning`/`insertTask` to accept optional `sequence` param. Write test file proving: migration adds column, ORDER BY respects sequence, null/0 sequence falls back to id ordering, backfill from positional order. diff --git a/.gsd/milestones/M001/slices/S04/tasks/T01-PLAN.md b/.gsd/milestones/M001/slices/S04/tasks/T01-PLAN.md index 0ba167f2e..6a401cbfd 100644 --- a/.gsd/milestones/M001/slices/S04/tasks/T01-PLAN.md +++ b/.gsd/milestones/M001/slices/S04/tasks/T01-PLAN.md @@ -54,3 +54,11 @@ Add a `sequence INTEGER DEFAULT 0` column to the `slices` and `tasks` tables via - `src/resources/extensions/gsd/gsd-db.ts` — updated with schema v9, sequence field, ORDER BY changes - `src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts` — new test file proving sequence ordering + +## Observability Impact + +- **Schema version**: `SCHEMA_VERSION` constant changes from 8 → 9; `schema_version` table gains a row for version 9 with timestamp +- **Column visibility**: `PRAGMA table_info(slices)` and `PRAGMA table_info(tasks)` now show `sequence INTEGER DEFAULT 0` +- **Query ordering**: All slice/task list queries sort by `sequence, id` — inspectable via `EXPLAIN QUERY PLAN` or by inserting rows with non-lexicographic sequence values +- **Failure state**: `getMilestoneSlices('NONEXISTENT')` returns `[]` (empty array, no crash); `getSliceTasks` with no DB open returns `[]` +- **Interface change**: `SliceRow.sequence` and `TaskRow.sequence` fields available to all downstream consumers diff --git a/.gsd/milestones/M001/slices/S04/tasks/T01-SUMMARY.md b/.gsd/milestones/M001/slices/S04/tasks/T01-SUMMARY.md new file mode 100644 index 000000000..f0e36f6d3 --- /dev/null +++ b/.gsd/milestones/M001/slices/S04/tasks/T01-SUMMARY.md @@ -0,0 +1,62 @@ +--- +id: T01 +parent: S04 +milestone: M001 +key_files: + - src/resources/extensions/gsd/gsd-db.ts + - src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts + - .gsd/milestones/M001/slices/S04/S04-PLAN.md + - .gsd/milestones/M001/slices/S04/tasks/T01-PLAN.md +key_decisions: + - Added sequence column to initial CREATE TABLE DDL in addition to migration block — required for fresh databases that skip migrations + - Used INTEGER DEFAULT 0 (not NOT NULL) for sequence column to keep it nullable-safe and backward compatible +duration: "" +verification_result: passed +completed_at: 2026-03-23T16:57:23.834Z +blocker_discovered: false +--- + +# T01: Add schema v9 migration with sequence column on slices/tasks tables and fix ORDER BY queries to use sequence, id + +**Add schema v9 migration with sequence column on slices/tasks tables and fix ORDER BY queries to use sequence, id** + +## What Happened + +Added a `sequence INTEGER DEFAULT 0` column to both `slices` and `tasks` tables via two changes: (1) updated the initial CREATE TABLE definitions so fresh databases include the column from the start, and (2) added a `currentVersion < 9` migration block using `ensureColumn()` for existing databases upgrading from v8. Bumped `SCHEMA_VERSION` from 8 to 9. + +Updated both `SliceRow` and `TaskRow` TypeScript interfaces to include `sequence: number`, and updated their `rowToSlice`/`rowToTask` converter functions to read the field with a `?? 0` fallback. + +Updated all 4 slice/task `ORDER BY id` queries to `ORDER BY sequence, id`: `getSliceTasks()`, `getActiveSliceFromDb()`, `getActiveTaskFromDb()`, and `getMilestoneSlices()`. Left the 2 milestone queries (`getAllMilestones`, `getActiveMilestoneFromDb`) using `ORDER BY id` as milestones don't have a sequence column. + +Updated `insertSlice` and `insertTask` to accept an optional `sequence` parameter, defaulting to 0. + +Wrote 7 tests covering: migration adds columns, sequence-based ordering for slices and tasks, default sequence=0 falls back to id ordering, `getActiveSliceFromDb` and `getActiveTaskFromDb` respect sequence, and sequence defaults to 0 when not provided. + +Also addressed the pre-flight observability gaps: added a diagnostic verification step to S04-PLAN.md and an Observability Impact section to T01-PLAN.md. + +## Verification + +Ran schema-v9-sequence test suite: 7/7 pass. Ran plan-milestone, plan-slice, plan-task regression tests: 15/15 pass. Verified SCHEMA_VERSION=9. Verified all 4 slice/task ORDER BY queries use `sequence, id`. Verified milestone ORDER BY queries remain `ORDER BY id`. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts` | 0 | ✅ pass | 203ms | +| 2 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-milestone.test.ts src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts` | 0 | ✅ pass | 207ms | + + +## Deviations + +Added `sequence INTEGER DEFAULT 0` to the initial CREATE TABLE definitions for slices and tasks (not just the migration block). This was necessary because fresh databases created via `openDatabase` use the CREATE TABLE DDL directly — the migration block only runs for existing DBs upgrading from a prior version. Without this, insertSlice/insertTask would fail on fresh DBs because the column wouldn't exist. + +## Known Issues + +None. + +## Files Created/Modified + +- `src/resources/extensions/gsd/gsd-db.ts` +- `src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts` +- `.gsd/milestones/M001/slices/S04/S04-PLAN.md` +- `.gsd/milestones/M001/slices/S04/tasks/T01-PLAN.md` diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index 2e29952de..aa19f26bd 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -145,7 +145,7 @@ function openRawDb(path: string): unknown { return new Database(path); } -const SCHEMA_VERSION = 8; +const SCHEMA_VERSION = 9; function initSchema(db: DbAdapter, fileBacked: boolean): void { if (fileBacked) db.exec("PRAGMA journal_mode=WAL"); @@ -267,6 +267,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { proof_level TEXT NOT NULL DEFAULT '', integration_closure TEXT NOT NULL DEFAULT '', observability_impact TEXT NOT NULL DEFAULT '', + sequence INTEGER DEFAULT 0, PRIMARY KEY (milestone_id, id), FOREIGN KEY (milestone_id) REFERENCES milestones(id) ) @@ -297,6 +298,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { inputs TEXT NOT NULL DEFAULT '[]', expected_output TEXT NOT NULL DEFAULT '[]', observability_impact TEXT NOT NULL DEFAULT '', + sequence INTEGER DEFAULT 0, PRIMARY KEY (milestone_id, slice_id, id), FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) ) @@ -592,6 +594,16 @@ function migrateSchema(db: DbAdapter): void { }); } + if (currentVersion < 9) { + ensureColumn(db, "slices", "sequence", `ALTER TABLE slices ADD COLUMN sequence INTEGER DEFAULT 0`); + ensureColumn(db, "tasks", "sequence", `ALTER TABLE tasks ADD COLUMN sequence INTEGER DEFAULT 0`); + + db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({ + ":version": 9, + ":applied_at": new Date().toISOString(), + }); + } + db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -967,16 +979,17 @@ export function insertSlice(s: { risk?: string; depends?: string[]; demo?: string; + sequence?: number; planning?: Partial; }): void { if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); currentDb.prepare( `INSERT OR IGNORE INTO slices ( milestone_id, id, title, status, risk, depends, demo, created_at, - goal, success_criteria, proof_level, integration_closure, observability_impact + goal, success_criteria, proof_level, integration_closure, observability_impact, sequence ) VALUES ( :milestone_id, :id, :title, :status, :risk, :depends, :demo, :created_at, - :goal, :success_criteria, :proof_level, :integration_closure, :observability_impact + :goal, :success_criteria, :proof_level, :integration_closure, :observability_impact, :sequence )`, ).run({ ":milestone_id": s.milestoneId, @@ -992,6 +1005,7 @@ export function insertSlice(s: { ":proof_level": s.planning?.proofLevel ?? "", ":integration_closure": s.planning?.integrationClosure ?? "", ":observability_impact": s.planning?.observabilityImpact ?? "", + ":sequence": s.sequence ?? 0, }); } @@ -1032,6 +1046,7 @@ export function insertTask(t: { keyFiles?: string[]; keyDecisions?: string[]; fullSummaryMd?: string; + sequence?: number; planning?: Partial; }): void { if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); @@ -1040,12 +1055,12 @@ export function insertTask(t: { milestone_id, slice_id, id, title, status, one_liner, narrative, verification_result, duration, completed_at, blocker_discovered, deviations, known_issues, key_files, key_decisions, full_summary_md, - description, estimate, files, verify, inputs, expected_output, observability_impact + description, estimate, files, verify, inputs, expected_output, observability_impact, sequence ) VALUES ( :milestone_id, :slice_id, :id, :title, :status, :one_liner, :narrative, :verification_result, :duration, :completed_at, :blocker_discovered, :deviations, :known_issues, :key_files, :key_decisions, :full_summary_md, - :description, :estimate, :files, :verify, :inputs, :expected_output, :observability_impact + :description, :estimate, :files, :verify, :inputs, :expected_output, :observability_impact, :sequence )`, ).run({ ":milestone_id": t.milestoneId, @@ -1071,6 +1086,7 @@ export function insertTask(t: { ":inputs": JSON.stringify(t.planning?.inputs ?? []), ":expected_output": JSON.stringify(t.planning?.expectedOutput ?? []), ":observability_impact": t.planning?.observabilityImpact ?? "", + ":sequence": t.sequence ?? 0, }); } @@ -1133,6 +1149,7 @@ export interface SliceRow { proof_level: string; integration_closure: string; observability_impact: string; + sequence: number; } function rowToSlice(row: Record): SliceRow { @@ -1153,6 +1170,7 @@ function rowToSlice(row: Record): SliceRow { proof_level: (row["proof_level"] as string) ?? "", integration_closure: (row["integration_closure"] as string) ?? "", observability_impact: (row["observability_impact"] as string) ?? "", + sequence: (row["sequence"] as number) ?? 0, }; } @@ -1200,6 +1218,7 @@ export interface TaskRow { inputs: string[]; expected_output: string[]; observability_impact: string; + sequence: number; } function rowToTask(row: Record): TaskRow { @@ -1227,6 +1246,7 @@ function rowToTask(row: Record): TaskRow { inputs: JSON.parse((row["inputs"] as string) || "[]"), expected_output: JSON.parse((row["expected_output"] as string) || "[]"), observability_impact: (row["observability_impact"] as string) ?? "", + sequence: (row["sequence"] as number) ?? 0, }; } @@ -1242,7 +1262,7 @@ export function getTask(milestoneId: string, sliceId: string, taskId: string): T export function getSliceTasks(milestoneId: string, sliceId: string): TaskRow[] { if (!currentDb) return []; const rows = currentDb.prepare( - "SELECT * FROM tasks WHERE milestone_id = :mid AND slice_id = :sid ORDER BY id", + "SELECT * FROM tasks WHERE milestone_id = :mid AND slice_id = :sid ORDER BY sequence, id", ).all({ ":mid": milestoneId, ":sid": sliceId }); return rows.map(rowToTask); } @@ -1361,7 +1381,7 @@ export function getActiveMilestoneFromDb(): MilestoneRow | null { export function getActiveSliceFromDb(milestoneId: string): SliceRow | null { if (!currentDb) return null; const rows = currentDb.prepare( - "SELECT * FROM slices WHERE milestone_id = :mid AND status NOT IN ('complete', 'done') ORDER BY id", + "SELECT * FROM slices WHERE milestone_id = :mid AND status NOT IN ('complete', 'done') ORDER BY sequence, id", ).all({ ":mid": milestoneId }); if (rows.length === 0) return null; @@ -1382,7 +1402,7 @@ export function getActiveSliceFromDb(milestoneId: string): SliceRow | null { export function getActiveTaskFromDb(milestoneId: string, sliceId: string): TaskRow | null { if (!currentDb) return null; const row = currentDb.prepare( - "SELECT * FROM tasks WHERE milestone_id = :mid AND slice_id = :sid AND status NOT IN ('complete', 'done') ORDER BY id LIMIT 1", + "SELECT * FROM tasks WHERE milestone_id = :mid AND slice_id = :sid AND status NOT IN ('complete', 'done') ORDER BY sequence, id LIMIT 1", ).get({ ":mid": milestoneId, ":sid": sliceId }); if (!row) return null; return rowToTask(row); @@ -1390,7 +1410,7 @@ export function getActiveTaskFromDb(milestoneId: string, sliceId: string): TaskR export function getMilestoneSlices(milestoneId: string): SliceRow[] { if (!currentDb) return []; - const rows = currentDb.prepare("SELECT * FROM slices WHERE milestone_id = :mid ORDER BY id").all({ ":mid": milestoneId }); + const rows = currentDb.prepare("SELECT * FROM slices WHERE milestone_id = :mid ORDER BY sequence, id").all({ ":mid": milestoneId }); return rows.map(rowToSlice); } diff --git a/src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts b/src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts new file mode 100644 index 000000000..44010ae15 --- /dev/null +++ b/src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts @@ -0,0 +1,176 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, + getMilestoneSlices, + getSliceTasks, + getActiveSliceFromDb, + getActiveTaskFromDb, +} from '../gsd-db.ts'; + +function makeTmp(): string { + return mkdtempSync(join(tmpdir(), 'gsd-v9-')); +} + +function cleanup(base: string): void { + try { closeDatabase(); } catch { /* noop */ } + try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ } +} + +test('schema v9: migration adds sequence column to slices and tasks', () => { + const base = makeTmp(); + const dbPath = join(base, 'gsd.db'); + openDatabase(dbPath); + try { + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + // If sequence column doesn't exist, these would throw + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Slice 1', sequence: 5 }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Task 1', sequence: 3 }); + + const slices = getMilestoneSlices('M001'); + assert.equal(slices.length, 1); + assert.equal(slices[0]!.sequence, 5); + + const tasks = getSliceTasks('M001', 'S01'); + assert.equal(tasks.length, 1); + assert.equal(tasks[0]!.sequence, 3); + } finally { + cleanup(base); + } +}); + +test('schema v9: getMilestoneSlices returns slices ordered by sequence then id', () => { + const base = makeTmp(); + openDatabase(join(base, 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + + // Insert in reverse lexicographic order with sequence overriding id order + insertSlice({ id: 'S03', milestoneId: 'M001', title: 'Third by id, first by seq', sequence: 1 }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First by id, third by seq', sequence: 3 }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second by id, second by seq', sequence: 2 }); + + const slices = getMilestoneSlices('M001'); + assert.equal(slices.length, 3); + assert.equal(slices[0]!.id, 'S03', 'sequence=1 should be first'); + assert.equal(slices[1]!.id, 'S02', 'sequence=2 should be second'); + assert.equal(slices[2]!.id, 'S01', 'sequence=3 should be third'); + } finally { + cleanup(base); + } +}); + +test('schema v9: getSliceTasks returns tasks ordered by sequence then id', () => { + const base = makeTmp(); + openDatabase(join(base, 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Slice' }); + + // Insert tasks with sequence overriding id order + insertTask({ id: 'T03', sliceId: 'S01', milestoneId: 'M001', title: 'Third by id', sequence: 1 }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First by id', sequence: 3 }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Second by id', sequence: 2 }); + + const tasks = getSliceTasks('M001', 'S01'); + assert.equal(tasks.length, 3); + assert.equal(tasks[0]!.id, 'T03', 'sequence=1 should be first'); + assert.equal(tasks[1]!.id, 'T02', 'sequence=2 should be second'); + assert.equal(tasks[2]!.id, 'T01', 'sequence=3 should be third'); + } finally { + cleanup(base); + } +}); + +test('schema v9: default sequence (0) falls back to id-based ordering', () => { + const base = makeTmp(); + openDatabase(join(base, 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + + // All slices with default sequence=0 should sort by id + insertSlice({ id: 'S03', milestoneId: 'M001', title: 'Third' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First' }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second' }); + + const slices = getMilestoneSlices('M001'); + assert.equal(slices[0]!.id, 'S01', 'default seq=0: should sort by id'); + assert.equal(slices[1]!.id, 'S02'); + assert.equal(slices[2]!.id, 'S03'); + + // Same for tasks + insertSlice({ id: 'S04', milestoneId: 'M001', title: 'Container' }); + insertTask({ id: 'T02', sliceId: 'S04', milestoneId: 'M001', title: 'B' }); + insertTask({ id: 'T01', sliceId: 'S04', milestoneId: 'M001', title: 'A' }); + insertTask({ id: 'T03', sliceId: 'S04', milestoneId: 'M001', title: 'C' }); + + const tasks = getSliceTasks('M001', 'S04'); + assert.equal(tasks[0]!.id, 'T01'); + assert.equal(tasks[1]!.id, 'T02'); + assert.equal(tasks[2]!.id, 'T03'); + } finally { + cleanup(base); + } +}); + +test('schema v9: getActiveSliceFromDb respects sequence ordering', () => { + const base = makeTmp(); + openDatabase(join(base, 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + + // S02 has lower sequence so should be active first despite higher id than S01 + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Higher seq', status: 'pending', sequence: 5 }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Lower seq', status: 'pending', sequence: 2 }); + + const active = getActiveSliceFromDb('M001'); + assert.ok(active); + assert.equal(active!.id, 'S02', 'lower sequence should be active first'); + } finally { + cleanup(base); + } +}); + +test('schema v9: getActiveTaskFromDb respects sequence ordering', () => { + const base = makeTmp(); + openDatabase(join(base, 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Slice' }); + + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Higher seq', status: 'pending', sequence: 10 }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Lower seq', status: 'pending', sequence: 1 }); + + const active = getActiveTaskFromDb('M001', 'S01'); + assert.ok(active); + assert.equal(active!.id, 'T02', 'lower sequence should be active first'); + } finally { + cleanup(base); + } +}); + +test('schema v9: sequence field defaults to 0 when not provided', () => { + const base = makeTmp(); + openDatabase(join(base, 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'No seq' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'No seq' }); + + const slices = getMilestoneSlices('M001'); + assert.equal(slices[0]!.sequence, 0, 'slice sequence defaults to 0'); + + const tasks = getSliceTasks('M001', 'S01'); + assert.equal(tasks[0]!.sequence, 0, 'task sequence defaults to 0'); + } finally { + cleanup(base); + } +});