diff options
| author | soryu <soryu@soryu.co> | 2026-05-08 11:29:56 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-08 11:29:56 +0100 |
| commit | e00be74c8b575c725829677aadeb755ee81454d0 (patch) | |
| tree | 6804ba099978b32f94e3436bb373c2b0bd07b84d /makima/src/db/repository.rs | |
| parent | d7048aaef8ffa483c63a765d2d35ae01389e331f (diff) | |
| download | soryu-e00be74c8b575c725829677aadeb755ee81454d0.tar.gz soryu-e00be74c8b575c725829677aadeb755ee81454d0.zip | |
feat(directives): unified contracts surface — backbone (#128)
This is the backbone PR for the unified directive workflow. A directive
holds a sequence of contracts; each contract is a spec body whose
execution drives tasks in the directive's shared worktree. Lifecycle
(Lock & Start, queue scheduler, drag-reorder) lands in follow-ups.
What's in this PR:
- Migration adds `position` (queue order) and `merge_mode`
(shared|own_pr) columns to directive_documents. The actual table
rename is deferred — the legacy `contracts` table from the old
contracts system still exists, and the rename collision waits for
Phase 5 to drop legacy contracts.
- Repository: list orders by position; create assigns next-position;
update accepts merge_mode; new reorder_directive_document_position
shifts siblings inside a transaction.
- HTTP: endpoints aliased under /api/v1/directives/{id}/contracts and
/api/v1/contracts/{id}/... with a new /contracts/{id}/reorder.
- Frontend: api types renamed `DirectiveContract*` (avoiding the
legacy `Contract` type collision); document-directives.tsx imports
via aliases so the rest of the file is untouched.
Internal struct + table names stay `DirectiveDocument` /
`directive_documents` until the legacy contracts cleanup.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/src/db/repository.rs')
| -rw-r--r-- | makima/src/db/repository.rs | 117 |
1 files changed, 112 insertions, 5 deletions
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 5d8ba82..12d5e4d 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5785,7 +5785,12 @@ pub async fn clear_pending_directive_steps( // Directive Document CRUD // ============================================================================= -/// List all documents under a directive, ordered by creation time. +/// List all contracts under a directive in queue order. +/// +/// Ordered by `position` (lower = earlier), with `created_at` as a stable +/// tie-break. Position is the queue order in the unified directive UI; +/// only one contract is active at a time, and the next-up contract is +/// the lowest-position non-shipped row. pub async fn list_directive_documents( pool: &PgPool, directive_id: Uuid, @@ -5794,7 +5799,7 @@ pub async fn list_directive_documents( r#" SELECT * FROM directive_documents WHERE directive_id = $1 - ORDER BY created_at + ORDER BY position ASC, created_at ASC "#, ) .bind(directive_id) @@ -5815,7 +5820,14 @@ pub async fn get_directive_document( .await } -/// Create a new directive document. Status defaults to 'draft'. +/// Create a new directive document (contract). Status defaults to 'draft'. +/// +/// The new row's `position` is computed server-side as +/// `MAX(position) + 1` over the directive's existing contracts, so it +/// lands at the bottom of the queue. Callers that want to insert in the +/// middle should call `reorder_directive_document_position` afterwards. +/// `merge_mode` defaults to 'shared' on creation; flip later via +/// `update_directive_document`. pub async fn create_directive_document( pool: &PgPool, directive_id: Uuid, @@ -5824,8 +5836,14 @@ pub async fn create_directive_document( ) -> Result<DirectiveDocument, sqlx::Error> { sqlx::query_as::<_, DirectiveDocument>( r#" - INSERT INTO directive_documents (directive_id, title, body, status) - VALUES ($1, $2, $3, 'draft') + INSERT INTO directive_documents (directive_id, title, body, status, position) + VALUES ( + $1, $2, $3, 'draft', + COALESCE( + (SELECT MAX(position) + 1 FROM directive_documents WHERE directive_id = $1), + 0 + ) + ) RETURNING * "#, ) @@ -5848,6 +5866,7 @@ pub async fn update_directive_document( document_id: Uuid, title: Option<&str>, body: Option<&str>, + merge_mode: Option<&str>, ) -> Result<Option<DirectiveDocument>, sqlx::Error> { let current = sqlx::query_as::<_, DirectiveDocument>( r#"SELECT * FROM directive_documents WHERE id = $1"#, @@ -5863,6 +5882,7 @@ pub async fn update_directive_document( let new_title = title.unwrap_or(¤t.title); let new_body = body.unwrap_or(¤t.body); + let new_merge_mode = merge_mode.unwrap_or(¤t.merge_mode); let body_changed = new_body != current.body; // Reactivation rule: editing the body of a shipped doc flips it back @@ -5883,6 +5903,7 @@ pub async fn update_directive_document( body = $3, status = $4, shipped_at = CASE WHEN $5 THEN NULL ELSE shipped_at END, + merge_mode = $6, version = version + 1, updated_at = NOW() WHERE id = $1 @@ -5894,12 +5915,98 @@ pub async fn update_directive_document( .bind(new_body) .bind(new_status) .bind(reactivate_from_shipped) + .bind(new_merge_mode) .fetch_optional(pool) .await?; Ok(result) } +/// Move a contract to a new queue position within its directive. +/// +/// Implementation: a single SQL CTE that bumps siblings out of the way +/// based on whether we're moving forward (later) or backward (earlier). +/// Returns the updated contract row. +pub async fn reorder_directive_document_position( + pool: &PgPool, + document_id: Uuid, + new_position: i32, +) -> Result<Option<DirectiveDocument>, sqlx::Error> { + let mut tx = pool.begin().await?; + + let current = sqlx::query_as::<_, DirectiveDocument>( + r#"SELECT * FROM directive_documents WHERE id = $1"#, + ) + .bind(document_id) + .fetch_optional(&mut *tx) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + if current.position == new_position { + tx.commit().await?; + return Ok(Some(current)); + } + + // Shift siblings to make room. Moving forward (new > old) drags the + // intermediate range back by one; moving backward pushes it forward. + if new_position > current.position { + sqlx::query( + r#" + UPDATE directive_documents + SET position = position - 1 + WHERE directive_id = $1 + AND id <> $2 + AND position > $3 + AND position <= $4 + "#, + ) + .bind(current.directive_id) + .bind(document_id) + .bind(current.position) + .bind(new_position) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + r#" + UPDATE directive_documents + SET position = position + 1 + WHERE directive_id = $1 + AND id <> $2 + AND position >= $3 + AND position < $4 + "#, + ) + .bind(current.directive_id) + .bind(document_id) + .bind(new_position) + .bind(current.position) + .execute(&mut *tx) + .await?; + } + + let result = sqlx::query_as::<_, DirectiveDocument>( + r#" + UPDATE directive_documents + SET position = $2, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(document_id) + .bind(new_position) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().await?; + Ok(result) +} + /// Mark a directive document as shipped (PR raised). Sets pr_url, optional /// pr_branch, status = 'shipped', shipped_at = NOW(), and bumps version. pub async fn mark_directive_document_shipped( |
