* feat(telemetry): marketplace item rollup refactor (schema v46)
Replace the v42 attribution layer with prefix-split + live lookup against
marketplace_plugins / store_entities. The v42 design had a latent bug —
AttributionLookup keyed on bare skill names while Claude Code writes
`<plugin>:<local>` in JSONL, so lookups never matched and
usage_plugin_daily stayed empty in every deployment.
Schema (v46 migration):
- Drop usage_attribution_skills / _agents / _commands (mapping tables,
derivable from marketplace_plugins + plugin tree).
- Drop usage_plugin_daily (always empty in production due to the bug above).
- Create usage_marketplace_item_daily — per-day fact (count, distinct_users,
error_count), composite PK on (day, source, type, parent_plugin, name).
- Create usage_marketplace_item_window — sliding-window snapshot with
true cross-window distinct user counts; period_label='last_7d' refreshes
every tick, 'last_30d' refreshes hourly (tracked via session_processor_state).
- Mark usage_tool_daily as candidate for removal (no product-UI consumer).
Attribution flow:
- MarketplaceItemLookup replaces AttributionLookup. Preloads
marketplace_plugins.name + store_entities.name into memory once per
UsageProcessor tick, then per-event splits identifier on ':',
matches prefix, writes resolved source / parent_plugin into
usage_events. agnes-store-bundle prefix routes to flea entities.
Slash commands with `plugin:` prefix count as type='skill' in rollup.
API:
- BREAKING: MarketplaceItem.unique_users_30d renamed to distinct_users_30d
(now a true distinct count from the window snapshot, not sum-of-daily).
- InnerDetailResponse gains a telemetry field — invocations_30d +
distinct_users_30d surfaced on curated inner skill / agent detail pages.
- Card chip hidden pending UX finalisation; data stays in the response.
Backfill: scripts/backfill_marketplace_rollup.py — one-shot rebuild over
historic usage_events after deploy, idempotent.
USAGE_PROCESSOR_VERSION bumped 4 → 5 so the reprocess loop re-attributes
existing events to the new source/ref_id semantics on the next tick.
Tests rewritten: test_session_processor_usage, test_usage_rollups,
test_marketplace_telemetry, test_api_admin_usage_reprocess,
test_db_schema_version, test_home_stats, test_schema_v42_migration.
New: test_backfill_marketplace_rollup.
* fix(marketplace): refresh Most Popular on search + category changes
`loadMostPopular()` early-exits when `state.q` or `state.category` is
set, but the search + category handlers only called `loadItems()` —
so once the section was visible, typing a query or filtering by
category didn't re-run the hide check and the cards stayed on screen
out of scope. Tab + sort handlers already chained the call.
Add the call to runSearch + category pill click handlers (All +
per-category) so the visibility contract holds for every state
mutation that can flip the early-exit condition.
* feat(marketplace): All-plugins section + 7-day Most Popular
Listing layout:
- Always-visible "All plugins" / "All items" / "Your stack" section
header (label swaps per tab) wrapped in `#mp-all-section` so its
margin-collapse mirrors the sibling `#mp-popular-section` and the
spacing from the filter row stays consistent in both layouts.
- Sort dropdown moved from the filter row into the All-* header,
pinned right via `margin-left: auto`. Anchored to its section so
the relationship between sort + grid is obvious.
- `.mp-section-header` gets `min-height: 32px` + `align-items: center`
so the bare-text Most Popular row matches the dropdown-bearing
All-* row.
- `.mp-section-header` margin tightened 24px → 20px on top.
Most Popular:
- Capacity reduced 8 → 4 cards.
- Now reflects a 7-day window (was 30-day). Backend surfaces
`invocations_7d` + `distinct_users_7d` on `MarketplaceItem`
alongside the existing 30d fields; the loader pulls a wider page
(server still sorts by 30d) and re-sorts + filters client-side
on `invocations_7d > 0` so the strip stays "hot right now".
- Section label updated to "Last 7 days".
- Section now renders on both `curated` and `flea` tabs (was
curated-only). Hidden on `my` and whenever search / category
filter is active. Refresh hooks wired into search + category
click handlers so visibility flips immediately on state change.
Backend (`_load_invocation_stats`):
- Single SELECT pulls both `last_30d` and `last_7d` rows from
`usage_marketplace_item_window`; the result dict carries
invocations + distinct_users for both windows.
- Trend (recent_7 vs prior_7) kept on the daily fact table so it
stays independent of the window snapshot's freshness.
* feat(marketplace): Most adopted sort + hide Trending when no trend data
Add a fourth sort option to the All-items dropdown — "Most adopted
(30d)", keyed on `MarketplaceItem.distinct_users_30d` (true 30d
distinct user count from `usage_marketplace_item_window`). Protects
the listing from power-user skew that `most_used` is susceptible to:
one user × 100 invokes can't beat 10 different users × 1 invoke
under adoption sort.
Hide Trending option when the response has no trend data. User
reported `sort=trending` returning an empty grid because every
plugin's `trend_pct` was None (prior-week threshold of >= 3
invocations didn't clear anywhere). Empty grids on a user-selected
sort are worse UX than just not offering the sort — surface what
works, hide what doesn't.
Backend (`app/api/marketplace.py`):
- `_apply_sort` gains a `most_adopted` branch (DESC distinct_users_30d,
ties by name ASC).
- `sort` Literal extended.
- `ItemListResponse.available_sorts` lists the sort keys the UI
should expose for this response. recent/most_used/most_adopted
always; trending only when at least one item in the tab's stats
carries a non-null trend_pct.
- `_available_sorts(stats_dicts)` helper centralises the rule —
curated and flea branches pass one stats dict, my-tab passes both
(option is available when either source has trend data).
Frontend (`app/web/templates/marketplace.html`):
- New `<option value="most_adopted">Most adopted (30d)</option>`
between Most used and Trending.
- URL state allowlist extended so `?sort=most_adopted` round-trips.
- `applyAvailableSorts(available)` runs after each list fetch:
hides options not in the response's available_sorts; if the user
is on a now-unavailable sort, resets to 'recent' and re-fetches.
Search-mode fan-out unions availability across the curated + flea
responses so a hit on either side keeps the option visible.
* feat(marketplace): funnel chip on cards + deterministic Most Popular sort
Card chip — funnel telemetry between description and footer:
[stack-icon] N installed · [user-icon] N active · [bolt-icon] N calls · ↑/↓ N%
- stack_count (new MarketplaceItem field): for curated it's COUNT(*)
on user_plugin_optouts (post-v28 row PRESENCE = subscribed; system
plugins are fanned out to every user via fanout_system_for_user so
the count includes them naturally). For flea it reuses the existing
store_entities.install_count (bumped on install/uninstall).
- distinct_users_30d (existing) — active users in the 30d window.
- invocations_30d (existing) — call volume.
- trend_pct (existing) — week-over-week, both directions: green ↑ /
red ↓, magnitude only (sign in the arrow). Hidden when null.
Backend additions in app/api/marketplace.py:
- MarketplaceItem.stack_count field.
- _load_curated_stack_counts() — one SELECT per render, GROUP BY
(marketplace_id, plugin_name). Wired into the curated + my-tab
branches; flea reads install_count off the entity row directly.
Frontend (app/web/templates/marketplace.html):
- Heroicons solid 24×24 inlined (one helper per icon, all
fill="currentColor" so per-segment colour tokens apply): rectangle-
stack (mirrors the My Stack tab icon), user, bolt, arrow-trending-
up/down.
- Per-segment colour: installed=amber #F59F0A (My Stack accent),
active=green #0e9b6a, calls=orange #f97316. Text stays neutral so
the chip still reads as metadata, the leading glyph carries the
visual cue. Trend pill keeps the full-segment green/red colour.
- Zero state: chip hidden when stack_count == 0 AND invocations_30d
== 0 — brand-new cards aren't visually penalised by a "0·0·0" row.
- Tooltips on every segment via title="…" so hover explains the
number's meaning to anyone uncertain about the icon.
Most Popular section — deterministic ordering:
Previously sorted by invocations_7d DESC with no tie-breakers, so
several cards with identical 7d call counts would swap places on
refresh (JS stable sort fell back on backend order, and the backend's
own tie-breaker for `most_used` was just name ASC — six `grpn`
plugins from six test marketplaces collapse to the same name and
became indeterminate via list_with_filters' created_at order).
New cascading hierarchy (chosen primary now matches what "most
popular" really means — wide adoption, not power-user volume):
1. distinct_users_7d DESC ← adoption / social proof
2. invocations_7d DESC ← volume at equal adoption
3. distinct_users_30d DESC ← broader adoption fallback
4. invocations_30d DESC ← broader volume fallback
5. name ASC ← deterministic textual order
6. marketplace_slug ASC ← splits duplicate plugin names across
marketplaces
Six levels guarantee any two items end at a different sort key, so
the strip is stable across refreshes.
* fix(marketplace): unify Most Popular on 30d + right-align installed chip
Most Popular section was sorting on the 7d window while its cards
rendered 30d numbers — header label promised one thing, cards showed
another. Unified everything on 30d so a card means the same data
everywhere on the page.
- Dropped the "Last 7 days" meta from the Most Popular header.
- Sort cascade now starts on distinct_users_30d, then invocations_30d,
with 7d adoption/volume as recency-aware fallbacks before the name +
marketplace_slug deterministic tail. Six levels guarantee identical
sort keys never produce indeterminate order across refreshes.
- Filter switched from invocations_7d > 0 to invocations_30d > 0 to
match the new horizon.
- Most Popular now only renders on page 1 of the listing. Past initial
discovery, a top-of-list popularity strip on page 2+ would shadow the
results the user paged into. Pager click handler refreshes the
section so navigating back to page 1 re-mounts it.
Chip layout — split engagement vs adoption visually:
[user] N active · [bolt] N calls · [↑/↓] N% [stack] N installed
└────────── LEFT (time-bounded engagement) ────┘ └── RIGHT (all-time) ──┘
- Installed (stack_count) is all-time, decremented on uninstall. Alone
it says little ("12 people installed it") without the engagement
context next to it ("…but did anyone actually use it?"). Visually
separating the two groups makes that distinction obvious — left
group answers "is it used", right answers "does anyone have it".
- Implemented via flex with margin-left:auto on .seg-installed so
installed drifts to the trailing edge.
- Installed tooltip now reads "Currently installed by N users" — the
count is a real-time net (uninstall drops it), and saying "currently"
makes that explicit. Helps when a card shows 0: signals "nobody has
this in their stack right now", not "data missing".
* feat(plugin-detail): telemetry chip in hero, derived rows in sidebar
Surface the same telemetry funnel the listing card carries on the
curated plugin detail page, so clicking through from /marketplace
keeps a single mental model — figures match, semantics match. The
detail sidebar drops the two raw numbers that used to live there
(Invocations 30d / Users 30d — duplicated by the chip now) and
replaces them with two *derived* signals only the daily series can
provide: Active days + Last used.
Backend (app/api/marketplace.py):
- PluginDetailResponse.stack_count — curated reads via
_load_curated_stack_counts(), flea reuses install_count. Frontend
treats both sources uniformly.
- _build_telemetry() always returns a dict (never None). Frontend
decides chip visibility from stack_count + invocations_30d the
same way the listing card does. daily_series is always 30 entries
(zero-padded) so "Active days" and "Last used" derivations on the
sidebar are trivial array filters.
Frontend (app/web/templates/marketplace_plugin_detail.html):
- New .hero-telemetry slot at the bottom of the hero meta column,
between the pills row and the action buttons. Renders the four
funnel segments — active · calls · trend · installed — joined by
` · `. No left/right split: the hero has space, so a single
coherent metadata strip reads cleaner than the card's split layout.
- Heroicons solid inlined (user / bolt / arrow-trending-up,-down /
rectangle-stack) recoloured against the dark hero — icons in
lighter tokens (mint #6ee7b7, peach #fdba74, cream #fde68a), trend
pill keeps the saturated green/red because direction-coding earns
its own colour.
- Tooltip on installed reads "Currently installed by N users" — the
count is a real-time net (drops on uninstall), and "currently"
makes that explicit when a card shows 0.
- fmtNum helper added so 1.2k / 14M renderings match the card's
format exactly.
- Sidebar swap: Invocations + Users rows removed, replaced by
Active days → "N of 30"
Last used → fmtRelative of the latest non-zero day
Both derived from telemetry.daily_series — engagement consistency
+ recency, neither of which the hero chip exposes on its own.
* feat(item-detail): telemetry chip in hero for curated skill/agent
Bring the funnel chip the plugin detail page got in 4cf38d40 to the
curated inner skill/agent detail page — clicking through from the
listing card now keeps the same metadata strip from grid to plugin
page to inner item page.
Backend (app/api/marketplace.py):
- _load_inner_item_stats() rewritten:
* always returns a dict (never None) so the frontend can decide
chip visibility client-side, same contract as _build_telemetry
* adds trend_pct, computed the same way as plugin level
(recent_7 vs prior_7 from usage_marketplace_item_daily, ≥3
prior-week threshold)
* adds daily_series (30 entries, zero-padded) so the sidebar can
derive Active days + Last used
- InnerDetailResponse.parent_stack_count — new field. Skills/agents
don't have a per-item subscription model, so the hero shows the
*parent plugin's* stack count under a "Plugin:" prefix. The
funnel: "12 installed plugin → 2 actually use this skill".
- curated_skill_detail + curated_agent_detail handlers load
_load_curated_stack_counts() once and pass the parent's value.
Frontend (app/web/templates/marketplace_item_detail.html):
- New .item-detail .hero .hero-telemetry slot beneath the badges
row. CSS mirrors plugin-detail's colour tokens (mint/peach/cream
Heroicons solid + saturated trend pill) so the two surfaces read
as one visual family.
- Installed segment uses a "Plugin:" label rendered with reduced
opacity to signal the metric describes the parent, not the item
itself. Tooltip: "Parent plugin (<plugin_name>) currently
installed by N users".
- Sidebar Invocations + Users rows removed (chip carries them).
Active days + Last used derived from telemetry.daily_series replace
them; only rendered when activeDays > 0 so a brand-new skill
doesn't show "0 of 30" / "Last used —".
- "Type" row dropped from the sidebar — duplicates the hero badge.
- fmtNum helper added (matches listing card + plugin detail).
Plugin detail (app/web/templates/marketplace_plugin_detail.html):
- Hero "Curator: …" line removed. The Details sidebar already
carries that info; duplicating it under the h1 was visual noise.
- Sidebar "Owner" row renamed to "Curator" — for curated plugins
it's a person who curates inclusion in this Agnes instance, not
the upstream code owner. "Owner" was a hold-over label.
* feat(item-detail): unify hero with plugin detail — pills + breadcrumb + cleaner sidebar
- Inner skill/agent hero now uses the same `.pills` / `.pill.cat / .curated /
.flea / .muted` class names + CSS as the plugin detail page; the only
item-only addition is `.pill.type` (Skill / Agent uppercase, plugin detail
has no kind axis).
- Hero `Updated` moved out of the meta-row into a muted pill (mirrors the
plugin detail hero), removed from the Details sidebar to avoid duplication.
- Details sidebar slimmed: dropped Marketplace, Path, Updated rows; Parent
plugin now shows the curator-friendly display name
(`parent_display_name || manifest_name || slug`) instead of the slug.
- Breadcrumb extended to full path: Marketplace > <marketplace_name> >
<plugin display name> > <self>, mirroring the plugin detail breadcrumb.
- Backend: new `InnerDetailResponse.parent_display_name` field, populated via
`_curated_plugin_enrichment` from marketplace-metadata.json — same source
plugin detail hero already uses.
* feat(marketplace): flea inner skill/agent detail + breadcrumb polish
- Flea inner skill/agent detail page parity with curated:
* GET /api/marketplace/flea/{id}/skill/{name} + /agent/{name}
returning InnerDetailResponse (mirror of curated_skill_detail).
* /marketplace/flea/{id}/skill|agent/{name} web routes that render
marketplace_item_detail.html with source='flea' + innerName context.
* Frontend apiURL grows a third branch for flea-inner; breadcrumb
grows to 4 segments (Marketplace > Flea Market > <plugin display
name> > <self>) when innerName is set.
* Telemetry attribution: MarketplaceItemLookup resolves
<flea_plugin>:<inner> prefixes to (source='flea',
parent_plugin=<plugin name>) so nested invocations land in the
same rollups curated nested skills use. USAGE_PROCESSOR_VERSION
bumped 5 -> 6 so the reprocess loop re-attributes historic events.
- Breadcrumb 2nd segment is now a generic clickable "Curated
Marketplace" / "Flea Market" link to /marketplace?tab=... instead
of the opaque per-instance marketplace_name. Applied on both plugin
detail and inner item detail.
- Inner item hero telemetry chip works for both sources: installedCount
branches on parent_stack_count (curated) vs install_count (flea),
installed segment drops the "Plugin:" prefix for flea standalone /
inner items.
- Updated row dropped from Details sidebar on item detail — the hero
pill already carries the value, sidebar row was duplicate.
* feat(item-detail): block stack-install on flea inner items (mirror curated)
Inner skills/agents nested inside a flea plugin can no longer be added
to a user's stack on their own — adoption only happens at the plugin
level, same rule curated nested items have followed since launch.
- Hero action: when innerName is set (curated nested OR flea nested),
render "Open parent plugin →" link + helper text instead of the
install/remove buttons. Flea standalone entities (no innerName) keep
the normal install UX.
- Meta-row: same branch now serves curated + flea inner — "part of
<parent plugin display name> · by <author>" with the parent link
pointing at the right detail page per source.
No API gate change needed: POST /api/store/entities/{id}/install only
accepts existing entity ids (plugin-level), inner items have no entity
id of their own so the endpoint cannot target them directly.
* feat(marketplace): telemetry chip on inner cards + fix flea hero chip visibility
Inner skill/agent cards on the plugin detail page now carry the same
four-segment funnel chip the marketplace listing cards show (N active
. N calls . trend . N installed), for both curated nested skills and
flea nested skills. Plus two fixes that were keeping the hero chip
hidden on flea plugin / flea inner detail pages.
- Backend `_load_inner_items_stats_by_parent(conn, source, parent_plugin)`
bulk loader: one query per plugin against usage_marketplace_item_window
+ one against _daily, returning {(name, type): stats}. Avoids N+1
per-card lookups.
- `InnerItemSummary` gains invocations_30d / distinct_users_30d /
trend_pct / parent_stack_count fields. `curated_detail` and
`flea_detail` (in the entity.type=='plugin' branch) enrich the
skills / agents lists after the existing cover-photo enrichment loop.
- `marketplace_plugin_detail.html`: new `.plugin-detail .inner-card
.inv-chip*` CSS lifted from marketplace.html with the listing-card
rules, new buildInnerCardChip() helper, buildCardSection appends
the chip to each card body. Same gate as the listing card (hidden
on parent_stack==0 && calls==0).
- fix(flea): flea_detail forgot to populate PluginDetailResponse.stack_count
from entity.install_count (listing card does this on line 851; detail
endpoint didn't). Hero chip gate `stackCount===0 && calls===0` then
always hid the chip even when the entity had installs. Now mirrors
listing card semantics: stack_count == install_count for flea.
- fix(flea inner): renderInnerHeroTelemetry was reading `d.install_count`
for any non-curated source. InnerDetailResponse has no install_count
field — it has parent_stack_count (populated server-side from the
parent flea plugin's install_count). Gate + label now read
parent_stack_count for both curated nested AND flea nested scenarios;
install_count remains the flea standalone path.
* fix(marketplace): Owner label on flea + parent-centric sidebar for flea inner
- Plugin detail Details sidebar — authorship row label now tracks the
source: curated bundles get `Curator` (existing behaviour), flea
bundles get `Owner`. The `owner_todo` reminder placeholder stays on
the curated branch only; flea falls through silently.
- Inner item detail Details sidebar — flea-inner (skill/agent nested
inside a flea plugin) now shares the curated nested layout: Parent
plugin / Bundle size / Active days / Last used / Owner. Drops the
flea-standalone shape's `Category`, `Version`, `Installs`, `Released`
rows that didn't apply to a nested item. Active days + Last used were
already wired (telemetryRows) — they just weren't on the flea-inner
branch.
* fix(tests): bump SCHEMA_VERSION assertions 47 -> 48 post-rebase
The marketplace telemetry migration was renamed _v46_to_v47 -> _v47_to_v48
during the rebase onto main (collision with #326 FTS BM25 migration that
took the v47 slot). Two test files still asserted the pre-rebase value:
- tests/test_home_stats.py::test_schema_version_constant_is_46 (CI red)
- tests/test_schema_v46_migration.py::test_schema_version_is_46
Renames the helper fn name + bumps the assertion. The other two test
files (test_db_schema_version.py, test_schema_v42_migration.py) were
already updated in the rebase resolution.
* fix(telemetry): _build_telemetry returns None when invocations_30d == 0
The follow-up commit that introduced the always-return-dict shape broke
the test contract from the original v46 PR (commit b603e998):
tests/test_marketplace_telemetry.py::TestDetailTelemetry::
test_detail_endpoint_telemetry_absent_when_no_data
AssertionError: assert {'daily_series': [...], ...} is None
Both `PluginDetailResponse.telemetry` and `InnerDetailResponse.telemetry`
are declared `Optional[Dict] = None`, the frontend renders are None-safe
(`d.telemetry || {}` guard + `if (!d.telemetry || ...)` on daily_series),
so dropping the dict on zero activity is the cleaner default.
* release: 0.54.21 — marketplace telemetry refactor (schema v48) + flea inner detail parity + listing UX polish
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
569 lines
26 KiB
Python
569 lines
26 KiB
Python
"""v20 adds source_query column to table_registry.
|
|
|
|
Backs query_mode='materialized' for BigQuery: admin registers a SQL body
|
|
that the scheduler runs through the DuckDB BQ extension and writes as a
|
|
parquet to /data/extracts/bigquery/data/<id>.parquet.
|
|
|
|
The v19 step (#150) drops dataset_permissions, access_requests tables and
|
|
users.role, table_registry.is_public columns; v20 then ALTERs the post-v19
|
|
table_registry to add the source_query column.
|
|
"""
|
|
|
|
import duckdb
|
|
|
|
from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version
|
|
|
|
|
|
def test_schema_version_is_48():
|
|
# v27 → v28: explicit-install (Model B) for curated marketplace plugins.
|
|
# user_plugin_optouts row presence flips meaning from "excluded" to
|
|
# "subscribed"; migration wipes existing rows so the inverted reading
|
|
# starts from a clean baseline. Also adds marketplace_plugins.created_at
|
|
# (per-plugin "newest first" sort on /marketplace), backfilled from
|
|
# parent marketplace_registry.registered_at.
|
|
# v28 → v29: /home page rollout — instance_templates singleton
|
|
# consolidation (welcome_template + claude_md_template merged) + new
|
|
# users.onboarded column. See tests/test_v29_home_migration.py for
|
|
# the exhaustive coverage of that step.
|
|
# v29 → v30: news_template — single versioned table for the /home
|
|
# news perex + /news permalink page. See
|
|
# tests/test_news_template_repository.py.
|
|
# v30 → v31: session-pipeline framework — session_processor_state
|
|
# replaces session_extraction_state with composite PK.
|
|
# v31 → v32 (PR #233): flea-market upload guardrails — adds
|
|
# store_entities.visibility_status + creates store_submissions.
|
|
# v32 → v33 (PR #233): forensic columns on store_submissions —
|
|
# file_size, bundle_sha256, bundle_purged_at. Underpins the
|
|
# persist-blocked-bundle behavior so admins can Rescan /
|
|
# Override / Download; 30-day TTL purge clears bytes while
|
|
# keeping the row + sha intact. See docs/STORE_GUARDRAILS.md.
|
|
# v33 → v34: drop store_submissions.retry_count — counter mixed LLM
|
|
# error count + admin rescan count, redundant with audit_log.
|
|
# v34 → v35 (PR #233): store_entities gains 'archived' visibility
|
|
# state + archived_at + archived_by audit columns. Owner
|
|
# soft-delete writes 'archived'; existing user_store_installs
|
|
# keep serving the bundle through marketplace.zip / .git.
|
|
# Hard delete (DELETE ?hard=true) remains admin-only.
|
|
# v35 → v36 (PR #233 follow-up): re-apply NOT NULL + DEFAULT 'pending'
|
|
# on store_entities.visibility_status. Lost in the v34→v35
|
|
# column rebuild. Without this, an INSERT that omits the
|
|
# column lands NULL → repo reads None → undefined behavior
|
|
# in the visibility gates. Value-list invariant remains
|
|
# enforced application-side (DuckDB ADD CHECK on existing
|
|
# column not supported).
|
|
# v36 → v37: curated marketplace enrichment from
|
|
# `.claude-plugin/marketplace-metadata.json` plus mandatory curator
|
|
# identity on marketplace_registry. Adds curator_name +
|
|
# curator_email to marketplace_registry, and
|
|
# cover_photo_url + video_url + doc_links to
|
|
# marketplace_plugins.
|
|
# v37 → v38: flea-market edit feature with version
|
|
# history. Adds store_entities.version_no INTEGER and
|
|
# version_history JSON. Each new bundle upload via
|
|
# PUT bumps version_no and appends to version_history;
|
|
# metadata-only edits don't bump. Existing rows backfill
|
|
# to version_no=1 with a single-entry history seeded
|
|
# from the row's current `version` (hash). Bundle bytes
|
|
# for each version live on disk under
|
|
# ${DATA_DIR}/store/<id>/versions/v<N>/plugin/.
|
|
# v38 → v39: system plugin tier — admin-toggleable mandatory plugin
|
|
# set. Adds marketplace_plugins.is_system BOOLEAN DEFAULT
|
|
# FALSE. The flag drives a fanout that materializes
|
|
# resource_grants + user_plugin_optouts rows for every
|
|
# existing user_groups + users row, so the resolver's
|
|
# existing (rbac ∩ subscriptions) computation naturally
|
|
# pulls system plugins into every user's stack. UI then
|
|
# locks the corresponding controls so users can't
|
|
# unsubscribe and admins can't revoke per-group grants.
|
|
# v39 → v40: persistent BigQuery metadata cache. Adds
|
|
# bq_metadata_cache(table_id PK, rows, size_bytes,
|
|
# partition_by, clustered_by, refreshed_at, error_at,
|
|
# error_msg).
|
|
# v40 → v41: Activity Center schema — audit_log gains params_before
|
|
# (JSON), client_ip (VARCHAR), client_kind (VARCHAR),
|
|
# correlation_id (VARCHAR). Three indices on (timestamp),
|
|
# (user_id, timestamp), (action, timestamp).
|
|
# v41 → v42 (this PR): platform telemetry schema — 7 new usage_*
|
|
# tables: usage_events (per-event log), usage_session_summary
|
|
# (per-session aggregate), usage_tool_daily + usage_plugin_daily
|
|
# (daily rollups), usage_attribution_skills/agents/commands
|
|
# (plugin manifest attribution). 10 indices for fast queries.
|
|
# v42 → v43: user_observability_views — per-user saved
|
|
# filter combinations backing the unified /admin/activity
|
|
# page (UNIQUE(user_id, name)). Schema is intentionally
|
|
# opaque JSON because the UI evolves faster than DB.
|
|
# v43 → v44: homepage status frame backing columns —
|
|
# users.last_pull_at (per-user manifest fetch timestamp,
|
|
# bumped by GET /api/sync/manifest) plus four BIGINT token
|
|
# counters on usage_session_summary (input_tokens,
|
|
# output_tokens, cache_read_tokens, cache_creation_tokens).
|
|
# USAGE_PROCESSOR_VERSION simultaneously bumps 1→2 so the
|
|
# reprocess loop backfills tokens on next tick.
|
|
# v44 → v45: user_id column on usage_session_summary + usage_events
|
|
# (stable RBAC filter — replaces the unstable email-local-part
|
|
# ``username`` column) plus matching indices.
|
|
# v45 → v46: per-user opt-out (dismiss) for curated memory
|
|
# items. New table ``knowledge_item_user_dismissed``
|
|
# ((user_id, item_id) PK, dismissed_at) + index on user_id
|
|
# for the EXISTS subquery used by list_items / search /
|
|
# count_items / bundle. Mandatory items are governance-
|
|
# protected: the API rejects POSTs against them, and the
|
|
# SQL filter exempts ``status = 'mandatory'`` so any stale
|
|
# row from before an item was mandated is silently ignored.
|
|
# v46 → v47: DuckDB FTS BM25 index over knowledge_items(title, content).
|
|
# Replaces ``ILIKE '%q%'`` ranking-by-insertion-order in
|
|
# ``KnowledgeRepository.search`` with BM25 relevance scoring.
|
|
# Migration is soft-fail: a missing fts extension leaves the
|
|
# DB at v46 (search falls back to ILIKE).
|
|
# v47 → v48 (this PR): marketplace telemetry refactor. Drops 4 legacy
|
|
# tables (usage_attribution_skills/_agents/_commands,
|
|
# usage_plugin_daily — all verified empty or derivable).
|
|
# Adds usage_marketplace_item_daily (per-day fact with
|
|
# count + distinct_users + error_count) and
|
|
# usage_marketplace_item_window (sliding-window snapshot,
|
|
# labels 'last_7d' refreshed every tick, 'last_30d' hourly).
|
|
# New attribution logic = prefix split on `<plugin>:<local>`
|
|
# identifier + live lookup against marketplace_plugins /
|
|
# store_entities — no mapping tables needed.
|
|
assert SCHEMA_VERSION == 48
|
|
|
|
|
|
def test_v37_marketplace_curator_columns(tmp_path):
|
|
"""Fresh install reaches the current schema with the v37 marketplace
|
|
columns present."""
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
_ensure_schema(conn)
|
|
|
|
registry_cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'marketplace_registry'"
|
|
).fetchall()
|
|
}
|
|
assert {"curator_name", "curator_email"} <= registry_cols, (
|
|
f"curator columns missing from marketplace_registry: {registry_cols}"
|
|
)
|
|
|
|
plugin_cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'marketplace_plugins'"
|
|
).fetchall()
|
|
}
|
|
assert {"cover_photo_url", "video_url", "doc_links"} <= plugin_cols, (
|
|
f"enrichment columns missing from marketplace_plugins: {plugin_cols}"
|
|
)
|
|
conn.close()
|
|
|
|
|
|
def test_v36_db_migrates_to_current(tmp_path):
|
|
"""Pre-existing v36 DB upgrades cleanly through v37 (curator
|
|
enrichment) and v38 (flea edit version history) without losing
|
|
existing rows."""
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
|
|
# Stand up a minimal v36-shape registry + plugin row, plus the
|
|
# schema_version row that pins us to 36.
|
|
conn.execute("CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)")
|
|
conn.execute("INSERT INTO schema_version (version) VALUES (36)")
|
|
conn.execute("""CREATE TABLE marketplace_registry (
|
|
id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL,
|
|
url VARCHAR NOT NULL, branch VARCHAR, token_env VARCHAR,
|
|
description TEXT, registered_by VARCHAR,
|
|
registered_at TIMESTAMP DEFAULT current_timestamp,
|
|
last_synced_at TIMESTAMP, last_commit_sha VARCHAR, last_error TEXT
|
|
)""")
|
|
conn.execute("""CREATE TABLE marketplace_plugins (
|
|
marketplace_id VARCHAR NOT NULL, name VARCHAR NOT NULL,
|
|
description TEXT, version VARCHAR, author_name VARCHAR,
|
|
homepage VARCHAR, category VARCHAR, source_type VARCHAR,
|
|
source_spec JSON, raw JSON,
|
|
created_at TIMESTAMP DEFAULT current_timestamp,
|
|
updated_at TIMESTAMP DEFAULT current_timestamp,
|
|
PRIMARY KEY (marketplace_id, name)
|
|
)""")
|
|
conn.execute(
|
|
"INSERT INTO marketplace_registry (id, name, url) VALUES ('legacy', 'Legacy', 'https://example.com/repo.git')"
|
|
)
|
|
conn.execute("INSERT INTO marketplace_plugins (marketplace_id, name) VALUES ('legacy', 'foo')")
|
|
|
|
_ensure_schema(conn)
|
|
assert get_schema_version(conn) == SCHEMA_VERSION
|
|
|
|
# v37 enrichment columns exist; existing rows preserved with NULL.
|
|
row = conn.execute("SELECT curator_name, curator_email FROM marketplace_registry WHERE id = 'legacy'").fetchone()
|
|
assert row == (None, None)
|
|
|
|
row = conn.execute(
|
|
"SELECT cover_photo_url, video_url, doc_links FROM marketplace_plugins "
|
|
"WHERE marketplace_id = 'legacy' AND name = 'foo'"
|
|
).fetchone()
|
|
assert row == (None, None, None)
|
|
conn.close()
|
|
|
|
|
|
def test_v39_adds_marketplace_plugins_is_system(tmp_path):
|
|
"""Fresh install reaches the current schema with the v39 is_system
|
|
column on marketplace_plugins. Default value is FALSE (not NULL) so
|
|
the fanout helpers don't need to special-case absent rows."""
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
_ensure_schema(conn)
|
|
|
|
cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'marketplace_plugins'"
|
|
).fetchall()
|
|
}
|
|
assert "is_system" in cols, f"is_system missing from {cols}"
|
|
|
|
# New rows default to FALSE — required so a freshly-synced plugin
|
|
# doesn't accidentally land in everyone's stack.
|
|
conn.execute("INSERT INTO marketplace_registry (id, name, url) VALUES ('m', 'M', 'https://example.com/repo.git')")
|
|
conn.execute("INSERT INTO marketplace_plugins (marketplace_id, name) VALUES ('m', 'p')")
|
|
row = conn.execute("SELECT is_system FROM marketplace_plugins WHERE marketplace_id = 'm' AND name = 'p'").fetchone()
|
|
assert row[0] is False, f"new plugin defaulted to {row[0]!r}, expected False"
|
|
conn.close()
|
|
|
|
|
|
def test_v38_db_migrates_to_v39(tmp_path):
|
|
"""Pre-existing v38 DB upgrades to v39 cleanly — adds is_system
|
|
column, existing rows backfill to FALSE, schema_version updates."""
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
|
|
# Stand up the v38 minimal shape: schema_version row + the two
|
|
# marketplace tables + a pre-existing plugin row that must survive
|
|
# the migration with is_system = FALSE.
|
|
conn.execute("CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)")
|
|
conn.execute("INSERT INTO schema_version (version) VALUES (38)")
|
|
conn.execute("""CREATE TABLE marketplace_registry (
|
|
id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL,
|
|
url VARCHAR NOT NULL, branch VARCHAR, token_env VARCHAR,
|
|
description TEXT, registered_by VARCHAR,
|
|
registered_at TIMESTAMP DEFAULT current_timestamp,
|
|
last_synced_at TIMESTAMP, last_commit_sha VARCHAR, last_error TEXT,
|
|
curator_name VARCHAR, curator_email VARCHAR
|
|
)""")
|
|
conn.execute("""CREATE TABLE marketplace_plugins (
|
|
marketplace_id VARCHAR NOT NULL, name VARCHAR NOT NULL,
|
|
description TEXT, version VARCHAR, author_name VARCHAR,
|
|
homepage VARCHAR, category VARCHAR, source_type VARCHAR,
|
|
source_spec JSON, raw JSON,
|
|
created_at TIMESTAMP DEFAULT current_timestamp,
|
|
updated_at TIMESTAMP DEFAULT current_timestamp,
|
|
cover_photo_url VARCHAR, video_url VARCHAR, doc_links JSON,
|
|
PRIMARY KEY (marketplace_id, name)
|
|
)""")
|
|
conn.execute(
|
|
"INSERT INTO marketplace_registry (id, name, url) VALUES ('legacy', 'Legacy', 'https://example.com/repo.git')"
|
|
)
|
|
conn.execute("INSERT INTO marketplace_plugins (marketplace_id, name) VALUES ('legacy', 'foo')")
|
|
|
|
_ensure_schema(conn)
|
|
assert get_schema_version(conn) == SCHEMA_VERSION
|
|
|
|
cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'marketplace_plugins'"
|
|
).fetchall()
|
|
}
|
|
assert "is_system" in cols
|
|
|
|
# Existing pre-v39 row backfilled to FALSE — no plugin lands in
|
|
# everyone's stack just because we ran the migration.
|
|
row = conn.execute(
|
|
"SELECT is_system FROM marketplace_plugins WHERE marketplace_id = 'legacy' AND name = 'foo'"
|
|
).fetchone()
|
|
assert row[0] is False, f"pre-existing row backfilled to {row[0]!r}"
|
|
conn.close()
|
|
|
|
|
|
def test_v20_adds_source_query(tmp_path):
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
_ensure_schema(conn)
|
|
|
|
cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'table_registry'"
|
|
).fetchall()
|
|
}
|
|
assert "source_query" in cols, f"source_query missing from {cols}"
|
|
assert get_schema_version(conn) == SCHEMA_VERSION
|
|
conn.close()
|
|
|
|
|
|
def test_claude_md_template_seeded_in_instance_templates(tmp_path):
|
|
"""v23 introduced claude_md_template as a singleton table; v28 consolidates
|
|
it into instance_templates keyed 'claude_md'. Post-v28 the legacy table is
|
|
dropped — the canonical lookup is `instance_templates WHERE key='claude_md'`.
|
|
|
|
See tests/test_v28_migration.py for the migration path coverage. This test
|
|
just verifies the seeded row is present on a fresh install.
|
|
"""
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
_ensure_schema(conn)
|
|
|
|
tables = {
|
|
r[0]
|
|
for r in conn.execute("SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'").fetchall()
|
|
}
|
|
assert "instance_templates" in tables
|
|
assert "claude_md_template" not in tables, "claude_md_template should be consolidated away post-v28"
|
|
|
|
row = conn.execute("SELECT key, content FROM instance_templates WHERE key = 'claude_md'").fetchone()
|
|
assert row is not None
|
|
assert row[0] == "claude_md"
|
|
assert row[1] is None # default = no override
|
|
conn.close()
|
|
|
|
|
|
def test_v19_db_migrates_to_v20(tmp_path):
|
|
"""Pre-existing v19 DB (post-RBAC-drop) without source_query upgrades
|
|
cleanly without losing data."""
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
|
|
# Simulate a v19 DB at minimal but realistic shape: schema_version row +
|
|
# a table_registry row in the post-v19 column shape (no is_public column,
|
|
# since v19 finalize dropped it via the table-rebuild idiom).
|
|
conn.execute("CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)")
|
|
conn.execute("INSERT INTO schema_version (version) VALUES (19)")
|
|
conn.execute("""CREATE TABLE table_registry (
|
|
id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL,
|
|
source_type VARCHAR, bucket VARCHAR, source_table VARCHAR,
|
|
sync_strategy VARCHAR DEFAULT 'full_refresh',
|
|
query_mode VARCHAR DEFAULT 'local',
|
|
sync_schedule VARCHAR, profile_after_sync BOOLEAN DEFAULT true,
|
|
primary_key VARCHAR, folder VARCHAR, description TEXT,
|
|
registered_by VARCHAR,
|
|
registered_at TIMESTAMP DEFAULT current_timestamp
|
|
)""")
|
|
conn.execute("INSERT INTO table_registry (id, name) VALUES ('foo', 'foo')")
|
|
|
|
_ensure_schema(conn)
|
|
|
|
assert get_schema_version(conn) == SCHEMA_VERSION # bumped 19→28 forward
|
|
cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'table_registry'"
|
|
).fetchall()
|
|
}
|
|
assert "source_query" in cols
|
|
# Existing row preserved, new column NULL
|
|
row = conn.execute("SELECT id, source_query FROM table_registry WHERE id='foo'").fetchone()
|
|
assert row == ("foo", None)
|
|
conn.close()
|
|
|
|
|
|
def _make_v34_store_entities(conn):
|
|
"""Build a minimal v34-shape store_entities table for v34→v35 path tests.
|
|
|
|
Only includes the columns the v34→v35 migration touches; the rest of
|
|
the schema isn't needed because the function operates only on
|
|
store_entities's column set.
|
|
"""
|
|
conn.execute("""
|
|
CREATE TABLE store_entities (
|
|
id VARCHAR PRIMARY KEY,
|
|
visibility_status VARCHAR DEFAULT 'pending'
|
|
)
|
|
""")
|
|
conn.execute(
|
|
"INSERT INTO store_entities (id, visibility_status) VALUES ('a', 'approved'), ('b', 'pending'), ('c', 'hidden')"
|
|
)
|
|
|
|
|
|
def test_v34_to_v35_clean_path_rebuilds_visibility_column(tmp_path):
|
|
"""Standard v34 → v35 path: ``visibility_status`` is present, no temp
|
|
column. Migration rebuilds the column without the legacy CHECK so
|
|
'archived' becomes a valid value, preserves all row values, and adds
|
|
the audit columns.
|
|
"""
|
|
from src.db import _v34_to_v35_migrate
|
|
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
_make_v34_store_entities(conn)
|
|
|
|
_v34_to_v35_migrate(conn)
|
|
|
|
cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'store_entities'"
|
|
).fetchall()
|
|
}
|
|
assert "visibility_status" in cols
|
|
assert "_vis_v35" not in cols, "temp column must be cleaned up"
|
|
assert "archived_at" in cols
|
|
assert "archived_by" in cols
|
|
|
|
rows = dict(conn.execute("SELECT id, visibility_status FROM store_entities ORDER BY id").fetchall())
|
|
assert rows == {"a": "approved", "b": "pending", "c": "hidden"}, f"row values must survive the rebuild: {rows}"
|
|
conn.close()
|
|
|
|
|
|
def test_v34_to_v35_recovers_from_partial_rebuild_missing_visibility(tmp_path):
|
|
"""Partial-rebuild recovery: a previous migration attempt completed
|
|
steps 3-5 (added _vis_v35, copied values, dropped visibility_status)
|
|
but failed before step 6 (RENAME). Subsequent restarts hit
|
|
DROP visibility_status (no IF EXISTS guard) and looped on the same
|
|
error, leaving the DB stranded with schema_version stuck pre-v35.
|
|
|
|
The new code detects this state — _vis_v35 present, visibility_status
|
|
absent — and finishes the rebuild with the RENAME alone instead of
|
|
re-running the full destructive sequence.
|
|
"""
|
|
from src.db import _v34_to_v35_migrate
|
|
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
# Hand-build the broken state: store_entities with _vis_v35 instead of
|
|
# visibility_status, populated with the canonical values.
|
|
conn.execute("""
|
|
CREATE TABLE store_entities (
|
|
id VARCHAR PRIMARY KEY,
|
|
_vis_v35 VARCHAR
|
|
)
|
|
""")
|
|
conn.execute(
|
|
"INSERT INTO store_entities (id, _vis_v35) VALUES ('a', 'approved'), ('b', 'pending'), ('c', 'hidden')"
|
|
)
|
|
|
|
_v34_to_v35_migrate(conn)
|
|
|
|
cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'store_entities'"
|
|
).fetchall()
|
|
}
|
|
assert "visibility_status" in cols
|
|
assert "_vis_v35" not in cols
|
|
assert "archived_at" in cols
|
|
assert "archived_by" in cols
|
|
|
|
rows = dict(conn.execute("SELECT id, visibility_status FROM store_entities ORDER BY id").fetchall())
|
|
assert rows == {"a": "approved", "b": "pending", "c": "hidden"}, (
|
|
f"row values must come back via RENAME, not be lost: {rows}"
|
|
)
|
|
conn.close()
|
|
|
|
|
|
def test_v34_to_v35_recovers_from_partial_rebuild_both_columns(tmp_path):
|
|
"""Edge state: a prior attempt aborted before the DROP, leaving both
|
|
visibility_status (canonical) and _vis_v35 (temp) on the table.
|
|
The recovery path drops _vis_v35 and keeps visibility_status — the
|
|
rest of the schema expects that name.
|
|
"""
|
|
from src.db import _v34_to_v35_migrate
|
|
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
conn.execute("""
|
|
CREATE TABLE store_entities (
|
|
id VARCHAR PRIMARY KEY,
|
|
visibility_status VARCHAR,
|
|
_vis_v35 VARCHAR
|
|
)
|
|
""")
|
|
conn.execute("INSERT INTO store_entities (id, visibility_status, _vis_v35) VALUES ('a', 'approved', 'approved')")
|
|
|
|
_v34_to_v35_migrate(conn)
|
|
|
|
cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'store_entities'"
|
|
).fetchall()
|
|
}
|
|
assert "visibility_status" in cols
|
|
assert "_vis_v35" not in cols, "temp column must be dropped"
|
|
|
|
row = conn.execute("SELECT id, visibility_status FROM store_entities WHERE id = 'a'").fetchone()
|
|
assert row == ("a", "approved")
|
|
conn.close()
|
|
|
|
|
|
def test_v32_db_with_partial_v35_recovers_through_full_ladder(tmp_path):
|
|
"""End-to-end: a DB stranded at schema_version=32 with the half-applied
|
|
v34→v35 state (visibility_status dropped, _vis_v35 left behind) must
|
|
upgrade cleanly through the full ladder when ``_ensure_schema`` runs.
|
|
|
|
This is the production scenario observed in operator instances after
|
|
the original list-form ``_V34_TO_V35_MIGRATIONS`` failed mid-run on
|
|
a fresh restart.
|
|
"""
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
|
|
# Stand up the broken state. We only need enough of the schema for the
|
|
# migration ladder to run — ``_ensure_schema`` will create the rest
|
|
# via ``_SYSTEM_SCHEMA``'s IF NOT EXISTS guards.
|
|
conn.execute("CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)")
|
|
conn.execute("INSERT INTO schema_version (version) VALUES (32)")
|
|
conn.execute("""
|
|
CREATE TABLE store_entities (
|
|
id VARCHAR PRIMARY KEY,
|
|
owner_user_id VARCHAR,
|
|
owner_username VARCHAR,
|
|
type VARCHAR,
|
|
name VARCHAR,
|
|
archived_at TIMESTAMP,
|
|
archived_by VARCHAR,
|
|
_vis_v35 VARCHAR
|
|
)
|
|
""")
|
|
conn.execute("INSERT INTO store_entities (id, type, name, _vis_v35) VALUES ('a', 'skill', 'alpha', 'approved')")
|
|
|
|
_ensure_schema(conn)
|
|
|
|
assert get_schema_version(conn) == SCHEMA_VERSION
|
|
cols = {
|
|
r[0]
|
|
for r in conn.execute(
|
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'store_entities'"
|
|
).fetchall()
|
|
}
|
|
assert "visibility_status" in cols
|
|
assert "_vis_v35" not in cols
|
|
# Existing row preserved, value carried over from _vis_v35.
|
|
row = conn.execute("SELECT id, visibility_status FROM store_entities WHERE id = 'a'").fetchone()
|
|
assert row == ("a", "approved")
|
|
conn.close()
|
|
|
|
|
|
def test_v35_to_v36_reapplies_visibility_constraints(tmp_path):
|
|
"""v34→v35 dropped NOT NULL + DEFAULT when rebuilding the column to
|
|
drop the legacy CHECK; v35→v36 re-applies them. Verifies that on a
|
|
freshly migrated DB, an INSERT omitting visibility_status either
|
|
inherits the default 'pending' or fails — never lands NULL.
|
|
"""
|
|
db_path = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db_path))
|
|
_ensure_schema(conn)
|
|
assert get_schema_version(conn) == SCHEMA_VERSION
|
|
|
|
cols = conn.execute(
|
|
"SELECT column_name, is_nullable, column_default "
|
|
"FROM information_schema.columns "
|
|
"WHERE table_name = 'store_entities' "
|
|
" AND column_name = 'visibility_status'"
|
|
).fetchall()
|
|
assert cols, "visibility_status column missing from store_entities"
|
|
name, is_nullable, default_expr = cols[0]
|
|
assert is_nullable == "NO", f"visibility_status must be NOT NULL after v36; got is_nullable={is_nullable!r}"
|
|
# DuckDB renders the default as a quoted literal — match either form.
|
|
assert default_expr is not None, "visibility_status DEFAULT must be set"
|
|
assert "pending" in str(default_expr).lower(), f"visibility_status DEFAULT must be 'pending'; got {default_expr!r}"
|
|
|
|
conn.close()
|