From 5e10e809927846a0b11b4d7a491a87534b40597d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:26:35 +0000 Subject: [PATCH] Layers & model boundaries (v0.4.6): asset layers, Atlas layer columns, move_asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/specs/layers-and-boundaries-spec.md — the product-vs- asset boundary rule made operational: - assets.layer (migration 0016, both dialects): free text with a conventional taxonomy (edge/frontend/backend/domain/data/infra/ shared). Unset layers display a default derived from asset type (app→frontend, service→backend, datastore→data, platform→infra, library→shared) — computed at display time, never written, so existing instances get a layered map with zero migration - Atlas: 'Columns: Product | Layer' toggle; auto-defaults to Layer columns when a single product is in scope (the product axis degenerates to one column — the MindStaq case); layer columns order by the taxonomy so edges mostly flow one direction; node sublabels show the product in layer mode with multi-product scope. Grid gains a layer chip; Table a sortable Layer column - moveAsset(assetId, targetProductId): blocked while draft/active plans target the asset (returns blockingPlans for retargeting); work items follow the asset; history — release stamps, completed- plan links, capabilities, design log — is preserved untouched; no-op when already home - MCP: layer on create_asset/update_asset, new move_asset tool (42 total), get_modeling_guide gains the boundaries section (rule, worked examples, refining recipe) and layer taxonomy; boundary heuristics added to create_product/create_asset descriptions - UI: layer input (with taxonomy datalist) in the asset edit panel, layer chip on the asset detail header - Demo seed assigns 4 explicit layers (API Gateway/Mobile BFF→edge, Plan Engine→domain, UI Component Library→frontend); the rest demo type defaults - 5 new tests (206 total); docs synced (app-spec, ai-agents guide, atlas guide, mcp spec, site); spec marked shipped; version 0.4.6 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y7FhGxYjnJHJ7m9MpH2dgJ --- README.md | 4 +- app/(dashboard)/actions.ts | 3 + app/(dashboard)/assets/[id]/page.tsx | 3 + app/(dashboard)/assets/asset-map.tsx | 72 +- app/(dashboard)/assets/assets-client.tsx | 18 +- .../products/[slug]/assets-section.tsx | 16 +- app/api/mcp/[transport]/route.ts | 38 +- docs/app-spec.md | 10 +- docs/guides/ai-agents.md | 18 +- docs/guides/asset-atlas.md | 11 +- docs/index.html | 15 +- docs/specs/layers-and-boundaries-spec.md | 8 +- docs/specs/mcp-server-spec.md | 5 +- .../migrations/postgres/0016_asset_layer.sql | 1 + lib/db/migrations/postgres/meta/_journal.json | 7 + lib/db/migrations/sqlite/0016_asset_layer.sql | 1 + .../migrations/sqlite/meta/0016_snapshot.json | 2706 +++++++++++++++++ lib/db/migrations/sqlite/meta/_journal.json | 7 + lib/db/mutations.ts | 43 +- lib/db/queries.ts | 9 + lib/db/schema.pg.ts | 1 + lib/db/schema.sqlite.ts | 1 + lib/db/seed-demo.ts | 14 + lib/types.ts | 22 + package.json | 2 +- tests/lib/db/asset-move.test.ts | 97 + 26 files changed, 3085 insertions(+), 47 deletions(-) create mode 100644 lib/db/migrations/postgres/0016_asset_layer.sql create mode 100644 lib/db/migrations/sqlite/0016_asset_layer.sql create mode 100644 lib/db/migrations/sqlite/meta/0016_snapshot.json create mode 100644 tests/lib/db/asset-move.test.ts diff --git a/README.md b/README.md index 132f618..27c8251 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ CodePlans sits between your issue tracker and your architecture diagram: | Analytics wired to real data (velocity, effort accuracy, debt by product) | ✅ Available | | Activity feed | ✅ Available | | GitHub, GitLab, Jira, Asana & Linear integrations (pull-only mirror into work items) | ✅ Available | -| MCP server — 41 tools incl. product/asset/dependency management, releases, asset design notes & the asset record | ✅ Available | +| MCP server — 42 tools incl. product/asset/dependency management, model refactoring (move_asset), releases, asset design notes & the asset record | ✅ Available | | Milestone-linked plans with mirrored tasks (mixed mode) | ✅ Available | | PR auto-linking (plan-asset PR status refreshed on sync) | ✅ Available | | Releases — delivery grouping with per-asset version stamps & derived release notes | ✅ Available | @@ -413,7 +413,7 @@ Your agent can then read plans/work items/tech debt and (with a write-scope key) - [x] **v0.4.3 — AI drafting (feature-flagged):** release notes & design notes drafted from delivered work, always landing in an editor - [x] **v0.4.4 — Asset Record (Phase A):** per-asset capabilities register with delivery lineage, graduation from resolved work items, derived known-issues/debt sections, tombstoned removals, `get_asset_record` + `graduate_work_item` MCP tools (41 total) - [x] **v0.4.5 — Asset Atlas** (see [`docs/specs/asset-atlas-spec.md`](docs/specs/asset-atlas-spec.md)): top-level Assets view — a live system map (products as columns, dependency edges, health/debt/activity lenses, blast-radius hover) plus grid and sortable table views -- [ ] **v0.4.6 — Layers & model boundaries** (see [`docs/specs/layers-and-boundaries-spec.md`](docs/specs/layers-and-boundaries-spec.md)): asset `layer` field, Atlas layer columns for single-product systems, `move_asset` model refactoring, boundary guidance in MCP +- [x] **v0.4.6 — Layers & model boundaries** (see [`docs/specs/layers-and-boundaries-spec.md`](docs/specs/layers-and-boundaries-spec.md)): asset `layer` field with display-time type defaults, Atlas layer columns (auto for single-product scope), `move_asset` model refactoring, boundary rule + layer taxonomy in the MCP modeling guide (42 tools) - [ ] **v0.4.7+ — Reconciliation & round-trip engineering** (see [`docs/specs/asset-record-spec.md`](docs/specs/asset-record-spec.md)): agent reconciliation proposals, release publishing - [ ] AI-assisted effort estimation - [ ] Billing / subscription management (hosted tier, optional & feature-flagged) diff --git a/app/(dashboard)/actions.ts b/app/(dashboard)/actions.ts index 75db99f..e65d21d 100644 --- a/app/(dashboard)/actions.ts +++ b/app/(dashboard)/actions.ts @@ -212,6 +212,7 @@ export async function updateAssetAction(id: string, productSlug: string, formDat const repositoryUrl = (formData.get('repositoryUrl') as string) || undefined const repoPath = (formData.get('repoPath') as string) || undefined const documentationUrl = (formData.get('documentationUrl') as string) || undefined + const layerRaw = formData.get('layer') await updateAsset(id, { name, @@ -223,6 +224,8 @@ export async function updateAssetAction(id: string, productSlug: string, formDat repositoryUrl, repoPath, documentationUrl, + // Absent field = form without the input (no change); blank = clear. + ...(layerRaw !== null ? { layer: (layerRaw as string).trim() || null } : {}), }) revalidatePath(`/products/${productSlug}`) diff --git a/app/(dashboard)/assets/[id]/page.tsx b/app/(dashboard)/assets/[id]/page.tsx index 12ae60d..750c007 100644 --- a/app/(dashboard)/assets/[id]/page.tsx +++ b/app/(dashboard)/assets/[id]/page.tsx @@ -129,6 +129,9 @@ export default async function AssetDetailPage({ params }: { params: Promise<{ id

{asset.name}

{assetTypeLabels[asset.type]} + {asset.layer && ( + {asset.layer} + )} {asset.health} diff --git a/app/(dashboard)/assets/asset-map.tsx b/app/(dashboard)/assets/asset-map.tsx index 5fa7403..3d10de5 100644 --- a/app/(dashboard)/assets/asset-map.tsx +++ b/app/(dashboard)/assets/asset-map.tsx @@ -3,9 +3,10 @@ import { useMemo, useState } from 'react' import Link from 'next/link' import { Badge } from '@/components/ui/badge' -import { HeartPulse, Wrench, Activity } from 'lucide-react' +import { HeartPulse, Wrench, Activity, Package, Rows3 } from 'lucide-react' import { cn } from '@/lib/utils' import type { AssetInventoryRow, DependencyEdge } from '@/lib/db/queries' +import { LAYER_TAXONOMY } from '@/lib/types' import { assetTypeIcons } from './assets-client' /** @@ -54,22 +55,45 @@ const edgeDash: Record = { } type LaidOutNode = { asset: AssetInventoryRow; x: number; y: number } +type GroupBy = 'product' | 'layer' + +/** Taxonomy layers in dependency-flow order, unknown layers appended alphabetically. */ +function layerOrder(layers: Set): string[] { + const known = LAYER_TAXONOMY.filter((l) => layers.has(l)) + const unknown = [...layers].filter((l) => !(LAYER_TAXONOMY as readonly string[]).includes(l)).sort() + return [...known, ...unknown] +} export function AssetMap({ assets, edges }: { assets: AssetInventoryRow[]; edges: DependencyEdge[] }) { const [lens, setLens] = useState('health') const [hoverId, setHoverId] = useState(null) + const productCount = useMemo(() => new Set(assets.map((a) => a.productId)).size, [assets]) + // Single-product scope: the product axis degenerates to one column, so + // default to layer columns (layers-and-boundaries-spec §4). Toggle overrides. + const [groupByChoice, setGroupByChoice] = useState(null) + const groupBy: GroupBy = groupByChoice ?? (productCount <= 1 ? 'layer' : 'product') const layout = useMemo(() => { - // Column per product, in first-seen order. + // One column per group, in first-seen (product) or taxonomy (layer) order. + const byGroup = new Map() const productOrder: { id: string; name: string }[] = [] - const byProduct = new Map() - for (const a of assets) { - if (!byProduct.has(a.productId)) { - byProduct.set(a.productId, []) - productOrder.push({ id: a.productId, name: a.productName }) + if (groupBy === 'product') { + for (const a of assets) { + if (!byGroup.has(a.productId)) { + byGroup.set(a.productId, []) + productOrder.push({ id: a.productId, name: a.productName }) + } + byGroup.get(a.productId)!.push(a) + } + } else { + for (const a of assets) { + byGroup.set(a.effectiveLayer, [...(byGroup.get(a.effectiveLayer) ?? []), a]) + } + for (const l of layerOrder(new Set(byGroup.keys()))) { + productOrder.push({ id: l, name: l }) } - byProduct.get(a.productId)!.push(a) } + const byProduct = byGroup // Two barycenter sweeps: order nodes within a column by the average row // of their neighbors, so cross-column edges stay short and legible. @@ -114,7 +138,7 @@ export function AssetMap({ assets, edges }: { assets: AssetInventoryRow[]; edges width: PAD * 2 + productOrder.length * COL_W + (productOrder.length - 1) * COL_GAP + 90, height: PAD * 2 + HEADER_H + maxRows * (NODE_H + NODE_GAP) - NODE_GAP, } - }, [assets, edges]) + }, [assets, edges, groupBy]) const neighborsOfHover = useMemo(() => { if (!hoverId) return null @@ -149,8 +173,30 @@ export function AssetMap({ assets, edges }: { assets: AssetInventoryRow[]; edges
{/* Lens picker + edge legend */}
-
- Lens +
+ Columns + {( + [ + { key: 'product' as GroupBy, label: 'Product', icon: Package }, + { key: 'layer' as GroupBy, label: 'Layer', icon: Rows3 }, + ] + ).map(({ key, label, icon: GIcon }) => ( + + ))} + Lens {lensOptions.map(({ key, label, icon: LIcon }) => (
- {assetTypeLabels[a.type]} +
+ {assetTypeLabels[a.type]} + + {a.effectiveLayer} + +
@@ -253,7 +262,7 @@ function AssetGrid({ assets }: { assets: AssetInventoryRow[] }) { ) } -type SortKey = 'name' | 'product' | 'debt' | 'plans' | 'shipped' +type SortKey = 'name' | 'product' | 'layer' | 'debt' | 'plans' | 'shipped' function AssetTable({ assets }: { assets: AssetInventoryRow[] }) { const [sort, setSort] = useState('name') @@ -265,6 +274,7 @@ function AssetTable({ assets }: { assets: AssetInventoryRow[] }) { rows.sort((a, b) => { switch (sort) { case 'product': return dir * (a.productName.localeCompare(b.productName) || a.name.localeCompare(b.name)) + case 'layer': return dir * (a.effectiveLayer.localeCompare(b.effectiveLayer) || a.name.localeCompare(b.name)) case 'debt': return dir * (a.effectiveDebtScore - b.effectiveDebtScore) case 'plans': return dir * (a.activePlanCount - b.activePlanCount) case 'shipped': return dir * (a.lastShippedAt ?? '').localeCompare(b.lastShippedAt ?? '') @@ -294,6 +304,7 @@ function AssetTable({ assets }: { assets: AssetInventoryRow[] }) { {header('name', 'Asset')} {header('product', 'Product')} + {header('layer', 'Layer')} Health Version {header('debt', 'Debt', 'text-right')} @@ -314,6 +325,9 @@ function AssetTable({ assets }: { assets: AssetInventoryRow[] }) { {a.productName} + + {a.effectiveLayer} + {a.health} diff --git a/app/(dashboard)/products/[slug]/assets-section.tsx b/app/(dashboard)/products/[slug]/assets-section.tsx index d6601d6..b7e59c3 100644 --- a/app/(dashboard)/products/[slug]/assets-section.tsx +++ b/app/(dashboard)/products/[slug]/assets-section.tsx @@ -392,9 +392,19 @@ function AssetEditor({
-
- - +
+
+ + +
+
+ + + + +

{isPending ? 'Saving…' : 'Changes save automatically'}

diff --git a/app/api/mcp/[transport]/route.ts b/app/api/mcp/[transport]/route.ts index 0bc81b9..b8a60a2 100644 --- a/app/api/mcp/[transport]/route.ts +++ b/app/api/mcp/[transport]/route.ts @@ -42,6 +42,7 @@ import { setReleaseAsset, createDesignNote, graduateWorkItem, + moveAsset, } from '@/lib/db/mutations' import { getAssetOptions, getAssetDetail } from '@/lib/db/queries' import { resolveAssigneeEmail } from '@/lib/mcp/users' @@ -158,6 +159,21 @@ const handler = createMcpHandler( 'How to break a codebase (especially a monorepo) into CodePlans products, assets, and dependency edges. Read this before bulk-creating assets.', {}, async () => json({ + boundaries: { + rule: 'A product is something you SHIP, VERSION, and GRANT ACCESS TO as a unit. An asset is something you CHANGE. When in doubt, prefer fewer products with more assets — assets are movable (move_asset); products are commitments (access control, plans, and releases all scope by product, and releases exist to coordinate a revision ACROSS assets — products drawn too small make coordinated releases impossible to express).', + examples: [ + 'Single SaaS (one app + API + shared libs + datastores): ONE product, many assets — even at 30-40 assets. Use layers for internal structure.', + 'Portfolio (web platform + mobile apps on app-store trains + public API with its own versioning contract): several products — each genuinely ships on its own cadence to its own audience.', + 'Monorepo: orthogonal — model the shipping boundary, not the folder structure (repoPath maps folders).', + 'Internal platform consumed by other teams on its own version contract: a product — that is an audience.', + ], + refining: 'Audit the Atlas → assign explicit layers (update_asset) → move_asset where a boundary was drawn wrong (blocked while draft/active plans target the asset; work items follow; history is preserved) → re-check the map.', + }, + layers: { + purpose: 'A layer describes where an asset sits INSIDE its product — structure, not boundary. Layers drive the Atlas map columns for single-product systems. Free text; prefer the taxonomy.', + taxonomy: ['edge', 'frontend', 'backend', 'domain', 'data', 'infra', 'shared'], + defaults: 'Unset layers display a default from asset type (app→frontend, service→backend, datastore→data, platform→infra, library→shared). Set explicit layers where the default is wrong — e.g. a library that is really domain logic gets layer "domain".', + }, product: 'A planning boundary, not a repo boundary. One product can span repos; a monorepo can host several products. Shared platform libraries serving multiple products go in a dedicated Platform product — cross-product dependency edges and impact analysis work.', assetTest: 'Model something as an asset only if: (a) code plans will target it, (b) tech debt will be registered against it, or (c) it must appear in blast-radius reports. Assets are coordination units, not folders.', tiers: [ @@ -174,7 +190,7 @@ const handler = createMcpHandler( ) server.tool( 'create_product', - 'Create a product in your workspace. A product is a PLANNING boundary, not a repo boundary — one product may span several repos, and shared platform libraries serving multiple products belong in their own "Platform" product (cross-product dependency edges work).', + 'Create a product in your workspace. Create a product ONLY for something shipped, versioned, and access-controlled as a unit — subsystems of one shippable system are assets (with layers), not products. A product is a PLANNING boundary, not a repo boundary — one product may span several repos, and shared platform libraries serving multiple products belong in their own "Platform" product (cross-product dependency edges work).', { name: z.string(), description: z.string().default(''), tags: z.array(z.string()).default([]), slug: z.string().optional() }, async ({ name, slug, ...rest }, extra) => { requireWrite(extra) @@ -204,7 +220,7 @@ const handler = createMcpHandler( server.tool( 'create_asset', - 'Add an asset to a product. Assets are COORDINATION units, not a folder inventory — model something only if plans will target it, debt will be registered against it, or it belongs in blast-radius reports. Apps and services: always. Libraries: only high-fanout/high-churn ones individually; cluster the long tail into domain-group assets (work items use their `area` field for lib-level precision inside a cluster). Use repoPath for monorepo folders. Call get_modeling_guide for the full heuristic.', + 'Add an asset to a product. Assets are COORDINATION units, not a folder inventory — model something only if plans will target it, debt will be registered against it, or it belongs in blast-radius reports. Apps and services: always. Libraries: only high-fanout/high-churn ones individually; cluster the long tail into domain-group assets (work items use their `area` field for lib-level precision inside a cluster). Use repoPath for monorepo folders, and `layer` (edge/frontend/backend/domain/data/infra/shared) for where the asset sits inside the product — subsystems are assets with layers, not new products. Call get_modeling_guide for the full heuristic.', { productId: z.string(), name: z.string(), @@ -214,6 +230,7 @@ const handler = createMcpHandler( repositoryUrl: z.string().optional(), repoPath: z.string().optional(), documentationUrl: z.string().optional(), + layer: z.string().optional(), ownerEmails: z.array(z.string()).optional(), }, async ({ ownerEmails, ...args }, extra) => { @@ -230,7 +247,7 @@ const handler = createMcpHandler( server.tool( 'update_asset', - 'Update an asset: description, notes (freeform ideation doc, markdown), tags, health, manual tech-debt score, repo details, owners (ownerEmails replaces the full owner set; [] clears it).', + 'Update an asset: description, notes (freeform ideation doc, markdown), tags, health, manual tech-debt score, repo details, layer (edge/frontend/backend/domain/data/infra/shared — where it sits inside the product), owners (ownerEmails replaces the full owner set; [] clears it).', { id: z.string(), name: z.string().optional(), @@ -242,6 +259,7 @@ const handler = createMcpHandler( repositoryUrl: z.string().optional(), repoPath: z.string().optional(), documentationUrl: z.string().optional(), + layer: z.string().optional(), ownerEmails: z.array(z.string()).optional(), }, async ({ id, ownerEmails, ...data }, extra) => { @@ -256,6 +274,20 @@ const handler = createMcpHandler( }, ) + server.tool( + 'move_asset', + 'Move an asset to another product — model refactoring for when a boundary was drawn wrong. Blocked while draft/active plans target the asset (the error lists them; retarget or complete first). Work items follow the asset; history (release stamps, completed-plan links, capabilities, design log) is preserved untouched.', + { assetId: z.string(), targetProductId: z.string() }, + async ({ assetId, targetProductId }, extra) => { + requireWrite(extra) + const userId = uid(extra) + const options = await getAssetOptions(userId) + if (!options.some((a) => a.id === assetId)) return json({ error: 'Asset not found or not accessible' }) + await assertProductAccess(userId, targetProductId) + return json(await moveAsset(assetId, targetProductId)) + }, + ) + server.tool( 'get_asset', 'Full asset context: description, notes, owners, debt score breakdown, linked code plans (with per-plan notes/PRs), and dependency edges in both directions.', diff --git a/docs/app-spec.md b/docs/app-spec.md index 9fd81f3..dec20b7 100644 --- a/docs/app-spec.md +++ b/docs/app-spec.md @@ -1,6 +1,6 @@ ## CodePlans App Spec -> **Status:** current implemented state as of **v0.4.5** (2026-08). For the target +> **Status:** current implemented state as of **v0.4.6** (2026-08). For the target > design and rationale, see `docs/specs/design-spec-v3.md` (all phases shipped), > `docs/specs/releases-and-asset-history-spec.md` (Phases A–D shipped), and > `docs/specs/asset-record-spec.md` (Phase A shipped; Phases B–C are the next @@ -8,7 +8,7 @@ ### Overview -CodePlans is a **code change coordination tool** for engineering teams. It organizes work around the hierarchy **Products → Assets → Code Plans → Tasks**, with **Work Items** (features, bugs, UX issues, tech debt) as the demand side linked many-to-many to code plans, per-asset **branch/PR tracking** on plans, **releases** grouping the plans that ship together (with per-asset version stamps and derived release notes), a per-asset **history timeline and design log**, a per-asset **record** (capabilities register graduated from delivered work), a top-level **Asset Atlas** (live system map with health/debt/activity lenses, plus grid/table views), **asset dependencies** with impact analysis, pull-only **integrations** that mirror external tracker items into work items, and a 41-tool **MCP server** for AI coding agents. Users track technical debt, coordinate architectural changes, and measure team velocity. Deployed at `codeplans.ai`. Stack: Next.js 16 (App Router), Drizzle ORM, pluggable auth/DB (SQLite local / Supabase+Postgres cloud). +CodePlans is a **code change coordination tool** for engineering teams. It organizes work around the hierarchy **Products → Assets → Code Plans → Tasks**, with **Work Items** (features, bugs, UX issues, tech debt) as the demand side linked many-to-many to code plans, per-asset **branch/PR tracking** on plans, **releases** grouping the plans that ship together (with per-asset version stamps and derived release notes), a per-asset **history timeline and design log**, a per-asset **record** (capabilities register graduated from delivered work), a top-level **Asset Atlas** (live system map with health/debt/activity lenses, plus grid/table views), **asset dependencies** with impact analysis, pull-only **integrations** that mirror external tracker items into work items, and a 42-tool **MCP server** for AI coding agents. Users track technical debt, coordinate architectural changes, and measure team velocity. Deployed at `codeplans.ai`. Stack: Next.js 16 (App Router), Drizzle ORM, pluggable auth/DB (SQLite local / Supabase+Postgres cloud). --- @@ -77,6 +77,7 @@ CodePlans is a **code change coordination tool** for engineering teams. It organ | status | `active\|deprecated\|planned` | default `active` | | techDebtScore | integer? | 0–100 scale | | repositoryUrl | text? | | +| layer | text? | v0.4.6 — where the asset sits inside its product; free text, taxonomy edge/frontend/backend/domain/data/infra/shared; display default derived from type | | documentationUrl | text? | | | metadata | JSON object | extensible | | createdAt / updatedAt | timestamp | | @@ -222,6 +223,7 @@ Provenance columns (`source` default `native`, `connectionId`, `externalId/Key/U | `removeReleaseAsset(releaseId, assetId)` | none | Returns `{ id }` or null | | `createDesignNote(data)` / `updateDesignNote` / `deleteDesignNote` | none | `authorKind` defaults `user`; MCP sets `agent` | | `graduateWorkItem(workItemId)` | none | Validates resolved + feature/enhancement + has asset; idempotent per work item; composes `originSummary` from item → first linked plan → that plan's release stamp | +| `moveAsset(assetId, targetProductId)` | at caller layer (both products) | (v0.4.6) Blocked while draft/active plans target the asset (returns `blockingPlans`); work items follow the asset; history (stamps, completed-plan links, capabilities, design log) untouched; no-op if already there | | `updateCapability(id, data)` | none | Edits title/description/area; lineage untouched | | `removeCapability(id, reason?)` | none | Tombstone: `status='removed'` + `removedAt`; reason appended to description as `**Removed:**` | @@ -295,7 +297,7 @@ Two tabs: **Assets** and **Code Plans**. #### `/assets` — Asset Atlas (v0.4.5; future phases in `docs/specs/asset-atlas-spec.md`) Top-level inventory of every visible asset (respects the global product scope + `?product=`), with a stats strip (totals, health breakdown, open debt, active plan targets), search (name/tag), and type/health filters. Three views: -- **Map** (default) — a system map drawn live from the inventory: products as columns, assets as nodes (type icon, name, shipped-version chip), `asset_dependencies` edges as curves (line style per dependency type: solid depends_on, dashed integrates_with, dotted aggregates, arrowheads at the target). **Lenses** recolor node accents and detail lines by Health, Debt (effective score thresholds 25/50), or Activity (active plan targets). Hovering an asset highlights its edges and neighbors and dims the rest (blast radius); click navigates to the asset. Hand-rolled deterministic layout (barycenter-ordered columns) — HTML nodes over an SVG underlay, no graph library. +- **Map** (default) — a system map drawn live from the inventory: columns are **products or layers** (v0.4.6 toggle; auto-defaults to Layer columns when a single product is in scope, since the product axis degenerates to one column), assets as nodes (type icon, name, shipped-version chip), `asset_dependencies` edges as curves (line style per dependency type: solid depends_on, dashed integrates_with, dotted aggregates, arrowheads at the target). Layer columns order by the taxonomy (edge → frontend → backend → domain → data → infra → shared, unknown layers appended); in Layer mode with multi-product scope, node sublabels show the product. **Lenses** recolor node accents and detail lines by Health, Debt (effective score thresholds 25/50), or Activity (active plan targets). Hovering an asset highlights its edges and neighbors and dims the rest (blast radius); click navigates to the asset. Hand-rolled deterministic layout (barycenter-ordered columns) — HTML nodes over an SVG underlay, no graph library. - **Grid** — cards: type icon, product, health dot, version chip, active plans / open debt / capabilities counts, debt-score bar, owners. - **Table** — sortable by name/product/debt/active plans/last shipped. @@ -373,7 +375,7 @@ Client component (`IntegrationsClient`) with: - Delete with confirm (mirrored items are kept, stop syncing) #### `/api/mcp/[transport]` — MCP server (no UI) -Streamable HTTP MCP endpoint (`mcp-handler`) with 41 tools wrapping the query/mutation layer (task assignees resolved by workspace-member email) — reads, plus management of products/assets/dependencies, plan lifecycle (activate/complete incl. write-back), plan targets, work items, tasks, releases (create/update/attach/version-stamp/ship — shipped releases reject mutation), `get_asset_history`, `record_design_note` (agent-attributed design-log entries), `get_asset_record`, and `graduate_work_item`; record deletes are deliberately excluded (link removals only) — see `docs/specs/mcp-server-spec.md`. Auth: `Authorization: Bearer cpk_…` resolved by `lib/mcp/auth.ts` to a user (scopes: read/write); 401 without a valid key. `proxy.ts` exempts this path from session redirects. Connect: `claude mcp add --transport http codeplans /api/mcp/mcp --header "Authorization: Bearer "`. +Streamable HTTP MCP endpoint (`mcp-handler`) with 42 tools wrapping the query/mutation layer (task assignees resolved by workspace-member email) — reads, plus management of products/assets/dependencies (incl. `move_asset` model refactoring and asset `layer`), plan lifecycle (activate/complete incl. write-back), plan targets, work items, tasks, releases (create/update/attach/version-stamp/ship — shipped releases reject mutation), `get_asset_history`, `record_design_note` (agent-attributed design-log entries), `get_asset_record`, and `graduate_work_item`; `get_modeling_guide` carries the boundary rule and layer taxonomy; record deletes are deliberately excluded (link removals only) — see `docs/specs/mcp-server-spec.md`. Auth: `Authorization: Bearer cpk_…` resolved by `lib/mcp/auth.ts` to a user (scopes: read/write); 401 without a valid key. `proxy.ts` exempts this path from session redirects. Connect: `claude mcp add --transport http codeplans /api/mcp/mcp --header "Authorization: Bearer "`. #### `/team` — Team Management - Requires org membership; shows message if no org diff --git a/docs/guides/ai-agents.md b/docs/guides/ai-agents.md index 78446c9..3832e31 100644 --- a/docs/guides/ai-agents.md +++ b/docs/guides/ai-agents.md @@ -24,7 +24,7 @@ claude mcp add --transport http codeplans http://localhost:3000/api/mcp \ Works identically against a local SQLite instance or a hosted deployment — the server lives inside the Next.js app. -## The tool catalog (41 tools) +## The tool catalog (42 tools) **Read** (any key): @@ -40,7 +40,7 @@ the server lives inside the Next.js app. | Area | Tools | |---|---| -| Model the graph | `create_product` · `update_product` · `create_asset` · `update_asset` · `add_asset_dependency` · `remove_asset_dependency` | +| Model the graph | `create_product` · `update_product` · `create_asset` · `update_asset` · `move_asset` · `add_asset_dependency` · `remove_asset_dependency` | | Demand | `create_work_item` · `update_work_item` · `update_work_item_status` · `link_work_item_to_plan` · `unlink_work_item_from_plan` | | Plans | `create_code_plan` · `update_code_plan` · `activate_plan` · `complete_plan` · `add_plan_asset` · `remove_plan_asset` · `update_plan_asset` | | Tasks | `create_task` · `update_task` · `update_task_status` | @@ -49,9 +49,17 @@ the server lives inside the Next.js app. | Asset record | `graduate_work_item` | Guardrails are enforced at the tool layer, not just the UI: mirrored items -reject writes to tracker-owned fields, shipped releases reject mutation, and -`create_asset` / `create_task` are idempotent so agent re-runs can't -duplicate records. +reject writes to tracker-owned fields, shipped releases reject mutation, +`move_asset` is blocked while open plans target the asset (the error lists +them), and `create_asset` / `create_task` are idempotent so agent re-runs +can't duplicate records. + +**Modeling boundaries** — before bulk-creating anything, call +`get_modeling_guide`: it carries the boundary rule (*products ship, assets +change*), the layer taxonomy (`edge / frontend / backend / domain / data / +infra / shared`), and a refining recipe (assign layers with `update_asset`, +relocate mis-homed assets with `move_asset` — work items follow, history is +preserved). ## Recommended agent workflows diff --git a/docs/guides/asset-atlas.md b/docs/guides/asset-atlas.md index 8820598..05a731c 100644 --- a/docs/guides/asset-atlas.md +++ b/docs/guides/asset-atlas.md @@ -16,7 +16,16 @@ updates. It cannot rot, because it is not a document. ## The Map -Products render as columns, assets as nodes. Each node carries its type icon, +Columns are **products or layers** — a toggle in the toolbar. With several +products in scope the map defaults to product columns; when the scope is a +single product (the global product filter, or an org with one product) it +automatically switches to **layer columns**, since one product would be one +column. Layers order edge → frontend → backend → domain → data → infra → +shared, which matches typical dependency flow. An asset's layer is set on +the asset (or over MCP); unset layers default sensibly from the asset type — +see the [concepts guide](concepts.md) for the boundary thinking. + +Assets render as nodes. Each node carries its type icon, name, and the version stamped by the latest shipped release. Dependency edges draw as curves between nodes, with the line style telling you the relationship: diff --git a/docs/index.html b/docs/index.html index 5538e5e..8278a9e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -350,7 +350,7 @@
-
Open Source · v0.4.5 · Beta Coming Soon
+
Open Source · v0.4.6 · Beta Coming Soon

Coordinate changes across
your entire architecture

CodePlans gives engineering teams a shared view of what's changing, in which components, @@ -495,7 +495,7 @@

Tracker Integrations

🤖

Built for AI Agents

-

An MCP server with 41 tools lets Claude Code, Cursor, and other agents read plans, log work, record design notes, and manage releases — with optional AI-drafted release notes.

+

An MCP server with 42 tools lets Claude Code, Cursor, and other agents read plans, log work, record design notes, and manage releases — with optional AI-drafted release notes.

@@ -539,7 +539,7 @@

AI drafts, humans decide

Claude Desktop, Cursor, Codex CLI, GitHub Copilot, and Antigravity. Keys act as your user, so org access rules and mirrored-field protections apply unchanged. See the AI agents guide - for the full 41-tool catalog. + for the full 42-tool catalog.

@@ -609,7 +609,7 @@

The Asset Atlas

Guide

Working with AI Agents

-

Connect Claude Code, Cursor, and friends over MCP: the full 41-tool catalog, recommended agent workflows, and the AI drafting flags.

+

Connect Claude Code, Cursor, and friends over MCP: the full 42-tool catalog, recommended agent workflows, and the AI drafting flags.

Read →
@@ -633,13 +633,13 @@

Asset Atlas Spec

Design

Layers & Boundaries Spec

-

Next (v0.4.6): the product-vs-asset rule made operational — asset layers, layer columns on the Atlas, and move_asset for refining a model.

+

Shipped in v0.4.6: the product-vs-asset rule made operational — asset layers, layer columns on the Atlas, and move_asset for refining a model.

Read →
Design

MCP Server Spec

-

How the MCP endpoint works: Streamable HTTP inside the app, API-key auth with read/write scopes, and the design of all 41 tools.

+

How the MCP endpoint works: Streamable HTTP inside the app, API-key auth with read/write scopes, and the design of all 42 tools.

Read →
@@ -947,12 +947,13 @@

Current feature status

GitHub, GitLab, Jira, Asana & Linear integrations (pull-only)Available Milestone-linked plans & PR auto-linkingAvailable Write-back: plan-completion comments on tracker issuesAvailable - MCP server (Claude Code, Cursor, Codex, Copilot, Antigravity — API-key auth, 41 tools)Available + MCP server (Claude Code, Cursor, Codex, Copilot, Antigravity — API-key auth, 42 tools)Available Releases — delivery grouping with per-asset version stamps & derived release notesAvailable Asset history timeline, version ladder & design log (user + agent authored)Available AI drafting — release notes & design notes (feature-flagged)Available Asset Record — per-asset capabilities register with delivery lineage (v0.4.4)Available Asset Atlas — live system map with health/debt/activity lenses (v0.4.5)Available + Layers & model boundaries — asset layers, Atlas layer columns, move_asset (v0.4.6)Available Asset Record — agent reconciliation & round-trip publishing (v0.4.7+)Planned AI-assisted effort estimationPlanned diff --git a/docs/specs/layers-and-boundaries-spec.md b/docs/specs/layers-and-boundaries-spec.md index 674a66d..51a379d 100644 --- a/docs/specs/layers-and-boundaries-spec.md +++ b/docs/specs/layers-and-boundaries-spec.md @@ -1,6 +1,8 @@ # Layers & Model Boundaries — Design Spec -> **Status: PROPOSED** (2026-08), targeting `v0.4.6`. Companion to +> **Status: SHIPPED `v0.4.6`** (2026-08; the §5 "Move to product…" UI action +> ships in a later pass — `move_asset` over MCP is the primary interface and +> is live). Companion to > `asset-atlas-spec.md` (this supplies the Atlas's second grouping axis) and > the concepts guide (`docs/guides/concepts.md`, which states the boundary > rule this spec operationalizes). Shifts asset-record reconciliation to @@ -117,8 +119,8 @@ write scope). Not an `update_asset` side effect: the guardrails need a home. (dependency edges are asset-to-asset and product-agnostic). UI: a "Move to product…" action in the asset edit dialog exposing the same -mutation and surfacing blocking plans. MCP is the primary interface — model -refactoring is agent work. +mutation and surfacing blocking plans (follow-up pass). MCP is the primary +interface — model refactoring is agent work. ## 6. MCP changes diff --git a/docs/specs/mcp-server-spec.md b/docs/specs/mcp-server-spec.md index e3b145d..7dd95a7 100644 --- a/docs/specs/mcp-server-spec.md +++ b/docs/specs/mcp-server-spec.md @@ -1,8 +1,9 @@ # MCP Server Interface — Design (v0.3.1) > **Status: SHIPPED in v0.3.1; management tools added in v0.3.2; release & asset-history -> tools added with releases-and-asset-history Phase C, plus `get_asset_record` / -> `graduate_work_item` from asset-record Phase A** (41 tools; plans and work items carry ownerEmail: +> tools added with releases-and-asset-history Phase C, `get_asset_record` / +> `graduate_work_item` from asset-record Phase A, and `move_asset` + asset +> `layer` params from layers-and-boundaries** (42 tools; plans and work items carry ownerEmail: > products, assets, dependencies, plan lifecycle/targets, full work-item edit). Companion to > `design-spec-v3.md`; reuses its data layer and access model unchanged. > diff --git a/lib/db/migrations/postgres/0016_asset_layer.sql b/lib/db/migrations/postgres/0016_asset_layer.sql new file mode 100644 index 0000000..b798b98 --- /dev/null +++ b/lib/db/migrations/postgres/0016_asset_layer.sql @@ -0,0 +1 @@ +ALTER TABLE "assets" ADD COLUMN "layer" text; diff --git a/lib/db/migrations/postgres/meta/_journal.json b/lib/db/migrations/postgres/meta/_journal.json index fea9625..2be8cd7 100644 --- a/lib/db/migrations/postgres/meta/_journal.json +++ b/lib/db/migrations/postgres/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1785812654617, "tag": "0015_asset_capabilities", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1785812654618, + "tag": "0016_asset_layer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/migrations/sqlite/0016_asset_layer.sql b/lib/db/migrations/sqlite/0016_asset_layer.sql new file mode 100644 index 0000000..20f3f3c --- /dev/null +++ b/lib/db/migrations/sqlite/0016_asset_layer.sql @@ -0,0 +1 @@ +ALTER TABLE `assets` ADD `layer` text; \ No newline at end of file diff --git a/lib/db/migrations/sqlite/meta/0016_snapshot.json b/lib/db/migrations/sqlite/meta/0016_snapshot.json new file mode 100644 index 0000000..81e18ea --- /dev/null +++ b/lib/db/migrations/sqlite/meta/0016_snapshot.json @@ -0,0 +1,2706 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "05a5a271-0d03-4182-857b-b78b1f66ac3f", + "prevId": "e218195c-21bc-4420-b274-6a1090d986d2", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read'" + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "asset_capabilities": { + "name": "asset_capabilities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'graduated'" + }, + "origin_work_item_id": { + "name": "origin_work_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_code_plan_id": { + "name": "origin_code_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_release_id": { + "name": "origin_release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_summary": { + "name": "origin_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "asset_capabilities_asset_idx": { + "name": "asset_capabilities_asset_idx", + "columns": [ + "asset_id" + ], + "isUnique": false + }, + "asset_capabilities_origin_item_idx": { + "name": "asset_capabilities_origin_item_idx", + "columns": [ + "origin_work_item_id" + ], + "isUnique": true, + "where": "\"asset_capabilities\".\"origin_work_item_id\" IS NOT NULL" + } + }, + "foreignKeys": { + "asset_capabilities_asset_id_assets_id_fk": { + "name": "asset_capabilities_asset_id_assets_id_fk", + "tableFrom": "asset_capabilities", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_capabilities_origin_work_item_id_work_items_id_fk": { + "name": "asset_capabilities_origin_work_item_id_work_items_id_fk", + "tableFrom": "asset_capabilities", + "tableTo": "work_items", + "columnsFrom": [ + "origin_work_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "asset_capabilities_origin_code_plan_id_code_plans_id_fk": { + "name": "asset_capabilities_origin_code_plan_id_code_plans_id_fk", + "tableFrom": "asset_capabilities", + "tableTo": "code_plans", + "columnsFrom": [ + "origin_code_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "asset_capabilities_origin_release_id_releases_id_fk": { + "name": "asset_capabilities_origin_release_id_releases_id_fk", + "tableFrom": "asset_capabilities", + "tableTo": "releases", + "columnsFrom": [ + "origin_release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "asset_dependencies": { + "name": "asset_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_asset_id": { + "name": "source_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_asset_id": { + "name": "target_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dependency_type": { + "name": "dependency_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "asset_dependencies_source_asset_id_assets_id_fk": { + "name": "asset_dependencies_source_asset_id_assets_id_fk", + "tableFrom": "asset_dependencies", + "tableTo": "assets", + "columnsFrom": [ + "source_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_dependencies_target_asset_id_assets_id_fk": { + "name": "asset_dependencies_target_asset_id_assets_id_fk", + "tableFrom": "asset_dependencies", + "tableTo": "assets", + "columnsFrom": [ + "target_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "asset_design_log": { + "name": "asset_design_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "code_plan_id": { + "name": "code_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "asset_design_log_asset_idx": { + "name": "asset_design_log_asset_idx", + "columns": [ + "asset_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "asset_design_log_asset_id_assets_id_fk": { + "name": "asset_design_log_asset_id_assets_id_fk", + "tableFrom": "asset_design_log", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_design_log_release_id_releases_id_fk": { + "name": "asset_design_log_release_id_releases_id_fk", + "tableFrom": "asset_design_log", + "tableTo": "releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "asset_design_log_code_plan_id_code_plans_id_fk": { + "name": "asset_design_log_code_plan_id_code_plans_id_fk", + "tableFrom": "asset_design_log", + "tableTo": "code_plans", + "columnsFrom": [ + "code_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "asset_design_log_author_id_users_id_fk": { + "name": "asset_design_log_author_id_users_id_fk", + "tableFrom": "asset_design_log", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "asset_owners": { + "name": "asset_owners", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "asset_owners_asset_user_idx": { + "name": "asset_owners_asset_user_idx", + "columns": [ + "asset_id", + "user_id" + ], + "isUnique": true + }, + "asset_owners_user_idx": { + "name": "asset_owners_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "asset_owners_asset_id_assets_id_fk": { + "name": "asset_owners_asset_id_assets_id_fk", + "tableFrom": "asset_owners", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_owners_user_id_users_id_fk": { + "name": "asset_owners_user_id_users_id_fk", + "tableFrom": "asset_owners", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "health": { + "name": "health", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'healthy'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "tech_debt_score": { + "name": "tech_debt_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layer": { + "name": "layer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "documentation_url": { + "name": "documentation_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "assets_product_id_products_id_fk": { + "name": "assets_product_id_products_id_fk", + "tableFrom": "assets", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "code_plan_assets": { + "name": "code_plan_assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code_plan_id": { + "name": "code_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_status": { + "name": "pr_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "code_plan_assets_plan_asset_idx": { + "name": "code_plan_assets_plan_asset_idx", + "columns": [ + "code_plan_id", + "asset_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "code_plan_assets_code_plan_id_code_plans_id_fk": { + "name": "code_plan_assets_code_plan_id_code_plans_id_fk", + "tableFrom": "code_plan_assets", + "tableTo": "code_plans", + "columnsFrom": [ + "code_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_plan_assets_asset_id_assets_id_fk": { + "name": "code_plan_assets_asset_id_assets_id_fk", + "tableFrom": "code_plan_assets", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "code_plans": { + "name": "code_plans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deadline": { + "name": "deadline", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spec_url": { + "name": "spec_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'native'" + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_data": { + "name": "external_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "external_deleted": { + "name": "external_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "code_plans_connection_external_idx": { + "name": "code_plans_connection_external_idx", + "columns": [ + "connection_id", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "code_plans_product_id_products_id_fk": { + "name": "code_plans_product_id_products_id_fk", + "tableFrom": "code_plans", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_plans_creator_id_users_id_fk": { + "name": "code_plans_creator_id_users_id_fk", + "tableFrom": "code_plans", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "code_plans_owner_id_users_id_fk": { + "name": "code_plans_owner_id_users_id_fk", + "tableFrom": "code_plans", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "code_plans_release_id_releases_id_fk": { + "name": "code_plans_release_id_releases_id_fk", + "tableFrom": "code_plans", + "tableTo": "releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "code_plans_connection_id_integrations_id_fk": { + "name": "code_plans_connection_id_integrations_id_fk", + "tableFrom": "code_plans", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_verification_tokens": { + "name": "email_verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "new_email": { + "name": "new_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_verification_tokens_token_unique": { + "name": "email_verification_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "email_verification_tokens_user_id_users_id_fk": { + "name": "email_verification_tokens_user_id_users_id_fk", + "tableFrom": "email_verification_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrations": { + "name": "integrations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_ref": { + "name": "auth_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_organization_id_organizations_id_fk": { + "name": "integrations_organization_id_organizations_id_fk", + "tableFrom": "integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_invited_by_users_id_fk": { + "name": "organization_members_invited_by_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "billing_tier": { + "name": "billing_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "product_limit": { + "name": "product_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organizations_owner_id_users_id_fk": { + "name": "organizations_owner_id_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "products": { + "name": "products", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "products_slug_unique": { + "name": "products_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "products_organization_id_organizations_id_fk": { + "name": "products_organization_id_organizations_id_fk", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "products_creator_id_users_id_fk": { + "name": "products_creator_id_users_id_fk", + "tableFrom": "products", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_assets": { + "name": "release_assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "release_assets_release_asset_idx": { + "name": "release_assets_release_asset_idx", + "columns": [ + "release_id", + "asset_id" + ], + "isUnique": true + }, + "release_assets_asset_idx": { + "name": "release_assets_asset_idx", + "columns": [ + "asset_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "release_assets_release_id_releases_id_fk": { + "name": "release_assets_release_id_releases_id_fk", + "tableFrom": "release_assets", + "tableTo": "releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_assets_asset_id_assets_id_fk": { + "name": "release_assets_asset_id_assets_id_fk", + "tableFrom": "release_assets", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "releases": { + "name": "releases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planned'" + }, + "shipped_at": { + "name": "shipped_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'native'" + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_data": { + "name": "external_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "external_deleted": { + "name": "external_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "releases_connection_external_idx": { + "name": "releases_connection_external_idx", + "columns": [ + "connection_id", + "external_id" + ], + "isUnique": true + }, + "releases_product_idx": { + "name": "releases_product_idx", + "columns": [ + "product_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "releases_product_id_products_id_fk": { + "name": "releases_product_id_products_id_fk", + "tableFrom": "releases", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "releases_creator_id_users_id_fk": { + "name": "releases_creator_id_users_id_fk", + "tableFrom": "releases", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "releases_connection_id_integrations_id_fk": { + "name": "releases_connection_id_integrations_id_fk", + "tableFrom": "releases", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_log": { + "name": "sync_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sync_log_org_created_idx": { + "name": "sync_log_org_created_idx", + "columns": [ + "organization_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_log_organization_id_organizations_id_fk": { + "name": "sync_log_organization_id_organizations_id_fk", + "tableFrom": "sync_log", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_log_connection_id_integrations_id_fk": { + "name": "sync_log_connection_id_integrations_id_fk", + "tableFrom": "sync_log", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sync_log_actor_id_users_id_fk": { + "name": "sync_log_actor_id_users_id_fk", + "tableFrom": "sync_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code_plan_id": { + "name": "code_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "percent_complete": { + "name": "percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_effort": { + "name": "estimated_effort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_effort": { + "name": "actual_effort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'native'" + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_data": { + "name": "external_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "external_deleted": { + "name": "external_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_connection_external_idx": { + "name": "tasks_connection_external_idx", + "columns": [ + "connection_id", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "tasks_code_plan_id_code_plans_id_fk": { + "name": "tasks_code_plan_id_code_plans_id_fk", + "tableFrom": "tasks", + "tableTo": "code_plans", + "columnsFrom": [ + "code_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_asset_id_assets_id_fk": { + "name": "tasks_asset_id_assets_id_fk", + "tableFrom": "tasks", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_connection_id_integrations_id_fk": { + "name": "tasks_connection_id_integrations_id_fk", + "tableFrom": "tasks", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_tier": { + "name": "billing_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feature_flags": { + "name": "feature_flags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "work_item_code_plans": { + "name": "work_item_code_plans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "work_item_id": { + "name": "work_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_plan_id": { + "name": "code_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "work_item_code_plans_item_plan_idx": { + "name": "work_item_code_plans_item_plan_idx", + "columns": [ + "work_item_id", + "code_plan_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "work_item_code_plans_work_item_id_work_items_id_fk": { + "name": "work_item_code_plans_work_item_id_work_items_id_fk", + "tableFrom": "work_item_code_plans", + "tableTo": "work_items", + "columnsFrom": [ + "work_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_item_code_plans_code_plan_id_code_plans_id_fk": { + "name": "work_item_code_plans_code_plan_id_code_plans_id_fk", + "tableFrom": "work_item_code_plans", + "tableTo": "code_plans", + "columnsFrom": [ + "code_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "work_items": { + "name": "work_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spec_url": { + "name": "spec_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'native'" + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_data": { + "name": "external_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "external_deleted": { + "name": "external_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "work_items_connection_external_idx": { + "name": "work_items_connection_external_idx", + "columns": [ + "connection_id", + "external_id" + ], + "isUnique": true + }, + "work_items_product_idx": { + "name": "work_items_product_idx", + "columns": [ + "product_id" + ], + "isUnique": false + }, + "work_items_asset_idx": { + "name": "work_items_asset_idx", + "columns": [ + "asset_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "work_items_product_id_products_id_fk": { + "name": "work_items_product_id_products_id_fk", + "tableFrom": "work_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_items_asset_id_assets_id_fk": { + "name": "work_items_asset_id_assets_id_fk", + "tableFrom": "work_items", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_parent_id_work_items_id_fk": { + "name": "work_items_parent_id_work_items_id_fk", + "tableFrom": "work_items", + "tableTo": "work_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_reporter_id_users_id_fk": { + "name": "work_items_reporter_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_owner_id_users_id_fk": { + "name": "work_items_owner_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_connection_id_integrations_id_fk": { + "name": "work_items_connection_id_integrations_id_fk", + "tableFrom": "work_items", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/sqlite/meta/_journal.json b/lib/db/migrations/sqlite/meta/_journal.json index 35f44ae..a4dfbac 100644 --- a/lib/db/migrations/sqlite/meta/_journal.json +++ b/lib/db/migrations/sqlite/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1785812654617, "tag": "0015_asset_capabilities", "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1785957265947, + "tag": "0016_asset_layer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/mutations.ts b/lib/db/mutations.ts index 645f975..8fb9f5d 100644 --- a/lib/db/mutations.ts +++ b/lib/db/mutations.ts @@ -15,7 +15,7 @@ import { assetDesignLog, assetCapabilities, } from './schema' -import { eq, and, ne } from 'drizzle-orm' +import { eq, and, ne, inArray } from 'drizzle-orm' import type { WorkItemType, WorkItemStatus, WorkItemSeverity, ReleaseStatus } from '@/lib/types' // --------------------------------------------------------------------------- @@ -73,6 +73,8 @@ type CreateAssetData = { repositoryUrl?: string repoPath?: string documentationUrl?: string + /** Free text; conventional taxonomy in layers-and-boundaries-spec §3. */ + layer?: string } export async function createAsset(data: CreateAssetData) { @@ -85,11 +87,13 @@ export async function createAsset(data: CreateAssetData) { return asset } -type UpdateAssetData = Partial & { +type UpdateAssetData = Partial & { health: 'healthy' | 'warning' | 'critical' status: 'active' | 'deprecated' | 'planned' techDebtScore: number notes: string + /** null clears the explicit layer (the type default takes over). */ + layer: string | null }> export async function updateAsset(id: string, data: UpdateAssetData) { @@ -118,6 +122,41 @@ export async function setAssetOwners(assetId: string, userIds: string[]) { } } +/** + * Move an asset to another product (layers-and-boundaries-spec §5). + * Blocked while draft/active plans target the asset (a silent move would + * falsify them — retarget or complete first). Work items follow the asset; + * history (release stamps, completed-plan links, capabilities, design log) + * is deliberately left untouched. Access is validated at the caller layer. + */ +export async function moveAsset(assetId: string, targetProductId: string) { + const asset = await db.query.assets.findFirst({ where: eq(assets.id, assetId) }) + if (!asset) return { error: 'Asset not found' as const } + if (asset.productId === targetProductId) return { asset, moved: false as const } + const target = await db.query.products.findFirst({ where: eq(products.id, targetProductId) }) + if (!target) return { error: 'Target product not found' as const } + + const blockingPlans = await db + .select({ id: codePlans.id, title: codePlans.title, status: codePlans.status }) + .from(codePlanAssets) + .innerJoin(codePlans, eq(codePlanAssets.codePlanId, codePlans.id)) + .where(and(eq(codePlanAssets.assetId, assetId), inArray(codePlans.status, ['draft', 'active']))) + if (blockingPlans.length > 0) { + return { + error: 'Blocked by open plans targeting this asset — retarget or complete them, then retry' as const, + blockingPlans, + } + } + + const [moved] = await db + .update(assets) + .set({ productId: targetProductId, updatedAt: new Date() }) + .where(eq(assets.id, assetId)) + .returning() + await db.update(workItems).set({ productId: targetProductId }).where(eq(workItems.assetId, assetId)) + return { asset: moved, moved: true as const } +} + // --------------------------------------------------------------------------- // Code Plans // --------------------------------------------------------------------------- diff --git a/lib/db/queries.ts b/lib/db/queries.ts index ed3770b..fe7877b 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -42,6 +42,7 @@ import type { ReleaseStatus, AssetType, } from '@/lib/types' +import { effectiveLayer } from '@/lib/types' // --------------------------------------------------------------------------- // Access control @@ -287,6 +288,7 @@ export async function getProduct(slug: string, userId: string): Promise<(Product techDebtScore: a.techDebtScore ?? undefined, derivedTechDebtScore: derivedByAsset.get(a.id)?.score, openDebtCount: derivedByAsset.get(a.id)?.count ?? 0, + layer: a.layer ?? undefined, owners: owners.get(a.id) ?? [], repositoryUrl: a.repositoryUrl ?? undefined, repoPath: a.repoPath ?? undefined, @@ -1030,6 +1032,7 @@ export async function getAssetDetail(id: string, userId: string): Promise>().notNull().default({}), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), diff --git a/lib/db/seed-demo.ts b/lib/db/seed-demo.ts index 80417f2..e3f9ede 100644 --- a/lib/db/seed-demo.ts +++ b/lib/db/seed-demo.ts @@ -880,6 +880,20 @@ async function seed() { } console.log(' linked plans and stamped asset versions') + // ── Layers (layers-and-boundaries-spec §3) ──────────────────────────────── + // Explicit layers only where the type default is wrong; the rest demo the + // display-time defaults (app→frontend, service→backend, datastore→data, …). + const layerAssignments: [string, string][] = [ + [apiGatewayId, 'edge'], + [bffId, 'edge'], + [planEngineId, 'domain'], + [uiLibId, 'frontend'], + ] + for (const [assetId, layer] of layerAssignments) { + await db.update(assets).set({ layer }).where(eq(assets.id, assetId)) + } + console.log(` assigned ${layerAssignments.length} explicit layers`) + // ── Asset record (graduated capabilities) ───────────────────────────────── // Graduate the resolved features into their assets' records, then backdate // to the ship dates so the record reads as history. One capability is marked diff --git a/lib/types.ts b/lib/types.ts index 79e1b01..aec7d53 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -84,6 +84,8 @@ export interface Asset { owners?: AssetOwner[] /** Freeform ideation/notes doc (markdown). */ notes?: string + /** Where the asset sits inside its product (layers-and-boundaries-spec §3). */ + layer?: string repositoryUrl?: string repoPath?: string documentationUrl?: string @@ -91,6 +93,26 @@ export interface Asset { createdAt: string } +/** + * Conventional layer taxonomy, in dependency-flow order (edge → shared). + * `layer` is free text — this is the promoted vocabulary, not an enum. + */ +export const LAYER_TAXONOMY = ['edge', 'frontend', 'backend', 'domain', 'data', 'infra', 'shared'] as const + +/** Display-time layer default by asset type — never written to rows. */ +export const DEFAULT_LAYER_BY_TYPE: Record = { + app: 'frontend', + service: 'backend', + datastore: 'data', + platform: 'infra', + library: 'shared', +} + +/** Explicit layer wins; otherwise the type default. */ +export function effectiveLayer(type: AssetType, layer?: string | null): string { + return layer?.trim() || DEFAULT_LAYER_BY_TYPE[type] +} + export type PrStatus = 'none' | 'draft' | 'open' | 'merged' | 'closed' export type ReleaseStatus = 'planned' | 'in_progress' | 'shipped' | 'abandoned' diff --git a/package.json b/package.json index 6bd965a..fd3f489 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeplans", - "version": "0.4.5", + "version": "0.4.6", "description": "Manage and track coordinated changes across your software architecture.", "author": "Sai Prakash ", "homepage": "https://codeplans.ai", diff --git a/tests/lib/db/asset-move.test.ts b/tests/lib/db/asset-move.test.ts new file mode 100644 index 0000000..04edf41 --- /dev/null +++ b/tests/lib/db/asset-move.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest' +import { runMigrations, seedFixtures, clearTables, F } from '@/tests/helpers/db' +import { getAssetInventory } from '@/lib/db/queries' +import { moveAsset, updateAsset, createRelease, updateRelease, setReleaseAsset } from '@/lib/db/mutations' +import { effectiveLayer } from '@/lib/types' +import { db } from '@/lib/db/index' +import { workItems, releaseAssets } from '@/lib/db/schema.sqlite' +import { eq } from 'drizzle-orm' + +beforeAll(async () => { + await runMigrations() +}) + +beforeEach(async () => { + await seedFixtures() +}) + +afterEach(async () => { + await clearTables() +}) + +describe('layers', () => { + it('effectiveLayer: explicit beats type default', () => { + expect(effectiveLayer('service')).toBe('backend') + expect(effectiveLayer('app', null)).toBe('frontend') + expect(effectiveLayer('library', 'domain')).toBe('domain') + expect(effectiveLayer('datastore', ' ')).toBe('data') + }) + + it('inventory carries explicit and effective layers', async () => { + await updateAsset(F.assetDb, { layer: 'domain' }) + const inv = await getAssetInventory(F.alice) + const dbRow = inv.assets.find((a) => a.id === F.assetDb)! + expect(dbRow.layer).toBe('domain') + expect(dbRow.effectiveLayer).toBe('domain') + const apiRow = inv.assets.find((a) => a.id === F.assetApi)! + expect(apiRow.layer).toBeUndefined() + expect(apiRow.effectiveLayer).toBe('backend') // service default + + // Clearing the explicit layer restores the type default (datastore → data). + await updateAsset(F.assetDb, { layer: null }) + const cleared = await getAssetInventory(F.alice) + expect(cleared.assets.find((a) => a.id === F.assetDb)!.effectiveLayer).toBe('data') + }) +}) + +describe('moveAsset', () => { + it('is blocked while draft/active plans target the asset, listing them', async () => { + // planActive (active) targets assetApi in the fixtures. + const result = await moveAsset(F.assetApi, F.productCarol) + expect('error' in result).toBe(true) + const blocking = 'blockingPlans' in result ? result.blockingPlans : undefined + expect(blocking?.map((p) => p.id)).toEqual([F.planActive]) + }) + + it('moves the asset, carries its work items, and preserves history', async () => { + await (db as any).insert(workItems).values({ + id: 'wi-on-db', + productId: F.productShared, + assetId: F.assetDb, + type: 'tech_debt', + title: 'No TTLs', + status: 'open', + severity: 'high', + tags: [], + }) + const release = await createRelease({ productId: F.productShared, name: 'DB v1' }, F.alice) + await setReleaseAsset(release.id, F.assetDb, { version: 'v1.0.0' }) + await updateRelease(release.id, { status: 'shipped' }) + + const result = await moveAsset(F.assetDb, F.productCarol) + if ('error' in result) throw new Error(result.error) + expect(result.moved).toBe(true) + expect(result.asset.productId).toBe(F.productCarol) + + // Demand followed the asset… + const item = await db.query.workItems.findFirst({ where: (w: any, { eq: e }: any) => e(w.id, 'wi-on-db') }) + expect(item!.productId).toBe(F.productCarol) + // …and history stayed put: the shipped stamp survives untouched. + const stamp = await (db as any).select().from(releaseAssets).where(eq(releaseAssets.assetId, F.assetDb)) + expect(stamp).toHaveLength(1) + expect(stamp[0].version).toBe('v1.0.0') + + // Access re-scopes: carol sees it now; it left alice's inventory. + expect((await getAssetInventory(F.carol)).assets.map((a) => a.id)).toEqual([F.assetDb]) + expect((await getAssetInventory(F.alice)).assets.some((a) => a.id === F.assetDb)).toBe(false) + }) + + it('no-ops when already in the target product and validates the target', async () => { + const same = await moveAsset(F.assetDb, F.productShared) + if ('error' in same) throw new Error(same.error) + expect(same.moved).toBe(false) + + const bad = await moveAsset(F.assetDb, 'nope') + expect('error' in bad).toBe(true) + }) +})