Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b09f15586 | ||
|
|
b82bf2198f | ||
|
|
80a9b19d8e | ||
|
|
a82d281ee4 | ||
|
|
e88be0feae | ||
|
|
7bee762404 | ||
|
|
5ad799266c | ||
|
|
477eec7301 | ||
|
|
af94c3e4ca | ||
|
|
0db7952d36 | ||
|
|
38cf24cbe4 | ||
|
|
7ffc29ca19 | ||
|
|
1098c16608 | ||
|
|
34d58e65d9 | ||
|
|
6f7e1c1081 | ||
|
|
33302251c1 | ||
|
|
0f3c6a58ce | ||
|
|
30e1ed1cbe | ||
|
|
6dc7b2ad69 | ||
|
|
55b73cf2a4 | ||
|
|
103bfae6a7 | ||
|
|
3b09017121 | ||
|
|
f18a58ebc5 | ||
|
|
a686e12d55 | ||
|
|
b450d2accf | ||
|
|
ba8877b9fc | ||
|
|
bdc8d0cb5f | ||
|
|
21e220a28c | ||
|
|
8e191ae3d8 | ||
|
|
a993346b1f | ||
|
|
2111e0ecd5 |
@@ -12,6 +12,12 @@ node_modules/
|
||||
pnpm-debug.log
|
||||
.pnpm-store/
|
||||
|
||||
# Vite per-contributor env overrides (e.g. VITE_WPC_API_URL pointing
|
||||
# at a dev community host). Never commit — leaks dev URLs into the
|
||||
# tree and makes `pnpm build` produce divergent bundles per checkout.
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
js/
|
||||
web/
|
||||
|
||||
@@ -202,16 +202,46 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
|
||||
touched_before, diff = fix_option_delete(
|
||||
conn, extra.get("wildcard_id", ""), target_id,
|
||||
)
|
||||
elif action == "delete" and kind in (
|
||||
"fixed_values",
|
||||
"combine",
|
||||
"derivation",
|
||||
"constraint",
|
||||
):
|
||||
# These module types are leaves from a reverse-ref
|
||||
# perspective: nothing in another row's payload points
|
||||
# at them by id the way `option.id` is referenced from
|
||||
# constraint matrices or `wildcard.id` is referenced
|
||||
# from constraint source/target. A whole-entity delete
|
||||
# is just a row drop + undo snapshot — no fix step,
|
||||
# no diff entries.
|
||||
#
|
||||
# Caveat: combine.output_var IS referenced by name
|
||||
# from downstream payloads, but the cascade pipeline
|
||||
# treats that as a "rename" concern (already wired as
|
||||
# combine_output_var/rename above). Deleting a combine
|
||||
# leaves any consumer referencing the output_var with
|
||||
# an unresolved variable, surfaced separately by the
|
||||
# integrity/broken-refs scan.
|
||||
touched_before, diff = [], []
|
||||
else:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"unsupported (kind, action) pair: ({kind}, {action})",
|
||||
}
|
||||
|
||||
# Delete target entity for whole-entity delete ops.
|
||||
# Subcategory deletes are NOT in this set — the fixer already
|
||||
# mutated the source wildcard's payload; there is no separate row.
|
||||
if action == "delete" and kind in ("wildcard", "category", "bundle"):
|
||||
# Delete target entity for whole-entity delete ops. Module
|
||||
# subtypes route through ModuleRepository via _delete_target
|
||||
# (the else-branch of its dispatch).
|
||||
if action == "delete" and kind in (
|
||||
"wildcard",
|
||||
"category",
|
||||
"bundle",
|
||||
"fixed_values",
|
||||
"combine",
|
||||
"derivation",
|
||||
"constraint",
|
||||
):
|
||||
target_snapshot = _delete_target(conn, kind, target_id)
|
||||
touched_before.append(target_snapshot)
|
||||
diff.append({"entity_id": target_id, "removed": True})
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Lazy-read routine — the ONE migration runtime.
|
||||
|
||||
See community-web spec
|
||||
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
|
||||
2026-06-05-schema-versioning-migration-design.md §4 Flow 5. Every row
|
||||
read (loadModule / loadBundle) goes through this function; the engine
|
||||
startup bulk runner also calls it for each stale row.
|
||||
|
||||
Six branches:
|
||||
1. No-op (row at CURRENT, original NULL or original.sv ≤ CURRENT).
|
||||
2. NULL-drop optimization (row.sv == CURRENT, original.sv == CURRENT).
|
||||
3. Recovery exact catch-up (original.sv ≤ CURRENT, strict-validate
|
||||
succeeds): promote original into payload, NULL original column.
|
||||
4. Still-drifted re-project (original.sv > CURRENT): tolerantly strip.
|
||||
5. Deferred strict-validate failure: mark row 'broken'.
|
||||
6. Standard forward chain (row.sv < CURRENT, no recovery applicable).
|
||||
|
||||
CRITICAL bug-fix from spec §4: the write-back gate is
|
||||
'base_v < current OR base IS original', NOT just 'base_v < current'.
|
||||
Otherwise the recovery exact-catch-up case (base_v == current AND base
|
||||
IS original) silently no-ops and the row stays drifted.
|
||||
|
||||
The three dependency-injected dicts:
|
||||
migrators[(kind, v)] → (payload, ctx) -> next_payload
|
||||
validators[(kind, v, subtype)] → payload -> bool
|
||||
tolerant_strip[(kind, target_v, sub)] → payload -> stripped_payload
|
||||
|
||||
Subtype lookup: for modules, derived from payload["type"]; for bundles, None.
|
||||
original_payload_json is INERT — only the initial tolerant install + this
|
||||
NULL-drop touch it. schema_migrated_at is SEPARATE from updated_at
|
||||
(structural migration is not a user edit).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso
|
||||
|
||||
Migrators = dict[tuple[str, int], Callable[[dict[str, Any], dict], dict]]
|
||||
Validators = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], bool]]
|
||||
TolerantStrip = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], dict]]
|
||||
|
||||
|
||||
def lazy_migrate_row(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kind: str,
|
||||
row_id: str,
|
||||
current_version: int,
|
||||
migrators: Migrators,
|
||||
validators: Validators,
|
||||
tolerant_strip: TolerantStrip | None = None,
|
||||
) -> None:
|
||||
"""Apply lazy migration to a single row.
|
||||
|
||||
Idempotent: if row is already at CURRENT and original is NULL or
|
||||
matches CURRENT, returns without writing (or fires NULL-drop only).
|
||||
"""
|
||||
table = "modules" if kind == "module" else "bundles"
|
||||
row = conn.execute(f"SELECT * FROM {table} WHERE id=?", (row_id,)).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
|
||||
sv = row["schema_version"]
|
||||
original_raw = row["original_payload_json"]
|
||||
original = json.loads(original_raw) if original_raw else None
|
||||
# Narrow original_v to int so the rest of the function's `original is not None`
|
||||
# checks correctly imply an int. A malformed original (no schema_version, or
|
||||
# non-int) is demoted to the no-original branch — the row's payload column is
|
||||
# the only thing we trust.
|
||||
original_v: int | None = None
|
||||
if original is not None:
|
||||
raw_v = original.get("schema_version")
|
||||
if isinstance(raw_v, int):
|
||||
original_v = raw_v
|
||||
else:
|
||||
original = None
|
||||
payload = json.loads(row["payload"])
|
||||
subtype = payload.get("type") if kind == "module" else None
|
||||
|
||||
# Branch 1: no-op or NULL-drop
|
||||
if sv == current_version and (
|
||||
original is None or (original_v is not None and original_v <= current_version)
|
||||
):
|
||||
if original is not None and original_v == sv:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET original_payload_json=NULL, "
|
||||
f"tolerant_drift_status='none' WHERE id=?",
|
||||
(row_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
|
||||
# Decide base
|
||||
base_is_original = False
|
||||
base_v: int
|
||||
if original is not None and original_v is not None and original_v > sv:
|
||||
if original_v <= current_version:
|
||||
# Recovery candidate — deferred strict-validate FIRST
|
||||
strict = validators.get((kind, original_v, subtype))
|
||||
if strict is not None and not strict(original):
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET tolerant_drift_status='broken', "
|
||||
f"schema_migrated_at=? WHERE id=?",
|
||||
(now_iso(), row_id),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
base = original
|
||||
base_v = original_v
|
||||
base_is_original = True
|
||||
else:
|
||||
# Still drifted; tolerantly strip original down to current
|
||||
strip = (tolerant_strip or {}).get((kind, current_version, subtype))
|
||||
if strip is None:
|
||||
return
|
||||
projected = strip(original)
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET payload=?, schema_version=?, "
|
||||
f"tolerant_drift_status='pending', schema_migrated_at=? WHERE id=?",
|
||||
(
|
||||
json.dumps(projected),
|
||||
current_version,
|
||||
now_iso(),
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
else:
|
||||
base = payload
|
||||
base_v = sv
|
||||
|
||||
# Write-back gate (THE BUG FIX): write when base_v < current OR base IS original.
|
||||
# The recovery exact-catch-up case has zero chain steps (base_v == current_version)
|
||||
# but still needs the write to promote original into payload + NULL the original
|
||||
# column. Gating on 'base_v < current_version' alone silently no-ops the recovery.
|
||||
needs_write = (base_v < current_version) or base_is_original
|
||||
if not needs_write:
|
||||
return
|
||||
|
||||
new_payload = base
|
||||
for v in range(base_v, current_version):
|
||||
migrator = migrators.get((kind, v))
|
||||
if migrator is None:
|
||||
return
|
||||
new_payload = migrator(new_payload, {})
|
||||
|
||||
promote_from_original = base_is_original
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET payload=?, schema_version=?, schema_migrated_at=?, "
|
||||
f"original_payload_json=?, tolerant_drift_status=? WHERE id=?",
|
||||
(
|
||||
json.dumps(new_payload),
|
||||
current_version,
|
||||
now_iso(),
|
||||
None if promote_from_original else row["original_payload_json"],
|
||||
"none" if promote_from_original else row["tolerant_drift_status"],
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Migration 013 — community install origin columns.
|
||||
|
||||
Adds ``community_post_slug`` and ``community_version_number`` to both
|
||||
``modules`` and ``bundles``. Stamped at install time by the host-bridge
|
||||
``install()`` path (via the embed's ``origin`` opt) so the SPA can:
|
||||
|
||||
- Render an "installed from community" pill on origin-stamped rows.
|
||||
- Run an update-availability check that compares
|
||||
``community_version_number`` against the post's
|
||||
``latest_version_number`` in the community API.
|
||||
|
||||
Both columns are nullable on purpose — locally-authored rows (Create
|
||||
flow, Import-from-file, starter set) leave them NULL forever.
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
||||
the migration against a DB that already has the columns is a no-op.
|
||||
The migration runner's existing version table guards against re-runs
|
||||
in production; this guard exists for tests that explicitly rewind the
|
||||
``migrations`` table to exercise the migration on a populated DB
|
||||
(see ``tests/engine/db/test_migration_010.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
if not _has_column(conn, "modules", "community_post_slug"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN community_post_slug TEXT;")
|
||||
if not _has_column(conn, "modules", "community_version_number"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN community_version_number INTEGER;")
|
||||
if not _has_column(conn, "bundles", "community_post_slug"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN community_post_slug TEXT;")
|
||||
if not _has_column(conn, "bundles", "community_version_number"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN community_version_number INTEGER;")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_modules_community_slug "
|
||||
"ON modules(community_post_slug);"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_bundles_community_slug "
|
||||
"ON bundles(community_post_slug);"
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Migration 014: payload schema_version + verbatim-local mirror columns.
|
||||
|
||||
See the community-web design doc at
|
||||
docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
|
||||
(in the community-web repo) sections 4 + 5 for the design. The columns:
|
||||
|
||||
- schema_version (INTEGER NOT NULL DEFAULT 1): the payload's shape
|
||||
version. ``1`` is the implicit baseline — every row that existed
|
||||
before this migration was authored against shape v1, so the DEFAULT
|
||||
doubles as the backfill.
|
||||
- original_payload_json (TEXT, nullable): verbatim future-shaped
|
||||
producer bytes preserved through tolerant installs. NULL means no
|
||||
preservation was needed (row was installed at-or-below CURRENT, i.e.
|
||||
no future-shape tolerance was applied).
|
||||
- tolerant_drift_status (TEXT NOT NULL DEFAULT 'none'): enum tracking
|
||||
the drift state of the row's payload vs the catalog. ``'none'`` = no
|
||||
drift; ``'pending'`` = drifted (tolerant-installed from a future
|
||||
shape and not yet reconciled); ``'broken'`` = deferred strict-validate
|
||||
failed.
|
||||
- schema_migrated_at (TEXT, nullable): timestamp of the last structural
|
||||
migration on the row. Intentionally SEPARATE from ``updated_at`` —
|
||||
structural migration is the engine moving the payload between shape
|
||||
versions, not a user edit, so it must not dirty user-edit state.
|
||||
|
||||
Backfill: every pre-existing row stamps ``schema_version=1`` and
|
||||
``tolerant_drift_status='none'`` implicitly via the DEFAULT on the
|
||||
NOT NULL columns — SQLite materializes the default into existing rows
|
||||
when ADD COLUMN runs.
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
||||
the migration against a DB that already has the columns is a no-op.
|
||||
The migration runner's existing version table guards against re-runs
|
||||
in production; this guard exists for tests that explicitly rewind the
|
||||
``migrations`` table to exercise the migration on a populated DB
|
||||
(see ``tests/engine/db/test_migration_010.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
# modules
|
||||
if not _has_column(conn, "modules", "schema_version"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
||||
)
|
||||
if not _has_column(conn, "modules", "original_payload_json"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN original_payload_json TEXT;")
|
||||
if not _has_column(conn, "modules", "tolerant_drift_status"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN tolerant_drift_status "
|
||||
"TEXT NOT NULL DEFAULT 'none';"
|
||||
)
|
||||
if not _has_column(conn, "modules", "schema_migrated_at"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN schema_migrated_at TEXT;")
|
||||
|
||||
# bundles
|
||||
if not _has_column(conn, "bundles", "schema_version"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "original_payload_json"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN original_payload_json TEXT;")
|
||||
if not _has_column(conn, "bundles", "tolerant_drift_status"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN tolerant_drift_status "
|
||||
"TEXT NOT NULL DEFAULT 'none';"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "schema_migrated_at"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN schema_migrated_at TEXT;")
|
||||
|
||||
# Bulk run: at the time 014 ships, CURRENT_SCHEMA_VERSION is still 1, so
|
||||
# every existing row is already at CURRENT and the bulk is a no-op pass.
|
||||
# We invoke it anyway to exercise + validate the wiring on first run.
|
||||
# FUTURE: when a bump migration 015 ships, it ALSO calls run_bulk_lazy
|
||||
# with the new CURRENT and the bump's migrators.
|
||||
run_bulk_lazy(conn, current_version=1, migrators={}, validators={}, tolerant_strip={})
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Helpers for migration 014 — the bulk runner used at startup + at
|
||||
future payload-bump migrations.
|
||||
|
||||
See community-web spec
|
||||
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
|
||||
2026-06-05-schema-versioning-migration-design.md §4 Flow 4: bulk is
|
||||
EAGER ITERATION of the lazy-read routine, NOT a parallel forward-chain
|
||||
implementation. This eliminates the transient-wrong window where bulk
|
||||
could write fabricated defaults over recoverable data preserved in
|
||||
original_payload_json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.lazy_migrate import lazy_migrate_row
|
||||
|
||||
STALE_PREDICATE = (
|
||||
"schema_version < ? "
|
||||
"OR (original_payload_json IS NOT NULL AND "
|
||||
" json_extract(original_payload_json, '$.schema_version') > schema_version)"
|
||||
)
|
||||
|
||||
|
||||
def run_bulk_lazy(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
current_version: int,
|
||||
migrators,
|
||||
validators,
|
||||
tolerant_strip,
|
||||
) -> int:
|
||||
"""Eager iteration of lazy_migrate_row over every stale row.
|
||||
|
||||
The stale predicate catches BOTH 'sv < current' AND 'tolerantly-
|
||||
installed row whose original now outranks the projection'. The
|
||||
second clause handles rows where the extension has caught up after
|
||||
a previous tolerant install.
|
||||
|
||||
Returns count of rows that failed to migrate (caller surfaces a
|
||||
dashboard banner when > 0).
|
||||
"""
|
||||
failed = 0
|
||||
for table, kind in [("modules", "module"), ("bundles", "bundle")]:
|
||||
ids = [
|
||||
row["id"]
|
||||
for row in conn.execute(
|
||||
f"SELECT id FROM {table} WHERE {STALE_PREDICATE}",
|
||||
(current_version,),
|
||||
).fetchall()
|
||||
]
|
||||
for row_id in ids:
|
||||
try:
|
||||
lazy_migrate_row(
|
||||
conn,
|
||||
kind=kind,
|
||||
row_id=row_id,
|
||||
current_version=current_version,
|
||||
migrators=migrators,
|
||||
validators=validators,
|
||||
tolerant_strip=tolerant_strip,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — defensive sweep
|
||||
failed += 1
|
||||
continue
|
||||
# After lazy_migrate_row: if the row's schema_version is still
|
||||
# below current_version, count as failed.
|
||||
after = conn.execute(
|
||||
f"SELECT schema_version FROM {table} WHERE id=?", (row_id,)
|
||||
).fetchone()
|
||||
if after and after["schema_version"] < current_version:
|
||||
failed += 1
|
||||
return failed
|
||||
@@ -55,6 +55,19 @@ class ModuleNotFound(LookupError):
|
||||
|
||||
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
payload = json.loads(row["payload"])
|
||||
# Community origin columns (migration 013) — NULL for any row that
|
||||
# didn't come from a community install. Surfaced verbatim so the
|
||||
# SPA can render an "installed from community" badge + drive
|
||||
# update-available checks.
|
||||
try:
|
||||
community_post_slug = row["community_post_slug"]
|
||||
community_version_number = row["community_version_number"]
|
||||
except (IndexError, KeyError):
|
||||
# Defensive: a connection opened against a DB that hasn't run
|
||||
# migration 013 yet (e.g. mid-rollout, test fixtures) won't
|
||||
# have these columns. Treat absence as NULL.
|
||||
community_post_slug = None
|
||||
community_version_number = None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
@@ -69,6 +82,8 @@ def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"community_post_slug": community_post_slug,
|
||||
"community_version_number": community_version_number,
|
||||
}
|
||||
|
||||
|
||||
@@ -481,6 +496,12 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
|
||||
intentionally frozen — the saved hash is the source of truth for
|
||||
insert-time `inserted_at_hash` capture + library-drift detection.
|
||||
"""
|
||||
try:
|
||||
community_post_slug = row["community_post_slug"]
|
||||
community_version_number = row["community_version_number"]
|
||||
except (IndexError, KeyError):
|
||||
community_post_slug = None
|
||||
community_version_number = None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
@@ -494,6 +515,8 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"community_post_slug": community_post_slug,
|
||||
"community_version_number": community_version_number,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+97
-35
@@ -154,8 +154,9 @@ def _insert_module(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, snapshot_fingerprint, version, "
|
||||
"created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
"created_at, updated_at, "
|
||||
"community_post_slug, community_version_number"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
mid, kind, entity["name"], entity.get("description", ""),
|
||||
entity.get("category_id"),
|
||||
@@ -166,6 +167,12 @@ def _insert_module(
|
||||
entity.get("version", 1),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
# Community origin — only set when the install bridge stamps
|
||||
# them on the entity (see manager/main.ts snapshotLibrary +
|
||||
# the embed's install call). Local Create/Import-from-file
|
||||
# paths leave these NULL so the row stays "locally authored".
|
||||
entity.get("community_post_slug"),
|
||||
entity.get("community_version_number"),
|
||||
),
|
||||
)
|
||||
return mid
|
||||
@@ -193,8 +200,9 @@ def _insert_bundle(
|
||||
"INSERT INTO bundles("
|
||||
"id, name, description, color, category_id, tags, "
|
||||
"is_favorite, children, payload_hash, version, "
|
||||
"created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
"created_at, updated_at, "
|
||||
"community_post_slug, community_version_number"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
bid, entity["name"], entity.get("description", ""),
|
||||
entity.get("color"),
|
||||
@@ -206,6 +214,9 @@ def _insert_bundle(
|
||||
entity.get("version", 1),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
# Community origin — see _insert_module comment above.
|
||||
entity.get("community_post_slug"),
|
||||
entity.get("community_version_number"),
|
||||
),
|
||||
)
|
||||
return bid
|
||||
@@ -219,50 +230,101 @@ def _update_module(
|
||||
Preserves `mid` and the row's `type` (the kind dispatch already
|
||||
routed us here so type is implied). Bumps `version` and stamps
|
||||
`updated_at` like `ModuleRepository.update`.
|
||||
|
||||
Community origin (migration 013) is rewritten ONLY when the new
|
||||
content explicitly carries it — a community Update-in-place
|
||||
install stamps both fields on the entity, while a vanilla Import
|
||||
tab replace leaves them absent and the row keeps its prior
|
||||
community_* values (or NULL for locally-authored rows).
|
||||
"""
|
||||
_require_entity_fields(kind, "replace", content, ("name",))
|
||||
now = now_iso()
|
||||
fp = _module_fp_for_entity({**content, "type": kind})
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now, mid,
|
||||
),
|
||||
)
|
||||
if "community_post_slug" in content or "community_version_number" in content:
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"community_post_slug = ?, community_version_number = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now,
|
||||
content.get("community_post_slug"),
|
||||
content.get("community_version_number"),
|
||||
mid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now, mid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _update_bundle(
|
||||
conn: sqlite3.Connection, bid: str, content: dict[str, Any],
|
||||
) -> None:
|
||||
"""UPDATE the bundles row identified by `bid` to mirror `content`."""
|
||||
"""UPDATE the bundles row identified by `bid` to mirror `content`.
|
||||
|
||||
See _update_module for the community_* rewrite semantics.
|
||||
"""
|
||||
_require_entity_fields("bundle", "replace", content, ("name",))
|
||||
children_blob = list(content.get("children") or [])
|
||||
ph = payload_hash({"children": children_blob})
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now, bid,
|
||||
),
|
||||
)
|
||||
if "community_post_slug" in content or "community_version_number" in content:
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"community_post_slug = ?, community_version_number = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now,
|
||||
content.get("community_post_slug"),
|
||||
content.get("community_version_number"),
|
||||
bid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now, bid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _module_exists(conn: sqlite3.Connection, mid: str) -> bool:
|
||||
|
||||
+6
-5
@@ -27,15 +27,13 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5",
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.14",
|
||||
"postcss": "^8.4.47",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@comfyorg/comfyui-frontend-types": "^1.42.15",
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
@@ -48,12 +46,15 @@
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vue/test-utils": "^2.4.8",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.31.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^29.0.2",
|
||||
"postcss": "^8.4.47",
|
||||
"semantic-release": "^25.0.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
|
||||
Generated
+3
@@ -20,6 +20,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^4.4.5
|
||||
version: 4.6.4(vue@3.5.33(typescript@5.9.3))
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@comfyorg/comfyui-frontend-types':
|
||||
specifier: ^1.42.15
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Runtime contracts shared with the community web service.
|
||||
*
|
||||
* The community service is the schema authority — the sister project
|
||||
* (this manager) consumes these contracts when importing/installing
|
||||
* payloads downloaded from the community side. Keep these in sync
|
||||
* with `web/src/api/types.ts` in the Wildcard-Pipeline-Community repo.
|
||||
*
|
||||
* This module deliberately holds runtime/transport contracts only.
|
||||
* Manager-internal types (engine row shapes, UI state) live under
|
||||
* `src/manager/api/types.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row from the community service's schema_catalog table (community
|
||||
* spec §3). Returned alongside a download payload so the installer can
|
||||
* plan its migration / tolerant-parse strategy without a second
|
||||
* round-trip. Mirrors the Pydantic schema in
|
||||
* `api/app/schemas/catalog.py` on the community side.
|
||||
*
|
||||
* Fields:
|
||||
* - `version`: schema version this row describes.
|
||||
* - `is_breaking_from_previous`: true when going from `version - 1`
|
||||
* to `version` introduced a NON-additive change (field removal,
|
||||
* type change, semantics change). Used by `decideInstallPath`'s
|
||||
* AND-fold check.
|
||||
* - `min_consumer_engine_version`: engine semver gate, or null when
|
||||
* no minimum is enforced.
|
||||
* - `notes`: free-form release notes for this schema bump.
|
||||
* - `created_at`: ISO timestamp the row was inserted, or null on
|
||||
* hand-seeded rows.
|
||||
*/
|
||||
export interface SchemaCatalogEntry {
|
||||
version: number;
|
||||
is_breaking_from_previous: boolean;
|
||||
min_consumer_engine_version: string | null;
|
||||
notes: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideInstallPath } from "@/manager/import-export/install";
|
||||
|
||||
import type { SchemaCatalogEntry } from "@/api/types";
|
||||
|
||||
const catalogAdditive: SchemaCatalogEntry[] = [
|
||||
{ version: 1, is_breaking_from_previous: false, min_consumer_engine_version: null, notes: "", created_at: null },
|
||||
{ version: 2, is_breaking_from_previous: false, min_consumer_engine_version: null, notes: "", created_at: null },
|
||||
];
|
||||
const catalogBreaking: SchemaCatalogEntry[] = [
|
||||
{ version: 1, is_breaking_from_previous: false, min_consumer_engine_version: null, notes: "", created_at: null },
|
||||
{ version: 2, is_breaking_from_previous: true, min_consumer_engine_version: null, notes: "", created_at: null },
|
||||
];
|
||||
|
||||
describe("install path decision", () => {
|
||||
it("Path 3a: payload at CURRENT -> 'install_direct'", () => {
|
||||
const out = decideInstallPath({ payloadVersion: 1, currentVersion: 1, catalog: catalogAdditive });
|
||||
expect(out.path).toBe("install_direct");
|
||||
});
|
||||
|
||||
it("Path 3a: payload below CURRENT -> 'install_with_chain'", () => {
|
||||
const out = decideInstallPath({ payloadVersion: 1, currentVersion: 2, catalog: catalogAdditive });
|
||||
expect(out.path).toBe("install_with_chain");
|
||||
});
|
||||
|
||||
it("Path 3b: payload above CURRENT, all-additive -> 'install_tolerant'", () => {
|
||||
const out = decideInstallPath({ payloadVersion: 2, currentVersion: 1, catalog: catalogAdditive });
|
||||
expect(out.path).toBe("install_tolerant");
|
||||
});
|
||||
|
||||
it("Path 3b: payload above CURRENT, any-breaking-in-interval -> 'refuse'", () => {
|
||||
const out = decideInstallPath({ payloadVersion: 2, currentVersion: 1, catalog: catalogBreaking });
|
||||
expect(out.path).toBe("refuse");
|
||||
});
|
||||
|
||||
it("AND-fold catches breaking step in middle of interval", () => {
|
||||
const catalog: SchemaCatalogEntry[] = [
|
||||
{ version: 1, is_breaking_from_previous: false, min_consumer_engine_version: null, notes: "", created_at: null },
|
||||
{ version: 2, is_breaking_from_previous: false, min_consumer_engine_version: null, notes: "", created_at: null },
|
||||
{ version: 3, is_breaking_from_previous: true, min_consumer_engine_version: null, notes: "", created_at: null },
|
||||
{ version: 4, is_breaking_from_previous: false, min_consumer_engine_version: null, notes: "", created_at: null },
|
||||
];
|
||||
// CURRENT=2, payload=4: interval is (2, 4] = {3, 4}. v3 is breaking -> refuse.
|
||||
const out = decideInstallPath({ payloadVersion: 4, currentVersion: 2, catalog });
|
||||
expect(out.path).toBe("refuse");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
migratePayload,
|
||||
registerMigrator,
|
||||
_resetRegistryForTests,
|
||||
} from "@/manager/import-export/migrations";
|
||||
|
||||
describe("migratePayload contract", () => {
|
||||
beforeEach(() => _resetRegistryForTests());
|
||||
|
||||
it("runs a single forward step on module", () => {
|
||||
registerMigrator({
|
||||
kind: "module",
|
||||
fromVersion: 1,
|
||||
migrate: (payload) => ({ ...payload, added_in_v2: true }),
|
||||
});
|
||||
const out = migratePayload({ id: "m1" }, "module", 1, 2);
|
||||
expect(out).toMatchObject({ id: "m1", added_in_v2: true });
|
||||
});
|
||||
|
||||
it("chains multiple steps for a longer interval", () => {
|
||||
registerMigrator({ kind: "module", fromVersion: 1, migrate: (p) => ({ ...p, v2: true }) });
|
||||
registerMigrator({ kind: "module", fromVersion: 2, migrate: (p) => ({ ...p, v3: true }) });
|
||||
const out = migratePayload({ id: "m1" }, "module", 1, 3);
|
||||
expect(out).toMatchObject({ v2: true, v3: true });
|
||||
});
|
||||
|
||||
it("bundle migrator MUST recurse into children — single-version-per-root invariant", () => {
|
||||
registerMigrator({
|
||||
kind: "module", fromVersion: 1,
|
||||
migrate: (payload) => ({ ...payload, nsfw: false }),
|
||||
});
|
||||
registerMigrator({
|
||||
kind: "bundle", fromVersion: 1,
|
||||
migrate: (payload, ctx) => ({
|
||||
...payload,
|
||||
children: ((payload.children as unknown[]) ?? []).map((child) =>
|
||||
ctx.applyModuleStep(child as Record<string, unknown>, 1)
|
||||
),
|
||||
}),
|
||||
});
|
||||
const out = migratePayload(
|
||||
{ id: "b1", children: [{ id: "wc1", type: "wildcard" }] },
|
||||
"bundle", 1, 2,
|
||||
);
|
||||
const child = (out.children as Record<string, unknown>[])[0];
|
||||
expect(child.nsfw).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects calling a non-registered migrator", () => {
|
||||
expect(() => migratePayload({}, "module", 1, 2))
|
||||
.toThrow(/no migrator for module 1->2/);
|
||||
});
|
||||
|
||||
it("supports context injection when requires_context is declared", () => {
|
||||
registerMigrator({
|
||||
kind: "module", fromVersion: 1,
|
||||
requiresContext: ["categoryTree"],
|
||||
migrate: (payload, ctx) => ({
|
||||
...payload,
|
||||
category_path: ctx.categoryTree?.lookup(payload.category_id as string),
|
||||
}),
|
||||
});
|
||||
const ctx = { categoryTree: { lookup: (id: string) => `path/of/${id}` } };
|
||||
const out = migratePayload({ id: "m1", category_id: "c1" }, "module", 1, 2, ctx);
|
||||
expect(out.category_path).toBe("path/of/c1");
|
||||
});
|
||||
|
||||
it("throws when requires_context is declared but missing", () => {
|
||||
registerMigrator({
|
||||
kind: "module", fromVersion: 1,
|
||||
requiresContext: ["categoryTree"],
|
||||
migrate: (payload) => payload,
|
||||
});
|
||||
expect(() => migratePayload({ id: "m1" }, "module", 1, 2))
|
||||
.toThrow(/requires context.*categoryTree/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildPublishBody } from "@/manager/import-export/single-row-publish";
|
||||
import { CURRENT_SCHEMA_VERSION } from "@/manager/import-export/migrations";
|
||||
import wildcardFixture from "@/validators/fixtures/v1/module-wildcard.json";
|
||||
|
||||
// Re-import the version constant from package.json so this test
|
||||
// stays in sync if the version is bumped.
|
||||
import { version as PKG_VERSION } from "../../../../package.json";
|
||||
|
||||
describe("publish body stamping", () => {
|
||||
it("stamps schema_version = CURRENT_SCHEMA_VERSION", () => {
|
||||
const body = buildPublishBody({
|
||||
payload: wildcardFixture as Record<string, unknown>,
|
||||
name: "demo",
|
||||
description: "",
|
||||
});
|
||||
expect(body.schema_version).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("stamps producer_engine_version from package.json", () => {
|
||||
const body = buildPublishBody({
|
||||
payload: wildcardFixture as Record<string, unknown>,
|
||||
name: "demo",
|
||||
description: "",
|
||||
});
|
||||
expect(body.producer_engine_version).toBe(PKG_VERSION);
|
||||
});
|
||||
|
||||
it("throws on payloads that fail strict v1 validation", () => {
|
||||
const broken = { ...wildcardFixture, mystery_field: 42 } as Record<string, unknown>;
|
||||
expect(() => buildPublishBody({ payload: broken, name: "demo", description: "" }))
|
||||
.toThrowError(/structural validation/);
|
||||
});
|
||||
|
||||
it("identifies kind=bundle when children array is present", () => {
|
||||
const bundlePayload = {
|
||||
id: "bd-001abc",
|
||||
name: "starter-bundle",
|
||||
description: "",
|
||||
color: null,
|
||||
category_id: null,
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
children: [],
|
||||
} as Record<string, unknown>;
|
||||
const body = buildPublishBody({ payload: bundlePayload, name: "demo", description: "" });
|
||||
expect(body.schema_version).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("forwards name + description verbatim", () => {
|
||||
const body = buildPublishBody({
|
||||
payload: wildcardFixture as Record<string, unknown>,
|
||||
name: "my-thing",
|
||||
description: "long description",
|
||||
});
|
||||
expect(body.name).toBe("my-thing");
|
||||
expect(body.description).toBe("long description");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseTolerantAsCurrentShape } from "@/manager/import-export/tolerant-parse";
|
||||
import wildcardFixture from "@/validators/fixtures/v1/module-wildcard.json";
|
||||
|
||||
describe("parseTolerantAsCurrentShape", () => {
|
||||
it("strips unknown top-level fields", () => {
|
||||
const futureShaped = {
|
||||
...wildcardFixture,
|
||||
nsfw: true, // hypothetical v2 additive
|
||||
tier_label: "epic", // hypothetical v3 additive
|
||||
};
|
||||
const result = parseTolerantAsCurrentShape(futureShaped, 1, "module", "wildcard");
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect("nsfw" in result.data).toBe(false);
|
||||
expect("tier_label" in result.data).toBe(false);
|
||||
expect((result.data as { id: string }).id).toBe(wildcardFixture.id);
|
||||
});
|
||||
|
||||
it("returns failure when required current-shape field is missing", () => {
|
||||
const broken: Record<string, unknown> = { ...wildcardFixture };
|
||||
delete broken.payload;
|
||||
const result = parseTolerantAsCurrentShape(broken, 1, "module", "wildcard");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("recursively strips inside bundle children", () => {
|
||||
const futureBundle = {
|
||||
id: "b1",
|
||||
name: "b",
|
||||
description: "",
|
||||
color: null,
|
||||
category_id: null,
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
children: [{ ...wildcardFixture, nsfw: true }],
|
||||
ghost_field: "drop me",
|
||||
};
|
||||
const result = parseTolerantAsCurrentShape(futureBundle, 1, "bundle");
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect("ghost_field" in result.data).toBe(false);
|
||||
const data = result.data as { children: Record<string, unknown>[] };
|
||||
expect("nsfw" in data.children[0]).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getValidator } from "@/validators";
|
||||
|
||||
import wildcardFixture from "@/validators/fixtures/v1/module-wildcard.json";
|
||||
import fixedValuesFixture from "@/validators/fixtures/v1/module-fixed_values.json";
|
||||
import combineFixture from "@/validators/fixtures/v1/module-combine.json";
|
||||
import derivationFixture from "@/validators/fixtures/v1/module-derivation.json";
|
||||
import constraintFixture from "@/validators/fixtures/v1/module-constraint.json";
|
||||
import bundleFixture from "@/validators/fixtures/v1/bundle.json";
|
||||
|
||||
describe("validator registry", () => {
|
||||
it("returns a strict validator for each subtype at v1", () => {
|
||||
for (const subtype of ["wildcard", "fixed_values", "combine", "derivation", "constraint"] as const) {
|
||||
const validator = getValidator({ kind: "module", subtype, version: 1, mode: "strict" });
|
||||
expect(validator).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("strict validators accept their own fixtures", () => {
|
||||
const cases = [
|
||||
["wildcard", wildcardFixture],
|
||||
["fixed_values", fixedValuesFixture],
|
||||
["combine", combineFixture],
|
||||
["derivation", derivationFixture],
|
||||
["constraint", constraintFixture],
|
||||
] as const;
|
||||
for (const [subtype, fixture] of cases) {
|
||||
const v = getValidator({ kind: "module", subtype, version: 1, mode: "strict" });
|
||||
const result = v.safeParse(fixture);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("bundle v1 validator accepts the bundle fixture", () => {
|
||||
const v = getValidator({ kind: "bundle", version: 1, mode: "strict" });
|
||||
expect(v.safeParse(bundleFixture).success).toBe(true);
|
||||
});
|
||||
|
||||
it("strict validators reject extra fields", () => {
|
||||
const v = getValidator({ kind: "module", subtype: "wildcard", version: 1, mode: "strict" });
|
||||
const withExtra = { ...wildcardFixture, mystery_field: 42 };
|
||||
expect(v.safeParse(withExtra).success).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerant validators silently strip extra fields", () => {
|
||||
const v = getValidator({ kind: "module", subtype: "wildcard", version: 1, mode: "tolerant" });
|
||||
const withExtra = { ...wildcardFixture, mystery_field: 42 };
|
||||
const result = v.safeParse(withExtra);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect("mystery_field" in result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on unknown (kind, version)", () => {
|
||||
expect(() => getValidator({ kind: "module", subtype: "wildcard", version: 99, mode: "strict" }))
|
||||
.toThrowError(/no validator for/);
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,16 @@ export interface ModuleRow {
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/**
|
||||
* Community origin (engine migration 013). Stamped on rows that
|
||||
* were installed via the community embed's host-bridge install
|
||||
* call with `origin: { post_slug, version_number }`. Drives the
|
||||
* "installed from community" badge + the update-available check
|
||||
* that compares `community_version_number` against the post's
|
||||
* current `latest_version_number`. Null on locally-authored rows.
|
||||
*/
|
||||
community_post_slug?: string | null;
|
||||
community_version_number?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,6 +237,9 @@ export interface BundleRow {
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** See ModuleRow.community_post_slug — same semantics for bundles. */
|
||||
community_post_slug?: string | null;
|
||||
community_version_number?: number | null;
|
||||
}
|
||||
|
||||
export interface BundleListResponse {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Loads the Wildcard Pipeline Community embed bundle at runtime.
|
||||
*
|
||||
* The bundle ships as an ESM module at
|
||||
* `${WPC_API_URL}/embed/wpc-embed.js`. We dynamic-import it the first
|
||||
* time someone visits the Community tab and cache the resulting
|
||||
* module so re-entry is instant. The bundle re-bundles Vue inside
|
||||
* itself (separate module scope from the host extension's Vue) so
|
||||
* there's no globalThis.Vue shim or import map ceremony.
|
||||
*
|
||||
* If the import fails (offline, CORS, host down), callers get a
|
||||
* typed `EmbedLoadError` they can render as an offline-screen.
|
||||
*/
|
||||
|
||||
import { WPC_EMBED_URL } from "../config/links";
|
||||
|
||||
export class EmbedLoadError extends Error {
|
||||
constructor(
|
||||
public readonly cause: unknown,
|
||||
public readonly url: string,
|
||||
) {
|
||||
const detail =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: typeof cause === "string"
|
||||
? cause
|
||||
: "unknown error";
|
||||
super(`Failed to load community embed from ${url} — ${detail}`);
|
||||
this.name = "EmbedLoadError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface EmbedNavigateTarget {
|
||||
view: "browse" | "detail" | "profile" | "auth" | "bundles" | "library" | "publish";
|
||||
slug?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface EmbedMountOptions {
|
||||
containerEl: HTMLElement;
|
||||
view: "browse" | "detail" | "profile" | "auth" | "bundles" | "library" | "publish";
|
||||
initialSlug?: string;
|
||||
initialUsername?: string;
|
||||
apiBaseUrl: string;
|
||||
tokenStore: {
|
||||
getAccessToken(): Promise<string | null>;
|
||||
getRefreshToken(): Promise<string | null>;
|
||||
setTokens(pair: { access: string; refresh: string; expiresAt: number }): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
};
|
||||
theme?: "auto" | "dark" | "light";
|
||||
onNavigate?: (target: EmbedNavigateTarget) => void;
|
||||
onClose?: () => void;
|
||||
onUnauthenticated?: () => void;
|
||||
}
|
||||
|
||||
export interface EmbedHandle {
|
||||
unmount(): void;
|
||||
navigate(target: EmbedNavigateTarget): void;
|
||||
}
|
||||
|
||||
interface EmbedModule {
|
||||
mount: (opts: EmbedMountOptions) => EmbedHandle;
|
||||
}
|
||||
|
||||
let cached: Promise<EmbedModule> | null = null;
|
||||
|
||||
/**
|
||||
* Load (or return the cached) embed module. Throws `EmbedLoadError`
|
||||
* on import failure so the caller can offer a retry.
|
||||
*/
|
||||
export async function loadEmbed(): Promise<EmbedModule> {
|
||||
if (cached === null) {
|
||||
cached = (async () => {
|
||||
try {
|
||||
// The /* @vite-ignore */ comment tells Vite not to try to
|
||||
// bundle the dynamic specifier — it's a runtime URL pulled
|
||||
// from config, not a known import resolvable at build time.
|
||||
const mod = (await import(/* @vite-ignore */ WPC_EMBED_URL)) as EmbedModule;
|
||||
if (typeof mod.mount !== "function") {
|
||||
throw new Error("embed module is missing mount()");
|
||||
}
|
||||
return mod;
|
||||
} catch (err) {
|
||||
// Clear the cache so a retry actually retries instead of
|
||||
// returning the rejected promise forever.
|
||||
cached = null;
|
||||
throw new EmbedLoadError(err, WPC_EMBED_URL);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Minimal localStorage-backed TokenStore for the embedded community.
|
||||
*
|
||||
* The Community web exposes its REST under bearer-token auth — the
|
||||
* embed reads tokens through this contract:
|
||||
*
|
||||
* interface TokenStore {
|
||||
* getAccessToken(): Promise<string | null>;
|
||||
* getRefreshToken(): Promise<string | null>;
|
||||
* setTokens(pair: { access, refresh, expiresAt }): Promise<void>;
|
||||
* clear(): Promise<void>;
|
||||
* }
|
||||
*
|
||||
* Today this is read-mostly: tokens land via the OAuth2 device flow
|
||||
* (host runs it; sets via `setTokens`). When the embed encounters a
|
||||
* 401 it fires `onUnauthenticated` — the host then re-runs device
|
||||
* flow and re-mounts. The store also handles proactive refresh: if
|
||||
* `now() > expiresAt - REFRESH_SKEW_MS`, `getAccessToken()` hits
|
||||
* `/api/v1/auth/refresh` once and overwrites itself before returning
|
||||
* the new access token.
|
||||
*
|
||||
* The device flow is not yet wired into the extension shell — until
|
||||
* it is, this store reports null and the embed renders anonymously
|
||||
* (public posts only). That's the right fallback for the v1 ship:
|
||||
* users get browse + detail + downloads with no auth ceremony.
|
||||
*/
|
||||
|
||||
const ACCESS_KEY = "wpc.embed.access";
|
||||
const REFRESH_KEY = "wpc.embed.refresh";
|
||||
const EXPIRES_KEY = "wpc.embed.expires_at";
|
||||
|
||||
const REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
interface PersistedTokens {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface WpcTokenStore {
|
||||
getAccessToken(): Promise<string | null>;
|
||||
getRefreshToken(): Promise<string | null>;
|
||||
setTokens(pair: PersistedTokens): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
function readPair(): PersistedTokens | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
const access = localStorage.getItem(ACCESS_KEY);
|
||||
const refresh = localStorage.getItem(REFRESH_KEY);
|
||||
const expiresRaw = localStorage.getItem(EXPIRES_KEY);
|
||||
if (!access || !refresh || !expiresRaw) return null;
|
||||
const expiresAt = Number(expiresRaw);
|
||||
if (!Number.isFinite(expiresAt)) return null;
|
||||
return { access, refresh, expiresAt };
|
||||
}
|
||||
|
||||
function writePair(pair: PersistedTokens): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(ACCESS_KEY, pair.access);
|
||||
localStorage.setItem(REFRESH_KEY, pair.refresh);
|
||||
localStorage.setItem(EXPIRES_KEY, String(pair.expiresAt));
|
||||
}
|
||||
|
||||
function wipe(): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(EXPIRES_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a TokenStore tied to a given API base. The base is needed for
|
||||
* the refresh-token round-trip; callers should pass the same
|
||||
* `WPC_API_URL` they hand to `mount({apiBaseUrl})`.
|
||||
*/
|
||||
export function createWpcTokenStore(apiBaseUrl: string): WpcTokenStore {
|
||||
const base = apiBaseUrl.replace(/\/$/, "");
|
||||
let refreshInFlight: Promise<PersistedTokens | null> | null = null;
|
||||
|
||||
async function refreshNow(): Promise<PersistedTokens | null> {
|
||||
const current = readPair();
|
||||
if (!current) return null;
|
||||
try {
|
||||
// Backend route is /auth/token/refresh per
|
||||
// api/app/routers/auth.py:425 — was hitting /auth/refresh which
|
||||
// 404'd silently and forced a re-login every access-token TTL.
|
||||
// Body field MUST be `refresh_token` — backend's RefreshRequest
|
||||
// uses Pydantic `extra="forbid"`, so a stray `refresh` key gets
|
||||
// rejected with 422 (not 401), the wipe path never fires, and
|
||||
// the embed silently loses auth on the next access-token expiry.
|
||||
const res = await fetch(`${base}/api/v1/auth/token/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: current.refresh }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) wipe();
|
||||
return null;
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
const pair: PersistedTokens = {
|
||||
access: data.access_token,
|
||||
refresh: data.refresh_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
writePair(pair);
|
||||
return pair;
|
||||
} catch {
|
||||
// Network blip — leave tokens as-is so we can try again next
|
||||
// call. Don't wipe.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
const current = readPair();
|
||||
if (!current) return null;
|
||||
if (Date.now() < current.expiresAt - REFRESH_SKEW_MS) {
|
||||
return current.access;
|
||||
}
|
||||
// Coalesce concurrent refresh calls — multiple embed mounts on
|
||||
// the same page would otherwise burn the same refresh token in
|
||||
// parallel and one would win, invalidating the others.
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = refreshNow().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
const refreshed = await refreshInFlight;
|
||||
return refreshed?.access ?? null;
|
||||
},
|
||||
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
return readPair()?.refresh ?? null;
|
||||
},
|
||||
|
||||
async setTokens(pair: PersistedTokens): Promise<void> {
|
||||
writePair(pair);
|
||||
},
|
||||
|
||||
async clear(): Promise<void> {
|
||||
wipe();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Two-button row action group: Upload-to-community + Copy-payload.
|
||||
*
|
||||
* Drops into every list view's #actions slot + every editor's footer
|
||||
* toolbar so the community publish path isn't hidden inside the
|
||||
* Import/Export tab. Same engine-row shape as the Export tab's
|
||||
* "Publish to community" button — both code paths go through
|
||||
* `single-row-publish` helpers.
|
||||
*
|
||||
* Renders as bare icon buttons styled to match the existing
|
||||
* actions-column convention (the trash icon at the row's right
|
||||
* edge). Sized small so they don't dominate the row.
|
||||
*/
|
||||
import { useRouter } from "vue-router";
|
||||
import type { BundleRow, ModuleRow } from "../api/types";
|
||||
import Button from "./ui/Button.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import {
|
||||
buildBundlePublishable,
|
||||
buildModulePublishable,
|
||||
copyPayloadToClipboard,
|
||||
publishToCommunity,
|
||||
} from "../import-export/single-row-publish";
|
||||
|
||||
interface Props {
|
||||
/** Library row to act on. Module rows carry `type`; bundle rows carry `children`. */
|
||||
row: ModuleRow | BundleRow;
|
||||
/** Discriminator for the shape — module vs bundle. */
|
||||
kind: "module" | "bundle";
|
||||
/**
|
||||
* When true, renders Upload+Copy as full pill buttons (label+icon).
|
||||
* Default is icon-only, which is what the per-row actions column
|
||||
* wants. Editor footers pass `true` so the action is more
|
||||
* discoverable next to the Delete pill.
|
||||
*/
|
||||
labeled?: boolean;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
function publishablePayload() {
|
||||
if (props.kind === "bundle") {
|
||||
return buildBundlePublishable(props.row as BundleRow);
|
||||
}
|
||||
return buildModulePublishable(props.row as ModuleRow);
|
||||
}
|
||||
|
||||
function onPublish() {
|
||||
publishToCommunity(publishablePayload(), router);
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
const ok = await copyPayloadToClipboard(publishablePayload());
|
||||
if (ok) {
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: "Payload copied",
|
||||
detail: "Engine-row JSON is on your clipboard.",
|
||||
life: 2500,
|
||||
});
|
||||
} else {
|
||||
toast.push({
|
||||
severity: "error",
|
||||
summary: "Copy failed",
|
||||
detail: "Clipboard access was denied by the browser.",
|
||||
life: 3500,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Labeled mode wraps the two pills in a single flex container so
|
||||
hosts that flex-distribute their children (editor header-extra
|
||||
uses justify-content: space-between) keep the two buttons
|
||||
grouped instead of splitting Publish to the middle and Copy
|
||||
to the far right. Icon-only mode stays as a fragment because
|
||||
row-action cells expect each button as a sibling flex item. -->
|
||||
<div v-if="labeled" class="wpc-row-actions-labeled">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="pi-share-alt"
|
||||
:aria-label="`Publish ${row.name} to community`"
|
||||
:title="`Publish ${row.name} to community`"
|
||||
@click="onPublish"
|
||||
>Publish</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="pi-copy"
|
||||
:aria-label="`Copy ${row.name} payload`"
|
||||
:title="`Copy ${row.name} payload to clipboard`"
|
||||
@click="onCopy"
|
||||
>Copy</Button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="pi-share-alt"
|
||||
:aria-label="`Publish ${row.name} to community`"
|
||||
:title="`Publish ${row.name} to community`"
|
||||
@click="onPublish"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="pi-copy"
|
||||
:aria-label="`Copy ${row.name} payload`"
|
||||
:title="`Copy ${row.name} payload to clipboard`"
|
||||
@click="onCopy"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wpc-row-actions-labeled {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,427 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Community-update action dialog.
|
||||
*
|
||||
* Opens when the user clicks the "v{N} available" pill on a row whose
|
||||
* `community_post_slug` is set and whose installed `community_version_number`
|
||||
* is below the post's `latest_version_number`. Offers three choices:
|
||||
*
|
||||
* - **Update in place** — fetch the post's latest version payload,
|
||||
* prepend a history snapshot of the current row (using the existing
|
||||
* per-module `payload.history` sidecar so the user can roll back from
|
||||
* the editor's History panel), and PATCH the row via `moduleStore.update`
|
||||
* / `bundleStore.update`. Same code path the editor uses on save.
|
||||
*
|
||||
* - **Install as new entry** — invoke the host-bridge install with the
|
||||
* post envelope wrapped via the rename decision baked in. Leaves the
|
||||
* original row untouched; the user ends up with two entries (old and
|
||||
* new) side-by-side.
|
||||
*
|
||||
* - **Dismiss** — remember in localStorage that the user explicitly
|
||||
* declined this version. The update pill stops surfacing until the
|
||||
* post ships an even newer version (we key the dismissal on the post
|
||||
* slug + version we offered, not just the slug).
|
||||
*
|
||||
* Update-in-place is module-only for now. Bundles have no per-row
|
||||
* history sidecar to preserve; we surface "install as new" as the
|
||||
* canonical bundle upgrade path.
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import Modal from "./ui/Modal.vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useBundleStore } from "../stores/bundleStore";
|
||||
import { useCommunityUpdateStore } from "../stores/communityUpdateStore";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { WPC_API_URL } from "../config/links";
|
||||
import { appendSnapshot, stripHistory } from "../utils/history";
|
||||
import { installEnvelope } from "../import-export/install";
|
||||
import { api } from "../api/client";
|
||||
import { wrapAsEngineExport } from "./engine-export-wrap";
|
||||
|
||||
interface UpdateEntry {
|
||||
entity_id: string;
|
||||
entity_kind: "module" | "bundle";
|
||||
post_slug: string;
|
||||
installed_version: number;
|
||||
latest_version: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
entry: UpdateEntry | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
const moduleStore = useModuleStore();
|
||||
const bundleStore = useBundleStore();
|
||||
const updateStore = useCommunityUpdateStore();
|
||||
const toast = useToast();
|
||||
|
||||
const acting = ref<null | "update" | "install-new" | "dismiss">(null);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
const isModule = computed(() => props.entry?.entity_kind === "module");
|
||||
const oldName = computed(() => {
|
||||
if (!props.entry) return "";
|
||||
if (props.entry.entity_kind === "module") {
|
||||
return moduleStore.catalog.find((m) => m.id === props.entry!.entity_id)?.name ?? props.entry.entity_id;
|
||||
}
|
||||
return bundleStore.catalog.find((b) => b.id === props.entry!.entity_id)?.name ?? props.entry.entity_id;
|
||||
});
|
||||
|
||||
/**
|
||||
* GET the community post's latest version payload. Reuses the public
|
||||
* download endpoint the embed already polls; no auth needed because
|
||||
* downloads are open for everyone (anon downloads still increment the
|
||||
* post counter, which is the desired behaviour anyway).
|
||||
*/
|
||||
async function fetchLatestPayload(slug: string): Promise<{ payload: Record<string, unknown>; version: number }> {
|
||||
const resp = await fetch(`${WPC_API_URL}/api/v1/posts/${slug}/download`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch latest version (HTTP ${resp.status})`);
|
||||
}
|
||||
const body = await resp.json() as { data: { payload_json: Record<string, unknown>; version_number: number } };
|
||||
return { payload: body.data.payload_json, version: body.data.version_number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current library into the LibrarySnapshot shape
|
||||
* installEnvelope expects. Same logic as main.ts's `snapshotLibrary`
|
||||
* — duplicated here because main.ts mounts at app bootstrap before
|
||||
* Pinia is fully resolved for component-level callers; pulling stores
|
||||
* inside this function keeps the snapshot accurate at action-fire
|
||||
* time (a long-lived list view picks up library mutations from other
|
||||
* tabs / pages between mount and update click).
|
||||
*/
|
||||
function snapshotLibrary() {
|
||||
const modules = new Map<string, { id: string; name: string }>();
|
||||
for (const m of moduleStore.catalog) modules.set(m.id, { id: m.id, name: m.name });
|
||||
const bundles = new Map<string, { id: string; name: string }>();
|
||||
for (const b of bundleStore.catalog) bundles.set(b.id, { id: b.id, name: b.name });
|
||||
return { modules, bundles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update in place: route the new community payload through the
|
||||
* install pipeline with a forced `replace` decision. For modules we
|
||||
* pre-inject a history snapshot of the local row's pre-update state
|
||||
* into the new payload's `history` sidecar so the editor's History
|
||||
* panel can roll back. Bundles have no history sidecar; the replace
|
||||
* path just swaps `children` verbatim.
|
||||
*
|
||||
* Origin is stamped via `opts.origin`, so the engine's `_update_module`
|
||||
* / `_update_bundle` paths rewrite `community_version_number` to the
|
||||
* fresh value and the update pill clears on the next check.
|
||||
*/
|
||||
async function onUpdateInPlace() {
|
||||
if (!props.entry || acting.value) return;
|
||||
acting.value = "update";
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const fetched = await fetchLatestPayload(props.entry.post_slug);
|
||||
let entityPayload = fetched.payload;
|
||||
if (props.entry.entity_kind === "module") {
|
||||
const oldRow = moduleStore.catalog.find((m) => m.id === props.entry!.entity_id);
|
||||
if (!oldRow) throw new Error("Local module row not found — refresh the library + try again.");
|
||||
// The community ships engine-row shape `{id, type, name, ..., payload:{…}}`.
|
||||
// History goes inside the INNER payload (next to `options`/etc),
|
||||
// not on the row's top-level. Mutate a copy so we don't disturb
|
||||
// the fetched object.
|
||||
const newRow = { ...(entityPayload as Record<string, unknown>) };
|
||||
const innerNew = { ...(newRow.payload as Record<string, unknown> ?? {}) };
|
||||
const history = appendSnapshot(
|
||||
{
|
||||
name: oldRow.name,
|
||||
description: oldRow.description,
|
||||
category_id: oldRow.category_id,
|
||||
tags: oldRow.tags,
|
||||
},
|
||||
oldRow.payload,
|
||||
);
|
||||
innerNew.history = history;
|
||||
newRow.payload = stripHistory(innerNew);
|
||||
// Re-stripHistory + re-attach so a pre-existing `history` field
|
||||
// on the inner payload doesn't double-up. The append helper
|
||||
// already strips on input but this is defensive.
|
||||
(newRow.payload as Record<string, unknown>).history = history;
|
||||
// Force the row's id to the local id so the replace decision
|
||||
// routes to the right DB row even if the community-side post
|
||||
// payload carries a different id field.
|
||||
newRow.id = props.entry.entity_id;
|
||||
entityPayload = newRow;
|
||||
} else {
|
||||
// Bundle: just pin the local id on the new payload so replace
|
||||
// hits the right row.
|
||||
const newRow = { ...(entityPayload as Record<string, unknown>) };
|
||||
newRow.id = props.entry.entity_id;
|
||||
entityPayload = newRow;
|
||||
}
|
||||
const subtype = (entityPayload as { type?: string }).type ?? null;
|
||||
const envelope = wrapAsEngineExport({
|
||||
kind: props.entry.entity_kind,
|
||||
subtype,
|
||||
payload: entityPayload,
|
||||
});
|
||||
const localEntityId = props.entry.entity_id;
|
||||
const result = await installEnvelope(
|
||||
{ envelope },
|
||||
{
|
||||
importExport: api.importExport,
|
||||
library: snapshotLibrary(),
|
||||
origin: { post_slug: props.entry.post_slug, version_number: fetched.version },
|
||||
resolveCollisions: async (rows) => {
|
||||
const out: Record<string, { kind: "replace" }> = {};
|
||||
for (const r of rows) {
|
||||
// Only force replace on the row the dialog is targeting;
|
||||
// any incidental collision on a different id falls back
|
||||
// to the install pipeline's default (treats as add and
|
||||
// the server rejects, which is the conservative path).
|
||||
if (r.id === localEntityId) out[r.id] = { kind: "replace" };
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error?.message ?? "Update failed");
|
||||
}
|
||||
await moduleStore.fetchCatalog();
|
||||
await bundleStore.fetchCatalog();
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `Updated to v${fetched.version}`,
|
||||
detail: props.entry.entity_kind === "module"
|
||||
? "Previous state saved in History panel."
|
||||
: undefined,
|
||||
life: 4500,
|
||||
});
|
||||
void updateStore.check({
|
||||
modules: moduleStore.catalog,
|
||||
bundles: bundleStore.catalog,
|
||||
});
|
||||
emit("close");
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
acting.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install as new entry: route through installEnvelope with a rename
|
||||
* decision baked in. The new row gets a fresh id, the user-supplied
|
||||
* suffix appended to the name, and the same community_post_slug —
|
||||
* tracking now points to the new row (and the old row keeps its
|
||||
* pre-update tracking, so the update pill keeps surfacing on the OLD
|
||||
* row until the user explicitly updates or dismisses it).
|
||||
*/
|
||||
async function onInstallAsNew() {
|
||||
if (!props.entry || acting.value) return;
|
||||
acting.value = "install-new";
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const fetched = await fetchLatestPayload(props.entry.post_slug);
|
||||
// Wrap in the engine-export envelope shape installEnvelope expects.
|
||||
// We hand it through as a fresh `add` and let the host-bridge's
|
||||
// own collision detector classify the id collision. The user
|
||||
// already picked "install as new" in this dialog; we forward that
|
||||
// intent through resolveCollisions by always returning a rename
|
||||
// decision (no user prompt — they made the choice up-front).
|
||||
const kindGuess = props.entry.entity_kind === "bundle" ? "bundle" : "module";
|
||||
const subtype = (fetched.payload as { type?: string }).type ?? null;
|
||||
const envelope = wrapAsEngineExport({
|
||||
kind: kindGuess,
|
||||
subtype: subtype as string | null,
|
||||
payload: fetched.payload,
|
||||
});
|
||||
const result = await installEnvelope(
|
||||
{ envelope },
|
||||
{
|
||||
importExport: api.importExport,
|
||||
library: snapshotLibrary(),
|
||||
origin: { post_slug: props.entry.post_slug, version_number: fetched.version },
|
||||
resolveCollisions: async (rows) => {
|
||||
const out: Record<string, { kind: "rename"; new_name: string }> = {};
|
||||
for (const r of rows) {
|
||||
out[r.id] = {
|
||||
kind: "rename",
|
||||
new_name: `${r.incomingName}-v${fetched.version}`,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error?.message ?? "Install failed");
|
||||
}
|
||||
await moduleStore.fetchCatalog();
|
||||
await bundleStore.fetchCatalog();
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `v${fetched.version} installed as a new entry`,
|
||||
life: 4500,
|
||||
});
|
||||
void updateStore.check({
|
||||
modules: moduleStore.catalog,
|
||||
bundles: bundleStore.catalog,
|
||||
});
|
||||
emit("close");
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
acting.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss: persist `{slug → version}` in localStorage so the update
|
||||
* pill stays hidden for this exact version. Future versions of the
|
||||
* same post bypass the dismissal (we compare against `version_number`,
|
||||
* not just the slug).
|
||||
*/
|
||||
const DISMISS_KEY = "wpc.community-update-dismissed";
|
||||
function onDismiss() {
|
||||
if (!props.entry) return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DISMISS_KEY);
|
||||
const map: Record<string, number> = raw ? JSON.parse(raw) : {};
|
||||
map[props.entry.post_slug] = props.entry.latest_version;
|
||||
window.localStorage.setItem(DISMISS_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
// localStorage can be denied in some sandbox contexts — fail
|
||||
// silently; user can dismiss again next render.
|
||||
}
|
||||
// Update-store also reads the dismissal map on its next check, so
|
||||
// re-check now to clear the pill immediately.
|
||||
void updateStore.check({
|
||||
modules: moduleStore.catalog,
|
||||
bundles: bundleStore.catalog,
|
||||
});
|
||||
emit("close");
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" @update:open="(v: boolean) => !v && $emit('close')" :close-on-backdrop="!acting" size="md">
|
||||
<template v-if="entry">
|
||||
<header style="padding: 18px 22px; border-bottom: 1px solid var(--wp-border)">
|
||||
<h2 style="margin: 0; font-size: 16px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px">
|
||||
<i class="pi pi-arrow-up" style="color: var(--wp-warn, #f59e0b)" />
|
||||
Update available
|
||||
</h2>
|
||||
<p style="margin: 6px 0 0; color: var(--wp-text-muted); font-size: 12.5px">
|
||||
<code>{{ entry.post_slug }}</code>
|
||||
shipped <b>v{{ entry.latest_version }}</b> — you have v{{ entry.installed_version }} of
|
||||
<b>"{{ oldName }}"</b>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div style="padding: 16px 22px; display: flex; flex-direction: column; gap: 14px">
|
||||
<div class="wpc-update-action" :data-disabled="!isModule || null">
|
||||
<h3>Update in place</h3>
|
||||
<p>
|
||||
Overwrite this row with the new payload. Your previous state goes
|
||||
into the editor's History panel so you can roll back from there.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="pi-refresh"
|
||||
:disabled="!isModule || acting !== null"
|
||||
:loading="acting === 'update'"
|
||||
@click="onUpdateInPlace"
|
||||
>Update in place</Button>
|
||||
<p v-if="!isModule" class="wpc-update-hint">
|
||||
Bundles have no history sidecar — use "Install as new" instead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wpc-update-action">
|
||||
<h3>Install as new entry</h3>
|
||||
<p>
|
||||
Adds the new version alongside the existing row with a
|
||||
<code>-v{{ entry.latest_version }}</code> suffix. The original
|
||||
row stays as-is.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="pi-plus"
|
||||
:disabled="acting !== null"
|
||||
:loading="acting === 'install-new'"
|
||||
@click="onInstallAsNew"
|
||||
>Install as new</Button>
|
||||
</div>
|
||||
|
||||
<div class="wpc-update-action">
|
||||
<h3>Dismiss this version</h3>
|
||||
<p>
|
||||
Hide the update pill until <code>{{ entry.post_slug }}</code>
|
||||
ships a newer version. Older offers stay dismissed.
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="pi-times"
|
||||
:disabled="acting !== null"
|
||||
@click="onDismiss"
|
||||
>Dismiss</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="wpc-update-error">
|
||||
<i class="pi pi-exclamation-circle" />{{ errorMsg }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wpc-update-action {
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: var(--wp-bg-2);
|
||||
}
|
||||
.wpc-update-action[data-disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wpc-update-action h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wpc-update-action p {
|
||||
margin: 0 0 10px;
|
||||
color: var(--wp-text-muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wpc-update-action code {
|
||||
font-family: var(--wp-font-mono);
|
||||
font-size: 11.5px;
|
||||
background: var(--wp-bg-3);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wpc-update-hint {
|
||||
margin-top: 8px !important;
|
||||
font-size: 11.5px !important;
|
||||
font-style: italic;
|
||||
}
|
||||
.wpc-update-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, var(--wp-danger, #ef4444) 14%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 36%, transparent);
|
||||
color: var(--wp-danger-text, #ef4444);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.wpc-update-error i { font-size: 14px; flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* App-level error boundary.
|
||||
*
|
||||
* Wraps the active RouterView so a render-time throw in any view
|
||||
* (computed crash, missing prop, etc.) lands here instead of taking
|
||||
* down the whole manager shell. The captured error stays contained
|
||||
* — sidebar + topbar remain interactive so the user can navigate
|
||||
* away. Route changes auto-reset the boundary; a one-off crash on
|
||||
* /community doesn't strand the user on "Something broke" when they
|
||||
* click Dashboard.
|
||||
*/
|
||||
import { onErrorCaptured, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const error = ref<Error | null>(null);
|
||||
const showStack = ref(false);
|
||||
const route = useRoute();
|
||||
|
||||
onErrorCaptured((err) => {
|
||||
error.value = err instanceof Error ? err : new Error(String(err));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[ErrorBoundary]", err);
|
||||
return false;
|
||||
});
|
||||
|
||||
function reset() {
|
||||
error.value = null;
|
||||
showStack.value = false;
|
||||
}
|
||||
|
||||
function reload() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
error.value = null;
|
||||
showStack.value = false;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="error" class="wp-error">
|
||||
<div class="wp-error__card">
|
||||
<div class="wp-error__icon">
|
||||
<i class="pi pi-exclamation-triangle" />
|
||||
</div>
|
||||
<h1 class="wp-error__title">Something broke</h1>
|
||||
<p class="wp-error__copy">
|
||||
This view hit an unexpected error while rendering. Try
|
||||
reloading, or head to a different tab.
|
||||
</p>
|
||||
<div class="wp-error__detail">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-btn wp-btn--ghost wp-btn--sm"
|
||||
@click="showStack = !showStack"
|
||||
>
|
||||
<i :class="`pi ${showStack ? 'pi-chevron-up' : 'pi-chevron-down'}`" />
|
||||
{{ showStack ? "Hide details" : "Show details" }}
|
||||
</button>
|
||||
<pre v-if="showStack" class="wp-error__stack">{{ error.message }}
|
||||
{{ error.stack }}</pre>
|
||||
</div>
|
||||
<div class="wp-error__actions">
|
||||
<button type="button" class="wp-btn wp-btn--primary" @click="reload">
|
||||
<i class="pi pi-refresh" />Reload page
|
||||
</button>
|
||||
<button type="button" class="wp-btn wp-btn--ghost" @click="reset">
|
||||
<i class="pi pi-times" />Dismiss
|
||||
</button>
|
||||
</div>
|
||||
<p class="wp-error__path">
|
||||
Path: <code>{{ route.fullPath }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-error {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.wp-error__card {
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 44px 32px;
|
||||
background: var(--wp-bg-2);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 38%, var(--wp-border));
|
||||
border-radius: 12px;
|
||||
}
|
||||
.wp-error__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in oklab, var(--wp-danger, #ef4444) 22%, transparent);
|
||||
color: var(--wp-danger, #ef4444);
|
||||
font-size: 22px;
|
||||
}
|
||||
.wp-error__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-error__copy {
|
||||
color: var(--wp-text-muted);
|
||||
font-size: 13.5px;
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wp-error__detail {
|
||||
margin: 10px 0 18px;
|
||||
}
|
||||
.wp-error__stack {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--wp-bg-3);
|
||||
border-radius: 6px;
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--wp-text-muted);
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.wp-error__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wp-error__actions .wp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.wp-error__path {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.wp-error__path code {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
background: var(--wp-bg-3);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,46 @@ import Modal from "./ui/Modal.vue";
|
||||
import Select from "./ui/Select.vue";
|
||||
import RelativeDate from "./RelativeDate.vue";
|
||||
import EmptyState from "./ui/EmptyState.vue";
|
||||
import { useCommunityUpdateStore } from "../stores/communityUpdateStore";
|
||||
import type { UpdateEntry } from "../stores/communityUpdateStore";
|
||||
import CommunityUpdateDialog from "./CommunityUpdateDialog.vue";
|
||||
|
||||
const communityUpdates = useCommunityUpdateStore();
|
||||
const updateDialogEntry = ref<UpdateEntry | null>(null);
|
||||
function openUpdateDialog(rowId: string) {
|
||||
const e = communityUpdates.entryFor(rowId);
|
||||
if (e) updateDialogEntry.value = e;
|
||||
}
|
||||
function closeUpdateDialog() {
|
||||
updateDialogEntry.value = null;
|
||||
}
|
||||
|
||||
/** Title for the update-available pill. Built as a function instead
|
||||
* of inline template-string so vue-tsc's template parser doesn't
|
||||
* choke on nested optional chaining inside attribute interpolation. */
|
||||
function updatePillTitle(rowId: string): string {
|
||||
const e = communityUpdates.entryFor(rowId);
|
||||
if (!e) return "";
|
||||
return `Update available - v${e.latest_version} (${e.post_slug})`;
|
||||
}
|
||||
function updatePillLatest(rowId: string): number | string {
|
||||
return communityUpdates.entryFor(rowId)?.latest_version ?? "?";
|
||||
}
|
||||
|
||||
interface CommunityOrigin {
|
||||
community_post_slug?: string | null;
|
||||
community_version_number?: number | null;
|
||||
}
|
||||
function communityOriginSlug(row: T): string | null {
|
||||
const r = row as T & CommunityOrigin;
|
||||
return r.community_post_slug ?? null;
|
||||
}
|
||||
function communityPillTitle(row: T): string {
|
||||
const r = row as T & CommunityOrigin;
|
||||
const slug = r.community_post_slug ?? "";
|
||||
const ver = r.community_version_number ?? "?";
|
||||
return `Installed from community - ${slug} v${ver}`;
|
||||
}
|
||||
|
||||
interface Filter {
|
||||
q?: string;
|
||||
@@ -914,12 +954,43 @@ defineExpose({
|
||||
<slot name="favorite" :row="row" />
|
||||
</td>
|
||||
<td>
|
||||
<slot name="name" :row="row">
|
||||
<div class="wp-row-name">
|
||||
<span class="wp-row-name__text">{{ row.name }}</span>
|
||||
<span class="wp-id">{{ row.id }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
<!-- Slot + pills wrapped in one flex line. Every list view
|
||||
overrides #name, so pills rendered ONLY inside the
|
||||
slot fallback would never appear. Render them
|
||||
alongside the slot output so they stay visible
|
||||
regardless of the override's markup. -->
|
||||
<div class="wp-row-name-wrap">
|
||||
<slot name="name" :row="row">
|
||||
<div class="wp-row-name">
|
||||
<span class="wp-row-name__text">{{ row.name }}</span>
|
||||
<span class="wp-id">{{ row.id }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
<!-- Community-origin pill. Only renders when the
|
||||
engine stamped `community_post_slug` on this
|
||||
row at install time (migration 013). Locally
|
||||
authored rows have NULL here and stay
|
||||
unmarked. -->
|
||||
<span
|
||||
v-if="communityOriginSlug(row)"
|
||||
class="wp-row-community-pill"
|
||||
:title="communityPillTitle(row)"
|
||||
>
|
||||
<i class="pi pi-globe" />community
|
||||
</span>
|
||||
<!-- Update-available indicator. Distinct pill so the
|
||||
user can tell which rows are behind, not just
|
||||
which rows came from community. -->
|
||||
<button
|
||||
v-if="communityUpdates.entryFor(row.id)"
|
||||
type="button"
|
||||
class="wp-row-community-pill wp-row-community-pill--update wp-row-community-pill--button"
|
||||
:title="updatePillTitle(row.id)"
|
||||
@click.stop="openUpdateDialog(row.id)"
|
||||
>
|
||||
<i class="pi pi-arrow-up" />v{{ updatePillLatest(row.id) }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<slot name="columns" :row="row" />
|
||||
<td v-if="showTags" @click.stop>
|
||||
@@ -1040,6 +1111,16 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mounted once at the list level (not once per row) so a single
|
||||
active dialog doesn't get stuck behind a re-render of its
|
||||
host row's chrome. The pill's @click stores the entry; the
|
||||
dialog reads + drives the action. -->
|
||||
<CommunityUpdateDialog
|
||||
:open="updateDialogEntry !== null"
|
||||
:entry="updateDialogEntry"
|
||||
@close="closeUpdateDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1195,6 +1276,17 @@ defineExpose({
|
||||
.wp-row-fav-btn[data-on="true"] { color: var(--wp-warn, #fcd34d); }
|
||||
.wp-row-fav-btn .pi { font-size: var(--wp-text-base); }
|
||||
|
||||
.wp-row-name-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-row-name-wrap > :first-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-row-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1203,6 +1295,39 @@ defineExpose({
|
||||
.wp-row-name__text {
|
||||
font-weight: 500;
|
||||
}
|
||||
.wp-row-community-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
padding: 1px 6px 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--wp-accent) 14%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent) 32%, transparent);
|
||||
color: var(--wp-accent-text);
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wp-row-community-pill .pi {
|
||||
font-size: 9px;
|
||||
}
|
||||
.wp-row-community-pill--update {
|
||||
background: color-mix(in oklab, var(--wp-warn, #f59e0b) 18%, transparent);
|
||||
border-color: color-mix(in oklab, var(--wp-warn, #f59e0b) 42%, transparent);
|
||||
color: var(--wp-warn, #f59e0b);
|
||||
}
|
||||
.wp-row-community-pill--button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.wp-row-community-pill--button:hover {
|
||||
background: color-mix(in oklab, var(--wp-warn, #f59e0b) 28%, transparent);
|
||||
border-color: color-mix(in oklab, var(--wp-warn, #f59e0b) 60%, transparent);
|
||||
}
|
||||
.wp-row-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -7,13 +7,28 @@
|
||||
* Mounted unconditionally in AppLayout. The store flag controls
|
||||
* visibility; transition fades it in/out.
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import Icon from "./ui/Icon.vue";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
import { useStaleStore } from "../stores/staleStore";
|
||||
import { useSystemStore } from "../stores/systemStore";
|
||||
|
||||
const stale = useStaleStore();
|
||||
const system = useSystemStore();
|
||||
|
||||
// Restart confirm prompt — kicking ComfyUI's process is destructive
|
||||
// enough (running workflows + queues get interrupted) that the user
|
||||
// should explicitly confirm rather than fire on a single click.
|
||||
const restartConfirmOpen = ref(false);
|
||||
function askRestart() { restartConfirmOpen.value = true; }
|
||||
function onRestartConfirmed() {
|
||||
restartConfirmOpen.value = false;
|
||||
void system.restart();
|
||||
}
|
||||
function onRestartCancelled() {
|
||||
restartConfirmOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,10 +58,20 @@ const system = useSystemStore();
|
||||
icon="pi-power-off"
|
||||
:loading="system.restarting"
|
||||
data-test="stale-restart"
|
||||
@click="() => system.restart()"
|
||||
@click="askRestart"
|
||||
>Restart ComfyUI</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<ConfirmDialog
|
||||
:visible="restartConfirmOpen"
|
||||
title="Restart ComfyUI?"
|
||||
body="This kicks the running ComfyUI process. Any in-flight workflows and queued prompts will be interrupted. The page will reload automatically once the server is back."
|
||||
confirm-label="Restart"
|
||||
variant="danger"
|
||||
@confirm="onRestartConfirmed"
|
||||
@cancel="onRestartCancelled"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Engine-export envelope wrapper — sister-side mirror of
|
||||
* `web/src/api/engine-export.ts` in the community repo.
|
||||
*
|
||||
* Used by CommunityUpdateDialog's "Install as new entry" path: the
|
||||
* community API ships each post as a single entity payload, but
|
||||
* installEnvelope expects the canonical multi-bucket envelope (the
|
||||
* shape the Export tab produces). We wrap the single payload into
|
||||
* the right bucket before handing it off.
|
||||
*
|
||||
* Why a separate copy instead of importing the community one: this
|
||||
* file ships in the sister extension's manager bundle and runs
|
||||
* without any network reach back to the community web build. Pinning
|
||||
* `schema_version` here means a sister vendored at a different
|
||||
* version still has a known schema target rather than picking up
|
||||
* whatever the live community deploys.
|
||||
*/
|
||||
|
||||
export const ENGINE_SCHEMA_VERSION = 1;
|
||||
|
||||
type Bucket =
|
||||
| "bundles"
|
||||
| "wildcards"
|
||||
| "fixed_values"
|
||||
| "combines"
|
||||
| "derivations"
|
||||
| "constraints";
|
||||
|
||||
export interface EngineExportEnvelope {
|
||||
schema_version: number;
|
||||
exported_at: string;
|
||||
bundles: Array<Record<string, unknown>>;
|
||||
wildcards: Array<Record<string, unknown>>;
|
||||
fixed_values: Array<Record<string, unknown>>;
|
||||
combines: Array<Record<string, unknown>>;
|
||||
derivations: Array<Record<string, unknown>>;
|
||||
constraints: Array<Record<string, unknown>>;
|
||||
categories: Array<Record<string, unknown>>;
|
||||
templates: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function bucketOf(kind: "module" | "bundle", subtype: string | null): Bucket {
|
||||
if (kind === "bundle") return "bundles";
|
||||
switch (subtype) {
|
||||
case "wildcard": return "wildcards";
|
||||
case "fixed_values": return "fixed_values";
|
||||
case "combine": return "combines";
|
||||
case "derivation": return "derivations";
|
||||
case "constraint": return "constraints";
|
||||
default: return "wildcards";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a single payload into a runtime-import-ready envelope.
|
||||
*
|
||||
* IMPORTANT note about `new Date()`: this is called only when the
|
||||
* user explicitly clicks "Install as new entry" — interactive flow,
|
||||
* fine to stamp with wall-clock time. The Workflow runner ban on
|
||||
* `new Date()` (it breaks resume) doesn't apply here.
|
||||
*/
|
||||
export function wrapAsEngineExport(args: {
|
||||
kind: "module" | "bundle";
|
||||
subtype: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
}): EngineExportEnvelope {
|
||||
const envelope: EngineExportEnvelope = {
|
||||
schema_version: ENGINE_SCHEMA_VERSION,
|
||||
exported_at: new Date().toISOString(),
|
||||
bundles: [],
|
||||
wildcards: [],
|
||||
fixed_values: [],
|
||||
combines: [],
|
||||
derivations: [],
|
||||
constraints: [],
|
||||
categories: [],
|
||||
templates: [],
|
||||
};
|
||||
envelope[bucketOf(args.kind, args.subtype)].push(args.payload);
|
||||
return envelope;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import Card from "../ui/Card.vue";
|
||||
import Icon from "../ui/Icon.vue";
|
||||
import LocationSection from "./LocationSection.vue";
|
||||
import MaintenanceOpModal from "./MaintenanceOpModal.vue";
|
||||
import ConfirmDialog from "../../../components/shared/ConfirmDialog.vue";
|
||||
import { useDatabaseStore } from "../../stores/databaseStore";
|
||||
import { useSystemStore } from "../../stores/systemStore";
|
||||
import type { MaintenanceOp, MaintenanceResult } from "../../api/types";
|
||||
@@ -91,6 +92,19 @@ function refresh(): void {
|
||||
void store.fetchInfo();
|
||||
}
|
||||
|
||||
// Restart confirm — kicking ComfyUI interrupts running workflows
|
||||
// and queued prompts, so users should explicitly confirm. Mirrors
|
||||
// the same gate StaleBanner uses for its Restart pill.
|
||||
const restartConfirmOpen = ref(false);
|
||||
function askRestart(): void { restartConfirmOpen.value = true; }
|
||||
function onRestartConfirmed(): void {
|
||||
restartConfirmOpen.value = false;
|
||||
void system.restart();
|
||||
}
|
||||
function onRestartCancelled(): void {
|
||||
restartConfirmOpen.value = false;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
@@ -199,7 +213,7 @@ onMounted(() => {
|
||||
:loading="system.restarting"
|
||||
:disabled="!!store.runningOp"
|
||||
data-test="database-restart"
|
||||
@click="() => system.restart()"
|
||||
@click="askRestart"
|
||||
>Restart</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,6 +233,16 @@ onMounted(() => {
|
||||
@close="closeModal"
|
||||
@update:open="(v) => { if (!v) closeModal(); }"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:visible="restartConfirmOpen"
|
||||
title="Restart ComfyUI?"
|
||||
body="This kicks the running ComfyUI process. Any in-flight workflows and queued prompts will be interrupted. The page will reload automatically once the server is back."
|
||||
confirm-label="Restart"
|
||||
variant="danger"
|
||||
@confirm="onRestartConfirmed"
|
||||
@cancel="onRestartCancelled"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
*
|
||||
* Consumers: `AppSidebar.vue` (Documentation + View Source nav),
|
||||
* `Dashboard.vue` ("Open docs" button), `Settings.vue` (repo link),
|
||||
* `CommunityWip.vue` (Discord CTA). New external links should land
|
||||
* here rather than be hard-coded at the call site.
|
||||
* `Community.vue` (Discord CTA on the offline fallback). New external
|
||||
* links should land here rather than be hard-coded at the call site.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -41,3 +41,27 @@ export function githubBranchUrl(branch: string): string {
|
||||
* the full discover/upload flow is being designed.
|
||||
*/
|
||||
export const DISCORD_INVITE = "https://discord.gg/BFYR9WQdVR";
|
||||
|
||||
/**
|
||||
* Wildcard Pipeline Community API + embed host.
|
||||
*
|
||||
* Both the sister web SPA (full https URL) and the in-extension
|
||||
* embed bundle (`/embed/wpc-embed.js`) live here. The community-tab
|
||||
* integration loads the embed bundle from this origin at runtime;
|
||||
* every downstream call (auth, REST, downloads) hits the same base.
|
||||
*
|
||||
* Set via Vite env (`VITE_WPC_API_URL`) so dev points at
|
||||
* `http://localhost:8765` and the published build hits the canonical
|
||||
* prod host. The fallback is the live host so a missing env at
|
||||
* runtime still surfaces the community rather than 404.
|
||||
*/
|
||||
export const WPC_API_URL: string =
|
||||
(import.meta.env.VITE_WPC_API_URL as string | undefined)?.replace(/\/$/, "") ??
|
||||
"https://wp.dumiflex.dev";
|
||||
|
||||
/** Latest embed bundle (always points at the newest release). */
|
||||
export const WPC_EMBED_URL = `${WPC_API_URL}/embed/wpc-embed.js`;
|
||||
|
||||
/** Embed manifest for version pinning. See
|
||||
* https://github.com/DumiFlex/Wildcard-Pipeline-Community/blob/main/docs/extension-integration.md */
|
||||
export const WPC_EMBED_MANIFEST_URL = `${WPC_API_URL}/api/v1/embed/version.json`;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* keys (`*_uuids`) match the Python endpoint exactly.
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { api, ApiError, type ExportBuildRequest } from "../api/client";
|
||||
@@ -26,8 +27,15 @@ import ExportDepWarningModal from "./ExportDepWarningModal.vue";
|
||||
import type { ExportDepWarningRow } from "./ExportDepWarningModal.vue";
|
||||
import { liveLibraryToRawPayload } from "./live-library-adapter";
|
||||
import { buildDepGraph, transitiveClosure } from "./dep-graph";
|
||||
import {
|
||||
buildBundlePublishable,
|
||||
buildModulePublishable,
|
||||
publishToCommunity as publishToCommunityShared,
|
||||
type PublishablePayload,
|
||||
} from "./single-row-publish";
|
||||
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
/**
|
||||
* Bucket key — matches the 8-bucket schema. `wildcard`, `fixed_values`,
|
||||
@@ -524,6 +532,52 @@ async function onDepWarningExportAnyway(): Promise<void> {
|
||||
await performExport();
|
||||
}
|
||||
|
||||
// ---------- Publish to community ----------
|
||||
//
|
||||
// Single-row deeplink to the community web's /upload?from=spa flow. The
|
||||
// SPA payload is the source of truth — we ship the row's engine-row JSON
|
||||
// verbatim through the URL hash so the community PublishView can prefill
|
||||
// payload + name + description. Hash (not query) avoids both Edge's
|
||||
// ~2KB address-bar limit and any chance of the payload being captured by
|
||||
// the server's request log on the way in.
|
||||
//
|
||||
// Enabled only when EXACTLY ONE module or bundle is selected and nothing
|
||||
// else — single-row publish is the only flow the community currently
|
||||
// supports (each post is one entity).
|
||||
|
||||
/**
|
||||
* Detect whether the current selection is a single publishable entity.
|
||||
* Single publishable = exactly one module OR exactly one bundle, and
|
||||
* every other bucket empty (including categories + templates, which
|
||||
* the community can't host yet). Returns the engine-row payload via
|
||||
* the shared `single-row-publish` helpers so per-row buttons on
|
||||
* ModuleListView / editors emit byte-for-byte the same shape.
|
||||
*/
|
||||
const singleSelected = computed<PublishablePayload | null>(() => {
|
||||
let total = 0;
|
||||
let kind: "module" | "bundle" | null = null;
|
||||
let id: string | null = null;
|
||||
for (const b of BUCKETS) {
|
||||
const sz = selection.value[b.key].size;
|
||||
total += sz;
|
||||
if (sz === 1) {
|
||||
kind = b.key === "bundle" ? "bundle" : (b.key === "category" || b.key === "template" ? null : "module");
|
||||
id = [...selection.value[b.key]][0] ?? null;
|
||||
}
|
||||
}
|
||||
if (total !== 1 || !kind || !id) return null;
|
||||
if (kind === "bundle") {
|
||||
const row = bundles.value.find((b) => b.id === id);
|
||||
return row ? buildBundlePublishable(row) : null;
|
||||
}
|
||||
const mod = modules.value.find((m) => m.id === id);
|
||||
return mod ? buildModulePublishable(mod) : null;
|
||||
});
|
||||
|
||||
function publishToCommunity() {
|
||||
if (singleSelected.value) publishToCommunityShared(singleSelected.value, router);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
selection.value = {
|
||||
bundle: new Set(),
|
||||
@@ -686,6 +740,15 @@ function presetFavoritesOnly(): void {
|
||||
class="wp-picker-footer__counter"
|
||||
data-test="export-tab-counter"
|
||||
><strong>{{ totalSelected }}</strong> of {{ totalRowsCount }} selected</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="pi-share-alt"
|
||||
:disabled="!singleSelected"
|
||||
:title="singleSelected ? 'Open the community Publish form pre-filled with this entity' : 'Select exactly one module or bundle to publish'"
|
||||
data-test="export-tab-publish-community"
|
||||
@click="publishToCommunity"
|
||||
>Publish to community</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="pi-download"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migratePayload, CURRENT_SCHEMA_VERSION } from "../migrations";
|
||||
import { migrateImportEnvelope, CURRENT_SCHEMA_VERSION } from "../migrations";
|
||||
|
||||
describe("migratePayload", () => {
|
||||
describe("migrateImportEnvelope", () => {
|
||||
it("returns payload as-is if version matches CURRENT_SCHEMA_VERSION", () => {
|
||||
const payload = {
|
||||
schema_version: CURRENT_SCHEMA_VERSION,
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [], derivations: [], constraints: [], categories: [], templates: [],
|
||||
};
|
||||
const result = migratePayload(payload);
|
||||
const result = migrateImportEnvelope(payload);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.migrated).toEqual(payload);
|
||||
@@ -20,7 +20,7 @@ describe("migratePayload", () => {
|
||||
schema_version: CURRENT_SCHEMA_VERSION + 1,
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [], derivations: [], constraints: [], categories: [],
|
||||
};
|
||||
const result = migratePayload(payload);
|
||||
const result = migrateImportEnvelope(payload);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/future schema version/i);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe("migratePayload", () => {
|
||||
const payload = {
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [], derivations: [], constraints: [], categories: [],
|
||||
} as Record<string, unknown>;
|
||||
const result = migratePayload(payload);
|
||||
const result = migrateImportEnvelope(payload);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("migratePayload", () => {
|
||||
bundles: [], wildcards: [{ uuid: "u", name: "old" }],
|
||||
fixed_values: [], combines: [], derivations: [], constraints: [], categories: [],
|
||||
};
|
||||
const result = migratePayload(v0Payload);
|
||||
const result = migrateImportEnvelope(v0Payload);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.migrated.schema_version).toBe(CURRENT_SCHEMA_VERSION);
|
||||
@@ -53,7 +53,7 @@ describe("migratePayload", () => {
|
||||
bundles: [], wildcards: [{ uuid: "u", name: "x" }],
|
||||
fixed_values: [], combines: [], derivations: [], constraints: [], categories: [],
|
||||
};
|
||||
const result = migratePayload(v0Payload);
|
||||
const result = migrateImportEnvelope(v0Payload);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const w = result.migrated.wildcards[0] as Record<string, unknown>;
|
||||
@@ -72,7 +72,7 @@ describe("migratePayload", () => {
|
||||
constraints: [],
|
||||
categories: [{ uuid: "cat1", name: "cat" }],
|
||||
};
|
||||
const result = migratePayload(v0Payload);
|
||||
const result = migrateImportEnvelope(v0Payload);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect((result.migrated.fixed_values[0] as Record<string, unknown>).migrated_from).toBe(0);
|
||||
@@ -87,7 +87,7 @@ describe("migratePayload", () => {
|
||||
schema_version: 0,
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [], derivations: [], constraints: [], categories: [],
|
||||
};
|
||||
const result = migratePayload(v0Empty);
|
||||
const result = migrateImportEnvelope(v0Empty);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.migrated.schema_version).toBe(CURRENT_SCHEMA_VERSION);
|
||||
@@ -106,7 +106,7 @@ describe("migratePayload", () => {
|
||||
constraints: [{ uuid: "c1" }],
|
||||
categories: [{ uuid: "cat1" }],
|
||||
};
|
||||
const result = migratePayload(v0Payload);
|
||||
const result = migrateImportEnvelope(v0Payload);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.migratedEntityCount).toBe(7);
|
||||
@@ -116,7 +116,7 @@ describe("migratePayload", () => {
|
||||
it("defaults missing arrays to empty (partial payload from older format)", () => {
|
||||
// A payload with only schema_version + wildcards should still succeed
|
||||
const partial = { schema_version: CURRENT_SCHEMA_VERSION, wildcards: [{ uuid: "w1" }] } as Record<string, unknown>;
|
||||
const result = migratePayload(partial);
|
||||
const result = migrateImportEnvelope(partial);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.migrated.fixed_values).toEqual([]);
|
||||
@@ -133,7 +133,7 @@ describe("migratePayload", () => {
|
||||
schema_version: CURRENT_SCHEMA_VERSION,
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [], derivations: [], constraints: [], categories: [],
|
||||
} as Record<string, unknown>;
|
||||
const result = migratePayload(noTemplates);
|
||||
const result = migrateImportEnvelope(noTemplates);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.migrated.templates).toEqual([]);
|
||||
@@ -146,7 +146,7 @@ describe("migratePayload", () => {
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [], derivations: [], constraints: [], categories: [],
|
||||
templates: [{ id: "t1", name: "hero" }],
|
||||
};
|
||||
const result = migratePayload(v0Payload);
|
||||
const result = migrateImportEnvelope(v0Payload);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect((result.migrated.templates[0] as Record<string, unknown>).migrated_from).toBe(0);
|
||||
@@ -159,7 +159,7 @@ describe("migratePayload", () => {
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [], derivations: [], constraints: [], categories: [],
|
||||
templates: [{ id: "t1" }, { id: "t2" }],
|
||||
};
|
||||
const result = migratePayload(v0Payload);
|
||||
const result = migrateImportEnvelope(v0Payload);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.migratedEntityCount).toBe(2);
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* One-shot install pipeline for community-side payloads.
|
||||
*
|
||||
* The full Import tab walks: file pick → parsePayload (JSON shape +
|
||||
* migrations + integrity verify) → ImportPicker (per-entity decisions +
|
||||
* collision detection) → buildCommitPayload → POST /import/commit.
|
||||
*
|
||||
* The community embed needs the same guarantees (schema migration,
|
||||
* integrity warnings, atomic write) but skips the picker — the user
|
||||
* already saw the post detail page and clicked Install. Everything in
|
||||
* the envelope goes in as `add`. The server collision detector will
|
||||
* surface duplicates as a 4xx with a structured error; a future
|
||||
* iteration can prompt for replace/rename via the same dialog the
|
||||
* Import tab uses today, but v1 fails loudly so users see the conflict
|
||||
* instead of silently overwriting their own work.
|
||||
*
|
||||
* Exposed globally as `window.__wpcRuntime.install` (see manager
|
||||
* bootstrap) so the community embed bundle — loaded into the same
|
||||
* document via dynamic import — can call into this pipeline directly
|
||||
* without re-implementing migrations or duplicating the commit shape.
|
||||
*/
|
||||
|
||||
import { api } from "../api/client";
|
||||
|
||||
/** Pull the importExport bucket off the live `api` const — there's no
|
||||
* exported ApiClient type, so we widen via `typeof` and pluck the
|
||||
* one bucket we need. Tests inject a fake of this shape. */
|
||||
type ImportExportApi = (typeof api)["importExport"];
|
||||
import { CURRENT_SCHEMA_VERSION, type RawPayload } from "./migrations";
|
||||
import { parsePayload, type IntegrityWarning } from "./parse";
|
||||
import {
|
||||
buildCommitPayload,
|
||||
type CommitOk,
|
||||
type Decision,
|
||||
type EntityKind,
|
||||
type ResolvedCategoryEntity,
|
||||
type ResolvedEntity,
|
||||
type ResolvedSelection,
|
||||
} from "./commit";
|
||||
import { newShortId } from "../utils/ids";
|
||||
import type { SchemaCatalogEntry } from "@/api/types";
|
||||
|
||||
/**
|
||||
* Library snapshot used by the install collision pre-check. Modules
|
||||
* and bundles are looked up by `id`; the lookup payload is small
|
||||
* (just `name`) so the caller can construct the maps cheaply from
|
||||
* Pinia stores. Missing entries are taken as "no collision".
|
||||
*/
|
||||
export interface LibrarySnapshot {
|
||||
modules: Map<string, { id: string; name: string }>;
|
||||
bundles: Map<string, { id: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row in the live library that an incoming entity collides with.
|
||||
* Passed to `resolveCollisions` so the UI can render "X already
|
||||
* exists" with both names side-by-side.
|
||||
*/
|
||||
export interface InstallCollision {
|
||||
kind: EntityKind;
|
||||
id: string;
|
||||
incomingName: string;
|
||||
existingName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-entity decision returned from `resolveCollisions`:
|
||||
* - `skip` drops the entity from the commit entirely.
|
||||
* - `replace` overwrites the existing live-DB row with the
|
||||
* incoming payload.
|
||||
* - `rename` mints a fresh id + user-supplied name; the original
|
||||
* library row is untouched and the incoming entity lands beside it.
|
||||
*/
|
||||
export type CollisionDecision =
|
||||
| { kind: "skip" }
|
||||
| { kind: "replace" }
|
||||
| { kind: "rename"; new_name: string };
|
||||
|
||||
/**
|
||||
* Tags every entity inserted in this install with the community post
|
||||
* + version it originated from. Stamped onto the row at insert time
|
||||
* (engine columns added in migration 013) so the SPA can later show
|
||||
* "installed from @author/pack" badges and surface "v2 available"
|
||||
* notifications when the post's latest_version_number drifts above
|
||||
* the locally-stored version_number.
|
||||
*/
|
||||
export interface InstallOrigin {
|
||||
post_slug: string;
|
||||
version_number: number;
|
||||
}
|
||||
|
||||
export interface InstallOptions {
|
||||
/** Used to POST the assembled commit. Defaults to the shared
|
||||
* manager `api` const; tests inject a fake. */
|
||||
importExport: ImportExportApi;
|
||||
/** Tag every inserted entity with this community origin. Omitted
|
||||
* for non-community installs (Import-tab file pick, starter set,
|
||||
* etc.) so those rows stay NULL on the new columns. */
|
||||
origin?: InstallOrigin;
|
||||
/**
|
||||
* Snapshot of the live library at install time. Provided by the
|
||||
* host bridge so installEnvelope can detect collisions client-side
|
||||
* before the commit roundtrip. Optional for back-compat — when
|
||||
* absent, the function falls through to the original "everything
|
||||
* as add" behaviour and the server surfaces conflicts as 4xx.
|
||||
*/
|
||||
library?: LibrarySnapshot;
|
||||
/**
|
||||
* Resolver callback invoked when client-side collision detection
|
||||
* finds duplicates. Receives one row per conflict; returns a map
|
||||
* keyed by id with the user's per-row decision. Returning `null`
|
||||
* (or an empty map) cancels the install. Required for the conflict
|
||||
* UX to fire; when absent we skip the pre-check entirely and let
|
||||
* the server reject as before.
|
||||
*/
|
||||
resolveCollisions?: (
|
||||
rows: InstallCollision[],
|
||||
) => Promise<Record<string, CollisionDecision> | null>;
|
||||
}
|
||||
|
||||
export interface InstallResult {
|
||||
ok: boolean;
|
||||
/** Per-bucket counts of entities that landed in `adds`. Mirrors the
|
||||
* shape the picker would have produced if the user had clicked
|
||||
* Add on everything. */
|
||||
installed: Record<EntityKind, number>;
|
||||
/** Integrity warnings surfaced by ``parsePayload``. Display these
|
||||
* alongside the success state — they aren't blocking but they
|
||||
* signal authoring drift (missing var refs, orphan branches, etc.). */
|
||||
warnings: IntegrityWarning[];
|
||||
/** Number of entities that got walked through a migration step on
|
||||
* the way to ``CURRENT_SCHEMA_VERSION``. > 0 means the pack was
|
||||
* authored against an older schema and got migrated on parse. */
|
||||
migratedEntityCount: number;
|
||||
/** Set when the server's commit succeeded. Undo + summary live here. */
|
||||
commit?: CommitOk;
|
||||
/** Set when something failed (parse, schema mismatch, server). One
|
||||
* of ``commit`` / ``error`` is always populated. */
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
const EMPTY_COUNTS: Record<EntityKind, number> = {
|
||||
bundle: 0,
|
||||
wildcard: 0,
|
||||
fixed_values: 0,
|
||||
combine: 0,
|
||||
derivation: 0,
|
||||
constraint: 0,
|
||||
category: 0,
|
||||
template: 0,
|
||||
};
|
||||
|
||||
function countBucket(rows: Array<{ kind: EntityKind }>): Record<EntityKind, number> {
|
||||
const out: Record<EntityKind, number> = { ...EMPTY_COUNTS };
|
||||
for (const row of rows) out[row.kind] += 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp every entity in a `RawPayload` bucket with a ``decision: "add"``
|
||||
* wrapper so it can flow through ``buildCommitPayload``.
|
||||
*
|
||||
* Community uploads are engine-row shaped (top-level ``name`` /
|
||||
* ``type``, type-specific fields nested under ``payload``) — the same
|
||||
* format the runtime's exporter produces. The community web side is
|
||||
* the transport layer, not the schema authority; if a payload lands
|
||||
* here without ``name`` it's the engine importer's job to reject,
|
||||
* not ours to silently patch. Lifting ``meta.name`` here used to
|
||||
* mask shape drift, which papered over the seed bug rather than
|
||||
* forcing the seed to mirror reality.
|
||||
*/
|
||||
function wrapAdds(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
): ResolvedEntity[] {
|
||||
return rows
|
||||
.filter((r): r is Record<string, unknown> & { id: string } =>
|
||||
typeof r === "object" && r !== null && typeof (r as { id?: unknown }).id === "string",
|
||||
)
|
||||
.map((entity) => ({ entity, decision: { kind: "add" as const } }));
|
||||
}
|
||||
|
||||
function wrapCategoryAdds(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
): ResolvedCategoryEntity[] {
|
||||
return rows
|
||||
.filter((r): r is Record<string, unknown> & { id: string } =>
|
||||
typeof r === "object" && r !== null && typeof (r as { id?: unknown }).id === "string",
|
||||
)
|
||||
.map((entity) => ({ entity, decision: { kind: "add" as const } }));
|
||||
}
|
||||
|
||||
function buildSelection(payload: RawPayload): ResolvedSelection {
|
||||
return {
|
||||
bundles: wrapAdds(payload.bundles),
|
||||
wildcards: wrapAdds(payload.wildcards),
|
||||
fixed_values: wrapAdds(payload.fixed_values),
|
||||
combines: wrapAdds(payload.combines),
|
||||
derivations: wrapAdds(payload.derivations),
|
||||
constraints: wrapAdds(payload.constraints),
|
||||
categories: wrapCategoryAdds(payload.categories),
|
||||
templates: wrapAdds(payload.templates ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The five module subtypes share one table on the server, so an id
|
||||
* collision is detected against a single map. Bundles use their own
|
||||
* map. Categories merge by name server-side — no client-side collision
|
||||
* concept — so they never appear here.
|
||||
*/
|
||||
const MODULE_BUCKETS: Array<keyof Omit<ResolvedSelection, "categories" | "bundles" | "templates">> = [
|
||||
"wildcards",
|
||||
"fixed_values",
|
||||
"combines",
|
||||
"derivations",
|
||||
"constraints",
|
||||
];
|
||||
|
||||
const BUCKET_TO_KIND: Record<string, EntityKind> = {
|
||||
bundles: "bundle",
|
||||
wildcards: "wildcard",
|
||||
fixed_values: "fixed_values",
|
||||
combines: "combine",
|
||||
derivations: "derivation",
|
||||
constraints: "constraint",
|
||||
templates: "template",
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk the resolved selection and surface every id that already
|
||||
* exists in the live library. Modules + bundles only; categories and
|
||||
* templates have their own merge semantics handled elsewhere. The
|
||||
* incoming `name` is the raw payload field, falling back to the id
|
||||
* if a row arrived nameless (which the engine validator would reject
|
||||
* anyway — but defensively we don't want the modal to render
|
||||
* "undefined").
|
||||
*/
|
||||
function detectInstallCollisions(
|
||||
selection: ResolvedSelection,
|
||||
library: LibrarySnapshot,
|
||||
): InstallCollision[] {
|
||||
const out: InstallCollision[] = [];
|
||||
for (const bucket of MODULE_BUCKETS) {
|
||||
for (const row of selection[bucket]) {
|
||||
const existing = library.modules.get(row.entity.id);
|
||||
if (existing) {
|
||||
out.push({
|
||||
kind: BUCKET_TO_KIND[bucket as string]!,
|
||||
id: row.entity.id,
|
||||
incomingName: String(row.entity.name ?? row.entity.id),
|
||||
existingName: existing.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const row of selection.bundles) {
|
||||
const existing = library.bundles.get(row.entity.id);
|
||||
if (existing) {
|
||||
out.push({
|
||||
kind: "bundle",
|
||||
id: row.entity.id,
|
||||
incomingName: String(row.entity.name ?? row.entity.id),
|
||||
existingName: existing.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate `selection` in place, applying the user's per-id decision
|
||||
* to every matching entity row. Skip → drop the row. Replace →
|
||||
* change the decision wrapper. Rename → mint a fresh id, swap the
|
||||
* entity's id field, set the rename decision carrying the new id +
|
||||
* user-supplied new name.
|
||||
*/
|
||||
function applyCollisionDecisions(
|
||||
selection: ResolvedSelection,
|
||||
decisions: Record<string, CollisionDecision>,
|
||||
): void {
|
||||
const buckets: Array<keyof Omit<ResolvedSelection, "categories">> = [
|
||||
"bundles",
|
||||
...MODULE_BUCKETS,
|
||||
"templates",
|
||||
];
|
||||
for (const bucket of buckets) {
|
||||
const rows = selection[bucket];
|
||||
const next: ResolvedEntity[] = [];
|
||||
for (const row of rows) {
|
||||
const d = decisions[row.entity.id];
|
||||
if (!d) {
|
||||
// No decision recorded — entity wasn't a collision, keep as `add`.
|
||||
next.push(row);
|
||||
continue;
|
||||
}
|
||||
if (d.kind === "skip") {
|
||||
continue;
|
||||
}
|
||||
if (d.kind === "replace") {
|
||||
next.push({ entity: row.entity, decision: { kind: "replace" } as Decision });
|
||||
continue;
|
||||
}
|
||||
// Rename: mint a new id, rewrite the entity's id field, and
|
||||
// emit a `rename` decision carrying old_id + new_id + new_name.
|
||||
const newId = newShortId();
|
||||
const renamedEntity: Record<string, unknown> & { id: string } = {
|
||||
...row.entity,
|
||||
id: newId,
|
||||
};
|
||||
next.push({
|
||||
entity: renamedEntity,
|
||||
decision: { kind: "rename" as const, new_id: newId, new_name: d.new_name },
|
||||
});
|
||||
}
|
||||
// Re-assign to preserve the typed shape (the buckets above all
|
||||
// resolve to ResolvedEntity[] in their declared types). The
|
||||
// ResolvedSelection's `categories` field is a different row type
|
||||
// but the `buckets` array above excludes it, so the cast is sound.
|
||||
(selection as unknown as Record<string, ResolvedEntity[]>)[bucket] = next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a fully-validated community payload into the live library.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. ``parsePayload`` re-runs schema validation + migration chain +
|
||||
* integrity check. Same guard the Import tab uses.
|
||||
* 2. Convert every entity into an ``add`` decision (no picker;
|
||||
* server collision detection surfaces dupes).
|
||||
* 3. ``buildCommitPayload`` → POST ``/import/commit``.
|
||||
* 4. Aggregate counts + warnings into a single ``InstallResult``
|
||||
* the caller can render.
|
||||
*
|
||||
* Accepts either a JSON string (`raw`) or an already-parsed envelope
|
||||
* shape (`envelope`). The embed passes the in-memory envelope
|
||||
* directly to skip a JSON.stringify → parse round-trip; the Import
|
||||
* tab passes raw text. ``parsePayload`` always takes a string, so
|
||||
* envelope input gets re-stringified at the boundary.
|
||||
*/
|
||||
export async function installEnvelope(
|
||||
input: { raw: string } | { envelope: unknown },
|
||||
opts: InstallOptions,
|
||||
): Promise<InstallResult> {
|
||||
const raw = "raw" in input ? input.raw : JSON.stringify(input.envelope);
|
||||
const parsed = parsePayload(raw);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
installed: { ...EMPTY_COUNTS },
|
||||
warnings: [],
|
||||
migratedEntityCount: 0,
|
||||
error: { code: "parse_failed", message: parsed.reason },
|
||||
};
|
||||
}
|
||||
|
||||
const selection = buildSelection(parsed.payload);
|
||||
|
||||
// Stamp community origin onto every module + bundle entity going
|
||||
// in. The engine's _insert_module / _insert_bundle paths pick these
|
||||
// fields off the entity dict and write them to the new
|
||||
// community_post_slug / community_version_number columns (migration
|
||||
// 013). Origin-less installs (Import tab, starter set) leave the
|
||||
// columns NULL.
|
||||
if (opts.origin) {
|
||||
for (const bucket of [...MODULE_BUCKETS, "bundles"] as const) {
|
||||
for (const row of selection[bucket]) {
|
||||
row.entity.community_post_slug = opts.origin.post_slug;
|
||||
row.entity.community_version_number = opts.origin.version_number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side collision pre-check. Only fires when both a library
|
||||
// snapshot and a resolveCollisions callback are wired — otherwise
|
||||
// we fall through and let the server's commit endpoint surface the
|
||||
// conflict as a 4xx, preserving the original behaviour for callers
|
||||
// that haven't opted in to the new UX.
|
||||
if (opts.library && opts.resolveCollisions) {
|
||||
const collisions = detectInstallCollisions(selection, opts.library);
|
||||
if (collisions.length > 0) {
|
||||
const decisions = await opts.resolveCollisions(collisions);
|
||||
if (!decisions || Object.keys(decisions).length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
installed: { ...EMPTY_COUNTS },
|
||||
warnings: parsed.integrityWarnings,
|
||||
migratedEntityCount: parsed.migratedEntityCount,
|
||||
error: { code: "cancelled", message: "Install cancelled by user." },
|
||||
};
|
||||
}
|
||||
applyCollisionDecisions(selection, decisions);
|
||||
}
|
||||
}
|
||||
|
||||
const commitPayload = buildCommitPayload(selection);
|
||||
|
||||
// Pre-compute counts off the commit payload so the caller sees
|
||||
// exactly what the server is about to write. `installed` covers
|
||||
// adds + renames + replaces — every entity that actually lands.
|
||||
const installed = countBucket([
|
||||
...commitPayload.adds,
|
||||
...commitPayload.renames.map((r) => ({ kind: r.kind })),
|
||||
...commitPayload.replaces.map((r) => ({ kind: r.kind })),
|
||||
]);
|
||||
|
||||
try {
|
||||
const result = await opts.importExport.commit(commitPayload);
|
||||
return {
|
||||
ok: true,
|
||||
installed,
|
||||
warnings: parsed.integrityWarnings,
|
||||
migratedEntityCount: parsed.migratedEntityCount,
|
||||
commit: result,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
installed: { ...EMPTY_COUNTS },
|
||||
warnings: parsed.integrityWarnings,
|
||||
migratedEntityCount: parsed.migratedEntityCount,
|
||||
error: { code: "commit_failed", message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-exported so the host-bridge global can advertise the runtime's
|
||||
* current schema number alongside the install function. */
|
||||
export { CURRENT_SCHEMA_VERSION };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install path decision (spec §4 Flow 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InstallDecisionInput {
|
||||
payloadVersion: number;
|
||||
currentVersion: number;
|
||||
catalog: SchemaCatalogEntry[];
|
||||
}
|
||||
|
||||
export type InstallPath =
|
||||
| { path: "install_direct" }
|
||||
| { path: "install_with_chain"; fromVersion: number; toVersion: number }
|
||||
| { path: "install_tolerant" }
|
||||
| { path: "refuse"; reason: string };
|
||||
|
||||
/**
|
||||
* Decide which install branch applies. See spec §4 Flow 3.
|
||||
*
|
||||
* Path 3a (payload_v ≤ CURRENT):
|
||||
* - Equal → install_direct
|
||||
* - Less → install_with_chain (forward migrate)
|
||||
*
|
||||
* Path 3b (payload_v > CURRENT):
|
||||
* - AND-fold over (CURRENT, payload_v]: any breaking → refuse
|
||||
* - All additive → install_tolerant (strip + persist original verbatim)
|
||||
*
|
||||
* The AND-fold over the WHOLE interval is critical: it's not enough
|
||||
* to check `catalog[payload_v].is_breaking_from_previous` — a breaking
|
||||
* step earlier in the interval would still poison everything after.
|
||||
*/
|
||||
export function decideInstallPath(input: InstallDecisionInput): InstallPath {
|
||||
const { payloadVersion, currentVersion, catalog } = input;
|
||||
|
||||
if (payloadVersion <= currentVersion) {
|
||||
return payloadVersion === currentVersion
|
||||
? { path: "install_direct" }
|
||||
: { path: "install_with_chain", fromVersion: payloadVersion, toVersion: currentVersion };
|
||||
}
|
||||
|
||||
// AND-fold check (spec §3 #1): fold over WHOLE interval (CURRENT, payload_v]
|
||||
const breakingInInterval = catalog
|
||||
.filter((row) => row.version > currentVersion && row.version <= payloadVersion)
|
||||
.some((row) => row.is_breaking_from_previous);
|
||||
|
||||
if (breakingInInterval) {
|
||||
return { path: "refuse", reason: "breaking schema bump in interval" };
|
||||
}
|
||||
|
||||
return { path: "install_tolerant" };
|
||||
}
|
||||
@@ -1,14 +1,27 @@
|
||||
/**
|
||||
* Versioned migration chain for import payloads.
|
||||
*
|
||||
* Each version delta has a single function. The dispatcher walks from
|
||||
* payload's schema_version up to CURRENT_SCHEMA_VERSION, applying each
|
||||
* delta in turn. Lossy migrations short-circuit with reason.
|
||||
* Two distinct migration domains live in this file:
|
||||
*
|
||||
* 1. `migrateImportEnvelope` — operates on the FULL multi-row import
|
||||
* envelope (`RawPayload` with `bundles[]`, `wildcards[]`, etc).
|
||||
* Used by the manual JSON-paste importer in `parse.ts`.
|
||||
*
|
||||
* 2. `migratePayload` — operates on a SINGLE ROW (a module or bundle
|
||||
* dict). Per spec §2: forward-only chain with registry-keyed
|
||||
* dispatch by (kind, fromVersion); context is opt-in via
|
||||
* `requiresContext`; bundle migrators recurse into children via
|
||||
* the `applyModuleStep` helper enforcing the
|
||||
* single-version-per-root invariant.
|
||||
*
|
||||
* Adding a new schema version:
|
||||
* 1. Bump CURRENT_SCHEMA_VERSION below.
|
||||
* 2. Add a function `migrateVNToVN_plus_1` operating on entity-shape payloads.
|
||||
* 3. Register it in MIGRATION_CHAIN below.
|
||||
* 2. For envelope-shape entities, add a function `migrateVNToVN_plus_1`
|
||||
* and register it in MIGRATION_CHAIN.
|
||||
* 3. For per-row migrations (modules / bundles), call `registerMigrator`
|
||||
* with a `MigratorDefinition` for each (kind, fromVersion) interval.
|
||||
* Bundle migrators must recurse into children via
|
||||
* `ctx.applyModuleStep` rather than re-dispatching `migratePayload`.
|
||||
*/
|
||||
|
||||
export const CURRENT_SCHEMA_VERSION = 1;
|
||||
@@ -71,7 +84,7 @@ const MIGRATION_CHAIN: Record<number, VersionMigration> = {
|
||||
0: migrateV0ToV1,
|
||||
};
|
||||
|
||||
export function migratePayload(payload: Partial<RawPayload>): MigrationResult<RawPayload> {
|
||||
export function migrateImportEnvelope(payload: Partial<RawPayload>): MigrationResult<RawPayload> {
|
||||
if (typeof payload.schema_version !== "number") {
|
||||
return { ok: false, reason: "payload missing schema_version field" };
|
||||
}
|
||||
@@ -114,3 +127,82 @@ export function migratePayload(payload: Partial<RawPayload>): MigrationResult<Ra
|
||||
}
|
||||
return { ok: true, migrated: current, migratedEntityCount };
|
||||
}
|
||||
|
||||
// === Per-row migration API (spec §2) ===
|
||||
|
||||
export interface MigrationContext {
|
||||
categoryTree?: { lookup: (id: string) => string };
|
||||
/** Bundle migrators call this to recurse into children — see spec
|
||||
* §2 single-version-per-root invariant. The bundle migrator owns
|
||||
* its tree; children never carry an independent schema_version. */
|
||||
applyModuleStep: (child: Record<string, unknown>, fromVersion: number) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MigratorDefinition {
|
||||
kind: "module" | "bundle";
|
||||
fromVersion: number;
|
||||
requiresContext?: (keyof MigrationContext)[];
|
||||
migrate: (
|
||||
payload: Record<string, unknown>,
|
||||
context: MigrationContext,
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ROW_REGISTRY: Map<string, MigratorDefinition> = new Map();
|
||||
|
||||
function rowKey(kind: "module" | "bundle", fromVersion: number): string {
|
||||
return `${kind}:${fromVersion}`;
|
||||
}
|
||||
|
||||
export function registerMigrator(def: MigratorDefinition): void {
|
||||
ROW_REGISTRY.set(rowKey(def.kind, def.fromVersion), def);
|
||||
}
|
||||
|
||||
/** Test-only: clear the registry between tests. */
|
||||
export function _resetRegistryForTests(): void {
|
||||
ROW_REGISTRY.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row migration chain dispatcher.
|
||||
*
|
||||
* See spec §2. Forward-only; pure-by-default with declared context
|
||||
* injection. Bundle migrators MUST recurse into children via the
|
||||
* `applyModuleStep` helper on the injected context.
|
||||
*/
|
||||
export function migratePayload(
|
||||
payload: Record<string, unknown>,
|
||||
kind: "module" | "bundle",
|
||||
fromVersion: number,
|
||||
toVersion: number,
|
||||
context: Partial<MigrationContext> = {},
|
||||
): Record<string, unknown> {
|
||||
let current = payload;
|
||||
for (let v = fromVersion; v < toVersion; v++) {
|
||||
const migrator = ROW_REGISTRY.get(rowKey(kind, v));
|
||||
if (!migrator) {
|
||||
throw new Error(`no migrator for ${kind} ${v}->${v + 1}`);
|
||||
}
|
||||
for (const req of migrator.requiresContext ?? []) {
|
||||
if (!(req in context)) {
|
||||
throw new Error(
|
||||
`migrator ${kind} ${v}->${v + 1} requires context: ${req}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const fullContext: MigrationContext = {
|
||||
...context,
|
||||
applyModuleStep: (child, childFromVersion) => {
|
||||
const childMigrator = ROW_REGISTRY.get(rowKey("module", childFromVersion));
|
||||
if (!childMigrator) {
|
||||
throw new Error(
|
||||
`bundle recursion: no module migrator for ${childFromVersion}->${childFromVersion + 1}`,
|
||||
);
|
||||
}
|
||||
return childMigrator.migrate(child, { ...fullContext });
|
||||
},
|
||||
};
|
||||
current = migrator.migrate(current, fullContext);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* against payload's stamped snapshot_fingerprint when present)
|
||||
*/
|
||||
|
||||
import { migratePayload, type RawPayload } from "./migrations";
|
||||
import { migrateImportEnvelope, type RawPayload } from "./migrations";
|
||||
import { moduleFingerprint, type ModuleRow } from "./fingerprint";
|
||||
|
||||
export type WarningField = "bundle" | "wildcard" | "fixed_value" | "combine" | "derivation" | "constraint" | "category" | "template";
|
||||
@@ -30,7 +30,7 @@ export interface ParseOk {
|
||||
payload: RawPayload;
|
||||
/** Total entity count across all migration steps (NOT distinct entity count).
|
||||
* A 5-entity v0 payload passing through 1 step returns 5; through 2 steps
|
||||
* returns 10. Matches `migratePayload.migratedEntityCount` semantics. */
|
||||
* returns 10. Matches `migrateImportEnvelope.migratedEntityCount` semantics. */
|
||||
migratedEntityCount: number;
|
||||
integrityWarnings: IntegrityWarning[];
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export function parsePayload(raw: string): ParseResult {
|
||||
if (obj.templates !== undefined && !Array.isArray(obj.templates)) {
|
||||
return { ok: false, reason: "'templates' must be an array" };
|
||||
}
|
||||
const migrationResult = migratePayload(obj as Partial<RawPayload>);
|
||||
const migrationResult = migrateImportEnvelope(obj as Partial<RawPayload>);
|
||||
if (!migrationResult.ok) return { ok: false, reason: migrationResult.reason };
|
||||
const { migrated, migratedEntityCount } = migrationResult;
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Single-row community publish + copy helpers.
|
||||
*
|
||||
* Pulled out of ExportTab.vue so per-row action buttons (ModuleListView,
|
||||
* each editor's footer toolbar) can reuse the exact same engine-row
|
||||
* shape + deeplink format without duplicating logic. The Export tab
|
||||
* still owns the multi-row export pipeline; this is just the
|
||||
* single-entity path that community publishing needs.
|
||||
*
|
||||
* Engine-row shape contract (matches the canonical engine output —
|
||||
* see `engine/db/repositories.py:_row_to_module` /
|
||||
* `_row_to_bundle`): module rows carry top-level `id`/`type`/`name`
|
||||
* with type-specific content nested under `payload`. Bundles drop
|
||||
* `type` + replace `payload` with `children`, an array of full
|
||||
* module-row snapshots. Server-stamped fields (`payload_hash`,
|
||||
* `version`, timestamps, `snapshot_fingerprint`) are stripped — the
|
||||
* receiving end (community web, or the user's own clipboard) assigns
|
||||
* its own.
|
||||
*/
|
||||
|
||||
import type { Router } from "vue-router";
|
||||
import type { BundleRow, ModuleRow } from "../api/types";
|
||||
import { CURRENT_SCHEMA_VERSION } from "./migrations";
|
||||
import { getValidator, type ModuleSubtype } from "@/validators";
|
||||
import { version as ENGINE_VERSION } from "../../../package.json";
|
||||
|
||||
export interface PublishablePayload {
|
||||
/** Engine-row shape ready to ship: `{id, type?, name, payload|children, …}` */
|
||||
payload: Record<string, unknown>;
|
||||
/** Display name (used for the URL hash + clipboard toast). */
|
||||
name: string;
|
||||
/** Optional human description (prefilled on the community publish form). */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PublishBody {
|
||||
name: string;
|
||||
description: string;
|
||||
payload: Record<string, unknown>;
|
||||
schema_version: number;
|
||||
producer_engine_version: string;
|
||||
changelog?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the publish body shape with stamped schema_version +
|
||||
* producer_engine_version, after structurally validating the payload
|
||||
* against sister's CURRENT validator.
|
||||
*
|
||||
* Per spec §5f, the same validator code runs at install-time strict
|
||||
* validation on any consumer at the same CURRENT — a bug has to exist
|
||||
* in BOTH producer + consumer simultaneously to slip past, which makes
|
||||
* it a generic validator bug, not a vendor-drift one.
|
||||
*
|
||||
* Throws if the payload fails strict validation at CURRENT (per spec
|
||||
* §2 #1's authoritative-on-sister rule).
|
||||
*/
|
||||
export function buildPublishBody(input: {
|
||||
payload: Record<string, unknown>;
|
||||
name: string;
|
||||
description: string;
|
||||
changelog?: string;
|
||||
}): PublishBody {
|
||||
const kind = typeof input.payload.children !== "undefined" ? "bundle" : "module";
|
||||
const subtype = kind === "module" ? (input.payload.type as ModuleSubtype) : undefined;
|
||||
|
||||
const validator = getValidator(
|
||||
kind === "module"
|
||||
? { kind: "module", subtype: subtype!, version: CURRENT_SCHEMA_VERSION, mode: "strict" }
|
||||
: { kind: "bundle", version: CURRENT_SCHEMA_VERSION, mode: "strict" },
|
||||
);
|
||||
const result = validator.safeParse(input.payload);
|
||||
if (!result.success) {
|
||||
throw new Error(`structural validation failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
payload: input.payload,
|
||||
schema_version: CURRENT_SCHEMA_VERSION,
|
||||
producer_engine_version: ENGINE_VERSION,
|
||||
changelog: input.changelog ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the engine-row payload for a module library row. Strips
|
||||
* server-stamped lifecycle fields — community + the user's clipboard
|
||||
* both want a fresh row that they can re-stamp.
|
||||
*/
|
||||
export function buildModulePublishable(row: ModuleRow): PublishablePayload {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
category_id: row.category_id,
|
||||
tags: row.tags,
|
||||
is_favorite: row.is_favorite,
|
||||
payload: row.payload,
|
||||
};
|
||||
return { payload, name: row.name, description: row.description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the engine-row payload for a bundle library row. Children
|
||||
* keep their full module-row shape (deep snapshots) — the community
|
||||
* importer expects to recurse on those at install time.
|
||||
*/
|
||||
export function buildBundlePublishable(row: BundleRow): PublishablePayload {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
color: row.color,
|
||||
category_id: row.category_id,
|
||||
tags: row.tags,
|
||||
is_favorite: row.is_favorite,
|
||||
children: row.children,
|
||||
};
|
||||
return { payload, name: row.name, description: row.description };
|
||||
}
|
||||
|
||||
/**
|
||||
* utf8-safe base64 — `btoa()` only handles latin-1, so we walk the
|
||||
* UTF-8 bytes manually. Mirror of `b64ToText` on the community web
|
||||
* side (`web/src/views/PublishView.vue:b64ToText`).
|
||||
*/
|
||||
function textToB64(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate the extension to the Community tab's publish view with the
|
||||
* payload pre-filled via the URL hash. The community embed's
|
||||
* EmbedPublish reads `window.location.hash` on mount + hydrates the
|
||||
* form — same b64 contract the community web's PublishView already
|
||||
* understood. Hash (not query) keeps the payload off the request log
|
||||
* and sidesteps Edge's ~2KB address-bar limit on bundle exports.
|
||||
*
|
||||
* Replaces the prior `window.open(WPC_API_URL/upload)` flow: opening
|
||||
* the browser threw away the in-extension bearer token (different
|
||||
* origin storage) and forced a fresh device-flow login. Routing
|
||||
* inside the SPA keeps auth alive + the user inside ComfyUI.
|
||||
*/
|
||||
export function publishToCommunity(
|
||||
pub: PublishablePayload,
|
||||
router: Router,
|
||||
): void {
|
||||
// Validate before navigation — same validator that consumers run at
|
||||
// install-time strict validation (spec §5f). Throws if invalid.
|
||||
buildPublishBody({
|
||||
payload: pub.payload,
|
||||
name: pub.name,
|
||||
description: pub.description,
|
||||
});
|
||||
// (We discard the body since the actual POST happens in EmbedPublish
|
||||
// after the user fills the form. The validation side-effect is the
|
||||
// point — fail fast at click time, not after navigation.)
|
||||
|
||||
const b64 = textToB64(JSON.stringify(pub.payload));
|
||||
const hash = new URLSearchParams();
|
||||
hash.set("payload", b64);
|
||||
if (pub.name) hash.set("name", pub.name);
|
||||
if (pub.description) hash.set("description", pub.description);
|
||||
router.push({ path: "/community/publish", hash: `#${hash.toString()}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the pretty-printed engine-row JSON to the clipboard. Resolves
|
||||
* to true on success, false on failure (clipboard denied / no API).
|
||||
* Callers should surface a toast for either case.
|
||||
*/
|
||||
export async function copyPayloadToClipboard(
|
||||
pub: PublishablePayload,
|
||||
): Promise<boolean> {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(pub.payload, null, 2));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tolerant strip-unknown parse for newer-than-CURRENT payloads.
|
||||
*
|
||||
* See community-web spec
|
||||
* docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
|
||||
* §3: when payload.schema_version > CURRENT and the AND-fold over the
|
||||
* catalog interval is all-additive, sister tolerantly drops unknown
|
||||
* fields and validates the result against CURRENT's strict shape.
|
||||
*
|
||||
* IMPORTANT: this function only handles the "shape projection" — the
|
||||
* caller is responsible for separately preserving the original payload
|
||||
* bytes in original_payload_json (the verbatim-local mirror).
|
||||
*/
|
||||
import type { SafeParseReturnType } from "zod";
|
||||
|
||||
import { getValidator, type ModuleSubtype } from "@/validators";
|
||||
|
||||
export function parseTolerantAsCurrentShape(
|
||||
payload: Record<string, unknown>,
|
||||
currentVersion: number,
|
||||
kind: "module",
|
||||
subtype: ModuleSubtype,
|
||||
): SafeParseReturnType<unknown, Record<string, unknown>>;
|
||||
export function parseTolerantAsCurrentShape(
|
||||
payload: Record<string, unknown>,
|
||||
currentVersion: number,
|
||||
kind: "bundle",
|
||||
): SafeParseReturnType<unknown, Record<string, unknown>>;
|
||||
export function parseTolerantAsCurrentShape(
|
||||
payload: Record<string, unknown>,
|
||||
currentVersion: number,
|
||||
kind: "module" | "bundle",
|
||||
subtype?: ModuleSubtype,
|
||||
): SafeParseReturnType<unknown, Record<string, unknown>> {
|
||||
const spec = kind === "module"
|
||||
? {
|
||||
kind: "module" as const,
|
||||
subtype: subtype!,
|
||||
version: currentVersion,
|
||||
mode: "tolerant" as const,
|
||||
}
|
||||
: {
|
||||
kind: "bundle" as const,
|
||||
version: currentVersion,
|
||||
mode: "tolerant" as const,
|
||||
};
|
||||
|
||||
const validator = getValidator(spec);
|
||||
|
||||
if (kind === "bundle") {
|
||||
// Pre-strip each child against its subtype's tolerant validator
|
||||
// BEFORE running the bundle root validator. The bundle root's own
|
||||
// .strip() only drops unknown top-level fields; children inside
|
||||
// the discriminated union remain strict unless we pre-process.
|
||||
const children = Array.isArray((payload as Record<string, unknown>).children)
|
||||
? ((payload as Record<string, unknown>).children as Record<string, unknown>[])
|
||||
: [];
|
||||
const strippedChildren = children.map((child) => {
|
||||
const subtypeVal = (child as Record<string, unknown>).type;
|
||||
if (typeof subtypeVal !== "string") return child;
|
||||
const childResult = parseTolerantAsCurrentShape(
|
||||
child,
|
||||
currentVersion,
|
||||
"module",
|
||||
subtypeVal as ModuleSubtype,
|
||||
);
|
||||
return childResult.success ? childResult.data : child;
|
||||
});
|
||||
const reshaped = { ...payload, children: strippedChildren };
|
||||
return validator.safeParse(reshaped) as SafeParseReturnType<unknown, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
return validator.safeParse(payload) as SafeParseReturnType<unknown, Record<string, unknown>>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RouterView } from "vue-router";
|
||||
import AppTopbar from "./AppTopbar.vue";
|
||||
import AppSidebar from "./AppSidebar.vue";
|
||||
import StaleBanner from "../components/StaleBanner.vue";
|
||||
import ErrorBoundary from "../components/ErrorBoundary.vue";
|
||||
import ToastHost from "../components/ui/ToastHost.vue";
|
||||
import TweaksPanel from "../components/TweaksPanel.vue";
|
||||
import CommandPalette from "../components/CommandPalette.vue";
|
||||
@@ -17,6 +18,7 @@ import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useBundleStore } from "../stores/bundleStore";
|
||||
import { useTemplateStore } from "../stores/templateStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useCommunityUpdateStore } from "../stores/communityUpdateStore";
|
||||
import { useCascadeStore } from "../cascade/cascade-store";
|
||||
import { api } from "../api/client";
|
||||
import {
|
||||
@@ -35,6 +37,7 @@ const moduleStore = useModuleStore();
|
||||
const bundleStore = useBundleStore();
|
||||
const templateStore = useTemplateStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
const communityUpdateStore = useCommunityUpdateStore();
|
||||
const cascadeStore = useCascadeStore();
|
||||
|
||||
const paletteOpen = ref(false);
|
||||
@@ -162,6 +165,14 @@ onMounted(() => {
|
||||
cascadeStore.rebuildFromCatalogs(
|
||||
moduleStore.catalog, bundleStore.catalog, categoryStore.items,
|
||||
);
|
||||
// Cross-check community-installed rows against the community
|
||||
// for newer versions. Runs after the library snapshot is in
|
||||
// place; failures (offline, CORS, server down) clear the
|
||||
// store quietly so we never block the UI on the embed host.
|
||||
void communityUpdateStore.check({
|
||||
modules: moduleStore.catalog,
|
||||
bundles: bundleStore.catalog,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
|
||||
// Live-refresh sidebar count badges + catalogs whenever the drift
|
||||
@@ -224,18 +235,20 @@ onBeforeUnmount(() => {
|
||||
<div class="wp-body" :data-collapsed="ui.sidebarCollapsed || undefined">
|
||||
<AppSidebar />
|
||||
<main id="wp-main" class="wp-content" tabindex="-1">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition name="route-fade" mode="out-in">
|
||||
<!-- Key by `route.meta.layoutKey` when set so a view that backs
|
||||
several param routes (e.g. Docs.vue across /docs/:page) stays
|
||||
mounted across those navigations — its sub-nav keeps scroll
|
||||
position. Falls back to route.path for every other view. -->
|
||||
<component
|
||||
:is="Component"
|
||||
:key="typeof route.meta.layoutKey === 'string' ? route.meta.layoutKey : route.path"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<ErrorBoundary>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition name="route-fade" mode="out-in">
|
||||
<!-- Key by `route.meta.layoutKey` when set so a view that backs
|
||||
several param routes (e.g. Docs.vue across /docs/:page) stays
|
||||
mounted across those navigations — its sub-nav keeps scroll
|
||||
position. Falls back to route.path for every other view. -->
|
||||
<component
|
||||
:is="Component"
|
||||
:key="typeof route.meta.layoutKey === 'string' ? route.meta.layoutKey : route.path"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
<ToastHost />
|
||||
|
||||
@@ -353,7 +353,7 @@ const countByKey = computed<Record<string, number>>(() => {
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
padding: var(--wp-space-3) var(--wp-space-4);
|
||||
margin-bottom: var(--wp-space-3);
|
||||
margin: var(--wp-space-3) 0;
|
||||
background: var(--wp-bg-2);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
border: 1px solid var(--wp-border);
|
||||
|
||||
@@ -8,6 +8,18 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { useUiStore } from "./stores/uiStore";
|
||||
import { useTweaksStore } from "./stores/tweaksStore";
|
||||
import { api as managerApi } from "./api/client";
|
||||
import {
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
installEnvelope,
|
||||
type CollisionDecision,
|
||||
type InstallCollision,
|
||||
type InstallOrigin,
|
||||
type InstallResult,
|
||||
type LibrarySnapshot,
|
||||
} from "./import-export/install";
|
||||
import { useModuleStore } from "./stores/moduleStore";
|
||||
import { useBundleStore } from "./stores/bundleStore";
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
@@ -21,3 +33,88 @@ useUiStore(pinia).initializeTheme();
|
||||
// CSS-var overrides land before the first paint and we don't flash defaults.
|
||||
useTweaksStore(pinia).initialize();
|
||||
app.mount("#app");
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
Host-bridge: window.__wpcRuntime
|
||||
────────────────────────────────────────────────────────────────
|
||||
The community embed bundle (loaded from wp.dumiflex.dev via the
|
||||
Community tab) detects this global at runtime and calls
|
||||
`install(envelope)` to drop a pack into the live library directly
|
||||
— no file download + manual Import tab round-trip.
|
||||
|
||||
`abi` is a coarse version on the bridge contract itself. Bump
|
||||
when the shape changes incompatibly (add a field = no bump; remove
|
||||
or rename a field = bump). `schemaVersion` is the entity-payload
|
||||
schema the engine accepts today. The embed compares its envelope's
|
||||
`schema_version` to this number: greater = "update extension to
|
||||
install"; less or equal = migrations run during install.
|
||||
|
||||
Threat model: same-origin globals are readable by every script
|
||||
running in this document — other ComfyUI extensions already have
|
||||
that access via Vue/Pinia imports, so this doesn't widen the
|
||||
surface. The user is expected to confirm before each install
|
||||
(the embed shows a dialog), and the install pipeline runs the
|
||||
same integrity + migration checks the Import tab uses, so a
|
||||
malformed payload from any source fails the same way.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
declare global {
|
||||
interface Window {
|
||||
__wpcRuntime?: {
|
||||
abi: number;
|
||||
schemaVersion: number;
|
||||
install: (
|
||||
envelope: unknown,
|
||||
opts?: {
|
||||
/** Called when client-side collision detection finds duplicates.
|
||||
* Receives one row per conflict. Returning null cancels the
|
||||
* install. The embed wires this to its own modal. */
|
||||
resolveCollisions?: (
|
||||
rows: InstallCollision[],
|
||||
) => Promise<Record<string, CollisionDecision> | null>;
|
||||
/** Tag every inserted entity with this community origin so
|
||||
* the SPA can surface "installed from @author/pack" + drive
|
||||
* update-available checks. Embed passes {post_slug,
|
||||
* version_number} when invoking install via the community
|
||||
* Install / Install vN buttons. */
|
||||
origin?: InstallOrigin;
|
||||
},
|
||||
) => Promise<InstallResult>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot Pinia stores into the LibrarySnapshot shape installEnvelope
|
||||
* expects. Called fresh per `install()` invocation so a long-lived
|
||||
* embed picks up library state mutations between installs.
|
||||
*/
|
||||
function snapshotLibrary(): LibrarySnapshot {
|
||||
const moduleStore = useModuleStore();
|
||||
const bundleStore = useBundleStore();
|
||||
const modules = new Map<string, { id: string; name: string }>();
|
||||
for (const m of moduleStore.catalog) {
|
||||
modules.set(m.id, { id: m.id, name: m.name });
|
||||
}
|
||||
const bundles = new Map<string, { id: string; name: string }>();
|
||||
for (const b of bundleStore.catalog) {
|
||||
bundles.set(b.id, { id: b.id, name: b.name });
|
||||
}
|
||||
return { modules, bundles };
|
||||
}
|
||||
|
||||
window.__wpcRuntime = {
|
||||
// ABI stays at 1: the install signature only gained an optional 2nd
|
||||
// argument, which old callers passing a single argument continue to
|
||||
// satisfy. Bump only if a future change removes/renames a field.
|
||||
abi: 1,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
install: (envelope, opts) => installEnvelope(
|
||||
{ envelope },
|
||||
{
|
||||
importExport: managerApi.importExport,
|
||||
library: snapshotLibrary(),
|
||||
...(opts?.resolveCollisions ? { resolveCollisions: opts.resolveCollisions } : {}),
|
||||
...(opts?.origin ? { origin: opts.origin } : {}),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -40,15 +40,29 @@ const routes: RouteRecordRaw[] = [
|
||||
// scroll position); only the content pane swaps reactively.
|
||||
{ path: "docs", name: "documentation", component: () => import("../views/Docs.vue"), meta: { layoutKey: "docs" } },
|
||||
{ path: "docs/:page", name: "documentation-page", component: () => import("../views/Docs.vue"), props: true, meta: { layoutKey: "docs" } },
|
||||
// Community hub is on `feat/community-tab` while it bakes; main ships
|
||||
// a WIP placeholder so the sidebar entry has somewhere to land. The
|
||||
// catch-all `community/:rest(.*)?` swallows any deep-links saved from
|
||||
// the old routes (/community/discover, /community/m/:id, etc.).
|
||||
{ path: "community", name: "community", component: () => import("../views/CommunityWip.vue") },
|
||||
{ path: "community/:rest(.*)*", redirect: "/community" },
|
||||
// Community tab dynamically loads the wpc-embed bundle from
|
||||
// WPC_API_URL. The `:rest(.*)*` catch-all lets the embed
|
||||
// surface its own deep-link routes (/community/p/owner/name,
|
||||
// /community/u/username) without forcing a separate route per
|
||||
// shape. Community.vue parses the rest segment and forwards
|
||||
// to the embed's navigate(target).
|
||||
//
|
||||
// Both records share `layoutKey: "community"` so AppLayout's
|
||||
// keyed <RouterView> does NOT re-mount the host when navigating
|
||||
// between /community and /community/p/... — otherwise the embed
|
||||
// bundle would tear down + re-mount on every internal nav, and
|
||||
// the first click on a card would silently re-mount instead of
|
||||
// navigating (only the second click would land).
|
||||
{ path: "community", name: "community", component: () => import("../views/Community.vue"), meta: { layoutKey: "community" } },
|
||||
{ path: "community/:rest(.*)*", name: "community-deep", component: () => import("../views/Community.vue"), meta: { layoutKey: "community" } },
|
||||
],
|
||||
},
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/dashboard" },
|
||||
// Unknown URL surfaces a 404 instead of hard-redirecting to
|
||||
// /dashboard. The previous redirect silently swallowed typos +
|
||||
// dead links — users assumed nothing was wrong because they
|
||||
// landed home. NotFound.vue makes the failure visible + gives
|
||||
// them a clear path back.
|
||||
{ path: "/:pathMatch(.*)*", name: "not-found", component: () => import("../views/NotFound.vue") },
|
||||
];
|
||||
|
||||
export default createRouter({ history: createWebHistory("/wp/"), routes });
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Community update-availability tracker.
|
||||
*
|
||||
* After every library refresh, walk all module + bundle rows that
|
||||
* carry a `community_post_slug` (engine migration 013), batch-fetch
|
||||
* the corresponding community posts, and surface any row whose
|
||||
* locally-stored `community_version_number` is below the post's
|
||||
* current `latest_version_number` as an "update available" entry.
|
||||
*
|
||||
* The Community tab's embed badge + per-row indicator both read from
|
||||
* this store; the actual upgrade action is "click the row → opens
|
||||
* the community post detail page in the embed → user clicks Install
|
||||
* again", at which point the collision resolver from v1.8 P2 lets
|
||||
* them pick Replace (in-place update) or Rename (parallel install).
|
||||
*
|
||||
* Polling cadence: we only refresh on explicit `check()` calls — the
|
||||
* caller (AppLayout / Dashboard mount) decides when. No background
|
||||
* timer; the ComfyUI tab might be open for hours in the background
|
||||
* and we don't want to hammer the community API for users who never
|
||||
* look at it.
|
||||
*
|
||||
* Bundle posts: when a bundle's latest_version_number drifts, only
|
||||
* the bundle row itself is marked — the underlying module rows in
|
||||
* the bundle's children list are tracked separately (each child
|
||||
* stamps its own community_post_slug at install time, derived from
|
||||
* the bundle post's children versions). Future refinement: surface
|
||||
* "bundle has updates" as a single rollup instead of N separate
|
||||
* child indicators.
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { WPC_API_URL } from "../config/links";
|
||||
|
||||
/** localStorage key for user-dismissed (slug → version) pairs.
|
||||
* Synced with CommunityUpdateDialog.onDismiss — keep in lockstep. */
|
||||
const DISMISS_KEY = "wpc.community-update-dismissed";
|
||||
|
||||
/**
|
||||
* One pending update entry. Keyed by entity id internally so the
|
||||
* per-row badge lookup is O(1); the post slug field carries the
|
||||
* source-of-truth pointer back to the community post detail.
|
||||
*/
|
||||
export interface UpdateEntry {
|
||||
entity_id: string;
|
||||
entity_kind: "module" | "bundle";
|
||||
post_slug: string;
|
||||
installed_version: number;
|
||||
latest_version: number;
|
||||
}
|
||||
|
||||
interface CommunityPostMeta {
|
||||
data: {
|
||||
slug: string;
|
||||
latest_version_number?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const useCommunityUpdateStore = defineStore("communityUpdates", () => {
|
||||
/** Updates keyed by entity id. Re-built on every `check()` so a
|
||||
* user upgrading via the collision-resolver Replace path clears
|
||||
* itself on the next refresh. */
|
||||
const updatesByEntityId = ref<Map<string, UpdateEntry>>(new Map());
|
||||
const checking = ref(false);
|
||||
const lastCheckedAt = ref<number | null>(null);
|
||||
|
||||
/**
|
||||
* Walk a fresh library snapshot, batch-fetch the unique post
|
||||
* slugs, and re-populate `updatesByEntityId`. Modules and bundles
|
||||
* both pass through the same flow; callers don't need to dedup the
|
||||
* slug set themselves.
|
||||
*
|
||||
* Network failures (offline, CORS, server down) are swallowed — a
|
||||
* broken community origin shouldn't make the SPA unusable. We
|
||||
* still clear stale entries so the badge state matches "we could
|
||||
* not verify, assume no updates".
|
||||
*/
|
||||
async function check(input: {
|
||||
modules: Array<{ id: string; community_post_slug?: string | null; community_version_number?: number | null }>;
|
||||
bundles: Array<{ id: string; community_post_slug?: string | null; community_version_number?: number | null }>;
|
||||
}): Promise<void> {
|
||||
checking.value = true;
|
||||
try {
|
||||
const candidates: Array<{
|
||||
entity_id: string;
|
||||
entity_kind: "module" | "bundle";
|
||||
post_slug: string;
|
||||
installed_version: number;
|
||||
}> = [];
|
||||
for (const row of input.modules) {
|
||||
if (row.community_post_slug && typeof row.community_version_number === "number") {
|
||||
candidates.push({
|
||||
entity_id: row.id,
|
||||
entity_kind: "module",
|
||||
post_slug: row.community_post_slug,
|
||||
installed_version: row.community_version_number,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const row of input.bundles) {
|
||||
if (row.community_post_slug && typeof row.community_version_number === "number") {
|
||||
candidates.push({
|
||||
entity_id: row.id,
|
||||
entity_kind: "bundle",
|
||||
post_slug: row.community_post_slug,
|
||||
installed_version: row.community_version_number,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: when no rows carry community origin (the typical
|
||||
// failure mode is "nothing was installed since migration 013
|
||||
// ran" or "the install bridge dropped origin"), surface that
|
||||
// explicitly so a user wondering why no update pills show up
|
||||
// can confirm the candidate set in DevTools instead of poking
|
||||
// around the UI. Only logs ONCE per check.
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
"[wpc-update-check] modules=%d bundles=%d candidates=%d",
|
||||
input.modules.length,
|
||||
input.bundles.length,
|
||||
candidates.length,
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
updatesByEntityId.value = new Map();
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedup post slugs — the same post can back multiple entities
|
||||
// (a bundle child install stamps each module row with the
|
||||
// bundle's slug; sharing the fetched latest_version across
|
||||
// rows avoids re-hitting the endpoint).
|
||||
const uniqueSlugs = Array.from(new Set(candidates.map((c) => c.post_slug)));
|
||||
const latestBySlug = new Map<string, number>();
|
||||
// Per-slug fetch — the community has no batch-by-slug endpoint
|
||||
// today; using GET /api/v1/posts/{slug} keeps us on the public
|
||||
// surface. Cap parallelism by chunk so a 50-entity library
|
||||
// doesn't open 50 sockets at once.
|
||||
const CHUNK = 8;
|
||||
for (let i = 0; i < uniqueSlugs.length; i += CHUNK) {
|
||||
const slice = uniqueSlugs.slice(i, i + CHUNK);
|
||||
const results = await Promise.allSettled(
|
||||
slice.map((slug) =>
|
||||
fetch(`${WPC_API_URL}/api/v1/posts/${slug}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j: CommunityPostMeta | null) => ({ slug, latest: j?.data?.latest_version_number ?? null })),
|
||||
),
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled" && r.value && typeof r.value.latest === "number") {
|
||||
latestBySlug.set(r.value.slug, r.value.latest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User-dismissed (slug, version) pairs — see
|
||||
// CommunityUpdateDialog's onDismiss. Survives reloads; we read
|
||||
// it fresh per check so a dismissal in one tab affects the
|
||||
// next check in another.
|
||||
let dismissedMap: Record<string, number> = {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DISMISS_KEY);
|
||||
if (raw) dismissedMap = JSON.parse(raw) as Record<string, number>;
|
||||
} catch {
|
||||
// localStorage denied / malformed — treat as empty.
|
||||
}
|
||||
|
||||
const next = new Map<string, UpdateEntry>();
|
||||
for (const c of candidates) {
|
||||
const latest = latestBySlug.get(c.post_slug);
|
||||
if (typeof latest !== "number" || latest <= c.installed_version) continue;
|
||||
// Skip if the user already dismissed this exact post version.
|
||||
// A newer version drops past the dismissal (we compare against
|
||||
// the dismissed number, not the slug alone).
|
||||
if ((dismissedMap[c.post_slug] ?? 0) >= latest) continue;
|
||||
next.set(c.entity_id, {
|
||||
entity_id: c.entity_id,
|
||||
entity_kind: c.entity_kind,
|
||||
post_slug: c.post_slug,
|
||||
installed_version: c.installed_version,
|
||||
latest_version: latest,
|
||||
});
|
||||
}
|
||||
updatesByEntityId.value = next;
|
||||
lastCheckedAt.value = Date.now();
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
"[wpc-update-check] fetched=%d updates=%d",
|
||||
latestBySlug.size,
|
||||
next.size,
|
||||
);
|
||||
} catch {
|
||||
// Defensive: any unexpected error path clears stale entries so
|
||||
// we don't render a misleading "v2 available" pill against a
|
||||
// post that's been deleted / unreachable.
|
||||
updatesByEntityId.value = new Map();
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Update entry for a specific entity id, or null when none. */
|
||||
function entryFor(entityId: string): UpdateEntry | null {
|
||||
return updatesByEntityId.value.get(entityId) ?? null;
|
||||
}
|
||||
|
||||
const totalCount = computed<number>(() => updatesByEntityId.value.size);
|
||||
|
||||
return {
|
||||
updatesByEntityId,
|
||||
checking,
|
||||
lastCheckedAt,
|
||||
totalCount,
|
||||
check,
|
||||
entryFor,
|
||||
};
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import ColorPicker from "../components/ColorPicker.vue";
|
||||
import BundleChildRow from "../components/BundleChildRow.vue";
|
||||
@@ -46,6 +47,9 @@ const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const { resolveReturnTo } = useReturnTo();
|
||||
const store = useBundleStore();
|
||||
const currentRow = computed(() =>
|
||||
props.id ? store.catalog.find((b) => b.id === props.id) ?? null : null,
|
||||
);
|
||||
const categoryStore = useCategoryStore();
|
||||
const moduleStore = useModuleStore();
|
||||
const toast = useToast();
|
||||
@@ -62,35 +66,7 @@ const cascadeRefs = computed(() => {
|
||||
|
||||
async function onEntityDeleteClick(): Promise<void> {
|
||||
if (!props.id) return;
|
||||
if (cascadeRefs.value.length === 0) {
|
||||
const result = await cascadeApply.apply({
|
||||
kind: "bundle", id: props.id, action: "delete",
|
||||
});
|
||||
if (result.ok) {
|
||||
store.remove(props.id);
|
||||
const undoId = result.undo_entry_id;
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `"${name.value}" deleted`,
|
||||
life: 5000,
|
||||
action: {
|
||||
label: "Undo",
|
||||
run: async () => {
|
||||
const undoResult = await cascadeApply.undo(undoId);
|
||||
if (!undoResult.ok) {
|
||||
toast.push({ severity: "error", summary: "Undo failed", detail: undoResult.error, life: 4000 });
|
||||
} else {
|
||||
toast.push({ severity: "info", summary: `"${name.value}" restored`, life: 3000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
router.push(resolveReturnTo("/bundles"));
|
||||
} else {
|
||||
toast.push({ severity: "error", summary: "Delete failed", detail: (result as { ok: false; error: string }).error, life: 4000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Always confirm — see WildcardEditor for the rationale.
|
||||
cascadeDialogOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -654,6 +630,12 @@ const visibleErrors = computed<EditorFieldError[]>(() =>
|
||||
<span v-if="cascadeRefs.length > 0" class="wp-editor-used-by">
|
||||
used by <PillCountBadge :count="cascadeRefs.length" />
|
||||
</span>
|
||||
<CommunityRowActions
|
||||
v-if="currentRow"
|
||||
:row="currentRow"
|
||||
kind="bundle"
|
||||
labeled
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isEdit" #footer-left>
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
|
||||
import { useBulkActions } from "../composables/useBulkActions";
|
||||
import { makeBundleStoreAdapter } from "../composables/bulkAdapters";
|
||||
import ModuleListView from "../components/ModuleListView.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import ValidityIcon from "../components/ValidityIcon.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
@@ -355,6 +356,7 @@ function frameColor(row: BundleRow): string {
|
||||
|
||||
<template #actions="{ row }">
|
||||
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
|
||||
<CommunityRowActions :row="row" kind="bundle" />
|
||||
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import Field from "../components/ui/Field.vue";
|
||||
import Chip from "../components/ui/Chip.vue";
|
||||
@@ -51,6 +52,9 @@ import type {
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const currentRow = computed(() =>
|
||||
props.id ? moduleStore.catalog.find((m) => m.id === props.id) ?? null : null,
|
||||
);
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
const recent = useRecentStore();
|
||||
@@ -75,35 +79,7 @@ const cascadeRefs = computed(() => {
|
||||
|
||||
async function onEntityDeleteClick(): Promise<void> {
|
||||
if (!props.id) return;
|
||||
if (cascadeRefs.value.length === 0) {
|
||||
const result = await cascadeApply.apply({
|
||||
kind: "combine", id: props.id, action: "delete",
|
||||
});
|
||||
if (result.ok) {
|
||||
moduleStore.remove(props.id);
|
||||
const undoId = result.undo_entry_id;
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `"${name.value}" deleted`,
|
||||
life: 5000,
|
||||
action: {
|
||||
label: "Undo",
|
||||
run: async () => {
|
||||
const undoResult = await cascadeApply.undo(undoId);
|
||||
if (!undoResult.ok) {
|
||||
toast.push({ severity: "error", summary: "Undo failed", detail: undoResult.error, life: 4000 });
|
||||
} else {
|
||||
toast.push({ severity: "info", summary: `"${name.value}" restored`, life: 3000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
router.push(resolveReturnTo("/combines"));
|
||||
} else {
|
||||
toast.push({ severity: "error", summary: "Delete failed", detail: (result as { ok: false; error: string }).error, life: 4000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Always confirm — see WildcardEditor for the rationale.
|
||||
cascadeDialogOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -524,6 +500,12 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
<span v-if="cascadeRefs.length > 0" class="wp-editor-used-by">
|
||||
used by <PillCountBadge :count="cascadeRefs.length" />
|
||||
</span>
|
||||
<CommunityRowActions
|
||||
v-if="currentRow"
|
||||
:row="currentRow"
|
||||
kind="module"
|
||||
labeled
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isEdit" #footer-left>
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
|
||||
import { useBulkActions } from "../composables/useBulkActions";
|
||||
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
|
||||
import ModuleListView from "../components/ModuleListView.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import ValidityIcon from "../components/ValidityIcon.vue";
|
||||
import { validateModule } from "../utils/validateModule";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
@@ -306,6 +307,7 @@ function templateParts(row: ModuleRow): TemplatePart[] {
|
||||
<template #actions="{ row }">
|
||||
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
|
||||
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
|
||||
<CommunityRowActions :row="row" kind="module" />
|
||||
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Community — mounts the Wildcard Pipeline Community embed bundle.
|
||||
*
|
||||
* The bundle ships from the community web host (`WPC_API_URL`) and
|
||||
* re-bundles Vue internally (separate module scope from this
|
||||
* extension's Vue). We dynamic-import it on first visit, mount it
|
||||
* into a slot div, drive navigation through the handle, and tear it
|
||||
* down on `onBeforeUnmount`.
|
||||
*
|
||||
* Auth: a localStorage-backed TokenStore. Until the device-flow
|
||||
* runner lands inside the extension shell the store reports null,
|
||||
* so the embed renders anonymously (public posts only) — perfect
|
||||
* fallback for v1.
|
||||
*
|
||||
* Offline / unreachable host: the loader throws `EmbedLoadError`;
|
||||
* we surface a retry + the Discord invite so users can still get
|
||||
* unblocked.
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import { DISCORD_INVITE, WPC_API_URL } from "../config/links";
|
||||
import {
|
||||
EmbedLoadError,
|
||||
loadEmbed,
|
||||
type EmbedHandle,
|
||||
type EmbedNavigateTarget,
|
||||
} from "../community/embedLoader";
|
||||
import { createWpcTokenStore } from "../community/tokenStore";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const containerEl = ref<HTMLDivElement | null>(null);
|
||||
const handle = ref<EmbedHandle | null>(null);
|
||||
const loadError = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
|
||||
// Map the extension's `/community/...` URL to the embed's view shape.
|
||||
// `/community` → browse, `/community/p/owner/name` → detail,
|
||||
// `/community/u/username` → profile. Everything else falls through
|
||||
// to browse so the embed doesn't 404 on a stale deep-link.
|
||||
//
|
||||
// vue-router 4's `:rest(.*)*` matcher hands back an ARRAY of segments
|
||||
// (one per `/` between the catch-all and the end of the URL) — NOT a
|
||||
// pre-joined string. `String(arr)` then comma-joins, which made
|
||||
// parseRoute always return browse and triggered an immediate
|
||||
// router.replace back to `/community` after every detail navigation
|
||||
// (URL flickered, first click visibly bounced).
|
||||
function parseRoute(): EmbedNavigateTarget {
|
||||
const raw = route.params.rest;
|
||||
const parts = (Array.isArray(raw) ? raw : raw ? [raw] : []).filter(Boolean);
|
||||
if (parts[0] === "p" && parts[1] && parts[2]) {
|
||||
return { view: "detail", slug: `${parts[1]}/${parts[2]}` };
|
||||
}
|
||||
if (parts[0] === "u" && parts[1]) {
|
||||
return { view: "profile", username: parts[1] };
|
||||
}
|
||||
// /community/publish — per-row Upload buttons land here with the
|
||||
// engine-row payload encoded into window.location.hash. The embed
|
||||
// bundle's EmbedPublish component reads the hash on mount and
|
||||
// hydrates the form (single-row-publish.ts is the producer).
|
||||
if (parts[0] === "publish") {
|
||||
return { view: "publish" };
|
||||
}
|
||||
return { view: "browse" };
|
||||
}
|
||||
|
||||
async function tryMount() {
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
// On retry, the host div is v-if'd out while loadError is set —
|
||||
// wait one tick so Vue mounts it back before we read the ref.
|
||||
await nextTick();
|
||||
if (!containerEl.value) return;
|
||||
try {
|
||||
const mod = await loadEmbed();
|
||||
const target = parseRoute();
|
||||
handle.value = mod.mount({
|
||||
containerEl: containerEl.value,
|
||||
view: target.view,
|
||||
...(target.slug ? { initialSlug: target.slug } : {}),
|
||||
...(target.username ? { initialUsername: target.username } : {}),
|
||||
apiBaseUrl: WPC_API_URL,
|
||||
tokenStore: createWpcTokenStore(WPC_API_URL),
|
||||
theme: "auto",
|
||||
onNavigate: (next) => {
|
||||
// Mirror the embed's internal nav into the extension router
|
||||
// so the URL bar reflects state + the back button works.
|
||||
if (next.view === "detail" && next.slug) {
|
||||
router.replace(`/community/p/${next.slug}`);
|
||||
} else if (next.view === "profile" && next.username) {
|
||||
router.replace(`/community/u/${next.username}`);
|
||||
} else {
|
||||
router.replace("/community");
|
||||
}
|
||||
},
|
||||
onClose: () => router.replace("/dashboard"),
|
||||
onUnauthenticated: () => {
|
||||
// Device flow comes in a follow-up. For now the embed handles
|
||||
// the anonymous fallback itself; if a once-authenticated user
|
||||
// hits 401, we clear local tokens so the next render is
|
||||
// anonymous rather than looping.
|
||||
createWpcTokenStore(WPC_API_URL).clear();
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof EmbedLoadError) {
|
||||
loadError.value = `Couldn't reach ${WPC_API_URL}. ${err.message}`;
|
||||
} else {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Route changes inside /community/... drive the embed via its handle
|
||||
// rather than remounting — the embed keeps its scroll position, its
|
||||
// fetched list state, etc.
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (handle.value) {
|
||||
handle.value.navigate(parseRoute());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(tryMount);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
handle.value?.unmount();
|
||||
handle.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-community-root">
|
||||
<div
|
||||
v-if="loading || !loadError"
|
||||
ref="containerEl"
|
||||
class="wp-community-host"
|
||||
/>
|
||||
|
||||
<Card v-if="loadError" title="Community is offline" class="wp-community-offline">
|
||||
<p class="wp-community-offline__lead">
|
||||
We couldn't load the community right now. The host may be down
|
||||
or your network is offline.
|
||||
</p>
|
||||
<pre class="wp-community-offline__detail">{{ loadError }}</pre>
|
||||
<div class="wp-community-offline__actions">
|
||||
<button class="wp-btn wp-btn--primary" @click="tryMount">
|
||||
<Icon name="pi-refresh" />Retry
|
||||
</button>
|
||||
<a
|
||||
class="wp-btn wp-btn--ghost"
|
||||
:href="DISCORD_INVITE"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Icon name="pi-discord" />Open Discord invite
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-community-root {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wp-community-host {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.wp-community-offline {
|
||||
margin: var(--wp-space-6);
|
||||
max-width: 640px;
|
||||
}
|
||||
.wp-community-offline__lead {
|
||||
margin: 0 0 var(--wp-space-3);
|
||||
color: var(--wp-text);
|
||||
font-size: var(--wp-text-base);
|
||||
}
|
||||
.wp-community-offline__detail {
|
||||
margin: 0 0 var(--wp-space-4);
|
||||
padding: var(--wp-space-3);
|
||||
font-family: var(--wp-font-mono);
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-text-dim);
|
||||
background: var(--wp-bg-elevated);
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.wp-community-offline__actions {
|
||||
display: flex;
|
||||
gap: var(--wp-space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wp-community-offline__actions .wp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Community — placeholder landing while the full discover / upload /
|
||||
* profile flow bakes.
|
||||
*
|
||||
* Primary CTA: Discord invite. The discover/upload UI ships when
|
||||
* `feat/community-tab` lands; until then, in-flight design feedback +
|
||||
* Q&A happens on Discord.
|
||||
*
|
||||
* Both URLs live in `manager/config/links.ts` — the Discord invite
|
||||
* rotates if the server resets invite settings, and the repo slug
|
||||
* may change when we rename the public repo. Centralized so a swap
|
||||
* is a one-line change.
|
||||
*/
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import { DISCORD_INVITE } from "../config/links";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-page wp-community-wip">
|
||||
<div class="wp-page__header">
|
||||
<div class="wp-page__title-wrap">
|
||||
<h1 class="wp-page__title">Community</h1>
|
||||
<p class="wp-page__subtitle">Share + discover wildcard packs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="Join the conversation">
|
||||
<div class="wp-community-wip__body">
|
||||
<div class="wp-community-wip__brand" aria-hidden="true">
|
||||
<Icon name="pi-discord" :size="32" />
|
||||
</div>
|
||||
<div class="wp-community-wip__copy">
|
||||
<p class="wp-community-wip__lead">
|
||||
The full Community hub is being designed. While it's in flight,
|
||||
jump on Discord for help, feedback, and the work-in-progress feed.
|
||||
</p>
|
||||
<div class="wp-community-wip__actions">
|
||||
<a
|
||||
class="wp-btn wp-btn--primary wp-btn--md"
|
||||
:href="DISCORD_INVITE"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Icon name="pi-discord" />
|
||||
<span>Open Discord invite</span>
|
||||
<Icon name="pi-external-link" />
|
||||
</a>
|
||||
<a
|
||||
class="wp-community-wip__alt"
|
||||
:href="DISCORD_INVITE"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ DISCORD_INVITE }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-community-wip { max-width: 720px; }
|
||||
|
||||
.wp-community-wip__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--wp-space-6);
|
||||
padding: var(--wp-space-2) 0;
|
||||
}
|
||||
.wp-community-wip__brand {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--wp-accent) 16%, transparent);
|
||||
color: var(--wp-accent);
|
||||
}
|
||||
.wp-community-wip__copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-community-wip__lead {
|
||||
margin: 0 0 var(--wp-space-4);
|
||||
font-size: var(--wp-text-base);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-community-wip__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wp-space-2);
|
||||
align-items: flex-start;
|
||||
}
|
||||
/* Anchor styled as primary button — reuses the same .wp-btn family
|
||||
* styles applied by Button.vue. text-decoration:none kills the
|
||||
* default anchor underline; everything else inherits from .wp-btn. */
|
||||
.wp-community-wip__actions .wp-btn {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
}
|
||||
.wp-community-wip__alt {
|
||||
font-family: var(--wp-font-mono);
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-text-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
.wp-community-wip__alt:hover {
|
||||
color: var(--wp-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
@@ -51,6 +52,9 @@ const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const { resolveReturnTo } = useReturnTo();
|
||||
const moduleStore = useModuleStore();
|
||||
const currentRow = computed(() =>
|
||||
props.id ? moduleStore.catalog.find((m) => m.id === props.id) ?? null : null,
|
||||
);
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
const recent = useRecentStore();
|
||||
@@ -66,35 +70,11 @@ const cascadeRefs = computed(() => {
|
||||
|
||||
async function onEntityDeleteClick(): Promise<void> {
|
||||
if (!props.id) return;
|
||||
if (cascadeRefs.value.length === 0) {
|
||||
const result = await cascadeApply.apply({
|
||||
kind: "constraint", id: props.id, action: "delete",
|
||||
});
|
||||
if (result.ok) {
|
||||
moduleStore.remove(props.id);
|
||||
const undoId = result.undo_entry_id;
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `"${name.value}" deleted`,
|
||||
life: 5000,
|
||||
action: {
|
||||
label: "Undo",
|
||||
run: async () => {
|
||||
const undoResult = await cascadeApply.undo(undoId);
|
||||
if (!undoResult.ok) {
|
||||
toast.push({ severity: "error", summary: "Undo failed", detail: undoResult.error, life: 4000 });
|
||||
} else {
|
||||
toast.push({ severity: "info", summary: `"${name.value}" restored`, life: 3000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
router.push(resolveReturnTo("/constraints"));
|
||||
} else {
|
||||
toast.push({ severity: "error", summary: "Delete failed", detail: (result as { ok: false; error: string }).error, life: 4000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Always open the cascade dialog — earlier the no-refs branch went
|
||||
// straight to cascadeApply.apply without any confirm prompt, which
|
||||
// surprised users editing freshly-installed entities. The dialog's
|
||||
// own dry-run handles 0 refs cleanly (empty affected list, Delete
|
||||
// still front-and-center).
|
||||
cascadeDialogOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -699,6 +679,12 @@ defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions, applyRest
|
||||
<span v-if="cascadeRefs.length > 0" class="wp-editor-used-by">
|
||||
used by <PillCountBadge :count="cascadeRefs.length" />
|
||||
</span>
|
||||
<CommunityRowActions
|
||||
v-if="currentRow"
|
||||
:row="currentRow"
|
||||
kind="module"
|
||||
labeled
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isEdit" #footer-left>
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
|
||||
import { useBulkActions } from "../composables/useBulkActions";
|
||||
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
|
||||
import ModuleListView from "../components/ModuleListView.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import ValidityIcon from "../components/ValidityIcon.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
@@ -345,6 +346,7 @@ function formatFactor(f: number): string {
|
||||
<template #actions="{ row }">
|
||||
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
|
||||
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
|
||||
<CommunityRowActions :row="row" kind="module" />
|
||||
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -490,7 +490,12 @@ onMounted(refresh);
|
||||
|
||||
<!-- Recent / Favorites -->
|
||||
<Card v-if="!showGettingStarted" padding>
|
||||
<template #actions>
|
||||
<!-- Tabs sit at the top-LEFT of the card body now, not in the
|
||||
Card's right-aligned #actions slot. The View-all link stays
|
||||
on the right via wp-spacer so the row balances. Earlier
|
||||
layout pushed both to the right which left the entire
|
||||
left half empty. -->
|
||||
<div class="wp-recent__head">
|
||||
<div class="wp-tabs wp-recent__tabs">
|
||||
<button
|
||||
type="button"
|
||||
@@ -519,13 +524,14 @@ onMounted(refresh);
|
||||
>
|
||||
<Icon name="pi-star-fill" /> Favorites
|
||||
</button>
|
||||
<a
|
||||
class="dashboard__view-all"
|
||||
href="#"
|
||||
@click.prevent="router.push('/all')"
|
||||
>View all <Icon name="pi-arrow-right" :size="ICON_SM" /></a>
|
||||
</div>
|
||||
</template>
|
||||
<span class="wp-spacer" />
|
||||
<a
|
||||
class="dashboard__view-all"
|
||||
href="#"
|
||||
@click.prevent="router.push('/all')"
|
||||
>View all <Icon name="pi-arrow-right" :size="ICON_SM" /></a>
|
||||
</div>
|
||||
<div v-if="visibleItems.length" class="wp-list wp-recent">
|
||||
<div
|
||||
v-for="row in visibleItems"
|
||||
@@ -712,6 +718,13 @@ onMounted(refresh);
|
||||
.dashboard__start-body strong { font-weight: 600; }
|
||||
.dashboard__start-body p { margin: 2px 0 0; font-size: var(--wp-text-sm); color: var(--wp-text-muted); }
|
||||
|
||||
.wp-recent__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
margin-bottom: var(--wp-space-4);
|
||||
}
|
||||
.wp-recent__head .wp-spacer { flex: 1; }
|
||||
.wp-recent__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,6 +14,7 @@ import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
@@ -44,6 +45,9 @@ import type {
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const currentRow = computed(() =>
|
||||
props.id ? moduleStore.catalog.find((m) => m.id === props.id) ?? null : null,
|
||||
);
|
||||
const { resolveReturnTo } = useReturnTo();
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
@@ -60,35 +64,7 @@ const cascadeRefs = computed(() => {
|
||||
|
||||
async function onEntityDeleteClick(): Promise<void> {
|
||||
if (!props.id) return;
|
||||
if (cascadeRefs.value.length === 0) {
|
||||
const result = await cascadeApply.apply({
|
||||
kind: "derivation", id: props.id, action: "delete",
|
||||
});
|
||||
if (result.ok) {
|
||||
moduleStore.remove(props.id);
|
||||
const undoId = result.undo_entry_id;
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `"${name.value}" deleted`,
|
||||
life: 5000,
|
||||
action: {
|
||||
label: "Undo",
|
||||
run: async () => {
|
||||
const undoResult = await cascadeApply.undo(undoId);
|
||||
if (!undoResult.ok) {
|
||||
toast.push({ severity: "error", summary: "Undo failed", detail: undoResult.error, life: 4000 });
|
||||
} else {
|
||||
toast.push({ severity: "info", summary: `"${name.value}" restored`, life: 3000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
router.push(resolveReturnTo("/derivations"));
|
||||
} else {
|
||||
toast.push({ severity: "error", summary: "Delete failed", detail: (result as { ok: false; error: string }).error, life: 4000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Always confirm — see WildcardEditor for the rationale.
|
||||
cascadeDialogOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -427,6 +403,12 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
<span v-if="cascadeRefs.length > 0" class="wp-editor-used-by">
|
||||
used by <PillCountBadge :count="cascadeRefs.length" />
|
||||
</span>
|
||||
<CommunityRowActions
|
||||
v-if="currentRow"
|
||||
:row="currentRow"
|
||||
kind="module"
|
||||
labeled
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isEdit" #footer-left>
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
|
||||
import { useBulkActions } from "../composables/useBulkActions";
|
||||
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
|
||||
import ModuleListView from "../components/ModuleListView.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import ValidityIcon from "../components/ValidityIcon.vue";
|
||||
import { validateModule } from "../utils/validateModule";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
@@ -303,6 +304,7 @@ function actView(a: DerivationAction | undefined): { verb: string; target: strin
|
||||
<template #actions="{ row }">
|
||||
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
|
||||
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
|
||||
<CommunityRowActions :row="row" kind="module" />
|
||||
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
@@ -40,6 +41,9 @@ interface NamedValue { id: string; name: string; value: string; }
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const moduleStore = useModuleStore();
|
||||
const currentRow = computed(() =>
|
||||
props.id ? moduleStore.catalog.find((m) => m.id === props.id) ?? null : null,
|
||||
);
|
||||
const categoryStore = useCategoryStore();
|
||||
const toast = useToast();
|
||||
const recent = useRecentStore();
|
||||
@@ -56,35 +60,7 @@ const cascadeRefs = computed(() => {
|
||||
|
||||
async function onEntityDeleteClick(): Promise<void> {
|
||||
if (!props.id) return;
|
||||
if (cascadeRefs.value.length === 0) {
|
||||
const result = await cascadeApply.apply({
|
||||
kind: "fixed_values", id: props.id, action: "delete",
|
||||
});
|
||||
if (result.ok) {
|
||||
moduleStore.remove(props.id);
|
||||
const undoId = result.undo_entry_id;
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `"${name.value}" deleted`,
|
||||
life: 5000,
|
||||
action: {
|
||||
label: "Undo",
|
||||
run: async () => {
|
||||
const undoResult = await cascadeApply.undo(undoId);
|
||||
if (!undoResult.ok) {
|
||||
toast.push({ severity: "error", summary: "Undo failed", detail: undoResult.error, life: 4000 });
|
||||
} else {
|
||||
toast.push({ severity: "info", summary: `"${name.value}" restored`, life: 3000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
router.push(resolveReturnTo("/fixed-values"));
|
||||
} else {
|
||||
toast.push({ severity: "error", summary: "Delete failed", detail: (result as { ok: false; error: string }).error, life: 4000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Always confirm — see WildcardEditor for the rationale.
|
||||
cascadeDialogOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -364,6 +340,12 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
<span v-if="cascadeRefs.length > 0" class="wp-editor-used-by">
|
||||
used by <PillCountBadge :count="cascadeRefs.length" />
|
||||
</span>
|
||||
<CommunityRowActions
|
||||
v-if="currentRow"
|
||||
:row="currentRow"
|
||||
kind="module"
|
||||
labeled
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isEdit" #footer-left>
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
|
||||
import { useBulkActions } from "../composables/useBulkActions";
|
||||
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
|
||||
import ModuleListView from "../components/ModuleListView.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import ValidityIcon from "../components/ValidityIcon.vue";
|
||||
import { validateModule } from "../utils/validateModule";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
@@ -279,6 +280,7 @@ function topValues(row: ModuleRow): NamedValue[] { return values(row).slice(0, 4
|
||||
<template #actions="{ row }">
|
||||
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
|
||||
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
|
||||
<CommunityRowActions :row="row" kind="module" />
|
||||
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 404 surface — landing for any URL that doesn't match a known route.
|
||||
* The router previously did `redirect: "/dashboard"` for the
|
||||
* pathMatch catch-all; that hard-bounced typos straight to the home
|
||||
* tab and made the user think nothing was wrong. Now they land here.
|
||||
*/
|
||||
import { RouterLink, useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-not-found">
|
||||
<div class="wp-not-found__card">
|
||||
<div class="wp-not-found__code">404</div>
|
||||
<h1 class="wp-not-found__title">Page not found</h1>
|
||||
<p class="wp-not-found__copy">
|
||||
Nothing lives at <code>{{ route.fullPath }}</code>.
|
||||
It may have been moved, deleted, or never existed.
|
||||
</p>
|
||||
<div class="wp-not-found__actions">
|
||||
<RouterLink to="/dashboard" class="wp-btn wp-btn--primary">
|
||||
<i class="pi pi-home" />Back to dashboard
|
||||
</RouterLink>
|
||||
<RouterLink to="/all" class="wp-btn wp-btn--ghost">
|
||||
<i class="pi pi-th-large" />Browse library
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-not-found {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.wp-not-found__card {
|
||||
max-width: 520px;
|
||||
text-align: center;
|
||||
padding: 48px 36px;
|
||||
background: var(--wp-bg-2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.wp-not-found__code {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -2px;
|
||||
color: var(--wp-accent-text, #d97757);
|
||||
line-height: 1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.wp-not-found__title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 10px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-not-found__copy {
|
||||
color: var(--wp-text-muted);
|
||||
font-size: 13.5px;
|
||||
margin: 0 0 22px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wp-not-found__copy code {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
font-size: 12.5px;
|
||||
background: var(--wp-bg-3);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
color: var(--wp-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.wp-not-found__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wp-not-found__actions .wp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ import EditorFrame from "../components/EditorFrame.vue";
|
||||
import IdentityCard from "../components/IdentityCard.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
import Chip from "../components/ui/Chip.vue";
|
||||
@@ -61,6 +62,18 @@ const recent = useRecentStore();
|
||||
const { resolveReturnTo } = useReturnTo();
|
||||
const cascade = useCascadeStore();
|
||||
const cascadeApply = useCascadeApply();
|
||||
|
||||
/**
|
||||
* Live library row for the entity being edited. Resolves through
|
||||
* `moduleStore.catalog` so per-row footer actions (publish to
|
||||
* community, copy payload) get the same engine-row shape every
|
||||
* other code path uses. Null on create or when the row hasn't yet
|
||||
* hydrated.
|
||||
*/
|
||||
const currentRow = computed(() => {
|
||||
if (!props.id) return null;
|
||||
return moduleStore.catalog.find((m) => m.id === props.id) ?? null;
|
||||
});
|
||||
const resolveWarnings = useResolveWarnings();
|
||||
|
||||
const cascadeDialogOpen = ref(false);
|
||||
@@ -71,6 +84,23 @@ const cascadeDialogProps = ref<{
|
||||
extra?: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
|
||||
async function onEntityDeleteClick(): Promise<void> {
|
||||
if (!props.id) return;
|
||||
// Always go through the cascade dialog — even when there are no
|
||||
// inbound refs. Earlier the no-refs branch deleted directly without
|
||||
// any prompt; that bit users who clicked the editor's Delete pill
|
||||
// by accident on a freshly-installed entity. The dialog runs its
|
||||
// own dry-run, so 0 refs just shows an empty "affected" list with
|
||||
// the Delete button still front-and-center — adds a 100ms scan but
|
||||
// guarantees confirm-before-destroy on every editor.
|
||||
cascadeDialogProps.value = {
|
||||
kind: "wildcard",
|
||||
id: props.id,
|
||||
action: "delete",
|
||||
};
|
||||
cascadeDialogOpen.value = true;
|
||||
}
|
||||
|
||||
// Rename dialog for sub-category pills
|
||||
const subcatRenameOpen = ref(false);
|
||||
const subcatRenameTarget = ref<string>("");
|
||||
@@ -386,6 +416,29 @@ function onCascadeDialogConfirmed(result: { undo_entry_id: string; affected_coun
|
||||
const undoId = result.undo_entry_id;
|
||||
const count = result.affected_count;
|
||||
|
||||
if (dialogProps.kind === "wildcard") {
|
||||
// Whole-entity delete confirmed from the cascade dialog.
|
||||
moduleStore.remove(dialogProps.id);
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `"${name.value}" deleted`,
|
||||
detail: count > 0 ? `Updated ${count} reference${count === 1 ? "" : "s"}` : undefined,
|
||||
life: 5000,
|
||||
action: {
|
||||
label: "Undo",
|
||||
run: async () => {
|
||||
const undoResult = await cascadeApply.undo(undoId);
|
||||
if (!undoResult.ok) {
|
||||
toast.push({ severity: "error", summary: "Undo failed", detail: undoResult.error, life: 4000 });
|
||||
} else {
|
||||
toast.push({ severity: "info", summary: `"${name.value}" restored`, life: 3000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
router.push(resolveReturnTo("/wildcards"));
|
||||
return;
|
||||
}
|
||||
if (dialogProps.kind === "option") {
|
||||
const rowIdx = dialogProps.extra?._row_idx as number | undefined;
|
||||
const optionId = dialogProps.id;
|
||||
@@ -772,6 +825,18 @@ defineExpose({ historyEntries, applyRestore, options });
|
||||
@cancel="cancel"
|
||||
@restore="applyRestore"
|
||||
>
|
||||
<template v-if="isEdit && currentRow" #header-extra>
|
||||
<CommunityRowActions :row="currentRow" kind="module" labeled />
|
||||
</template>
|
||||
<template v-if="isEdit" #footer-left>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="pi-trash"
|
||||
class="wp-btn--danger"
|
||||
data-test="wc-delete-btn"
|
||||
@click="onEntityDeleteClick"
|
||||
>Delete</Button>
|
||||
</template>
|
||||
<template #draft-banner>
|
||||
<DraftBanner
|
||||
:has-draft="draft.hasDraft.value"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
|
||||
import { useBulkActions } from "../composables/useBulkActions";
|
||||
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
|
||||
import ModuleListView from "../components/ModuleListView.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import RichTextPreview from "../components/RichTextPreview.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
@@ -469,6 +470,7 @@ function issuesFor(row: ModuleRow) {
|
||||
<template #actions="{ row }">
|
||||
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
|
||||
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
|
||||
<CommunityRowActions :row="row" kind="module" />
|
||||
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Strict v1 validator for bundle root.
|
||||
*
|
||||
* Bundles carry the canonical engine-row shape with `children[]` —
|
||||
* each child is itself a v1 module row. Discriminated union on
|
||||
* children's `type` field; single-version-per-root invariant.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { wildcardV1, fixedValuesV1, combineV1, derivationV1, constraintV1 } from "../module/v1";
|
||||
|
||||
export const bundleV1Child = z.discriminatedUnion("type", [
|
||||
wildcardV1,
|
||||
fixedValuesV1,
|
||||
combineV1,
|
||||
derivationV1,
|
||||
constraintV1,
|
||||
]);
|
||||
|
||||
export const bundleV1 = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
color: z.string().nullable(),
|
||||
category_id: z.string().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
is_favorite: z.boolean(),
|
||||
children: z.array(bundleV1Child),
|
||||
}).strict();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "bd-001abc",
|
||||
"name": "starter-bundle",
|
||||
"description": "",
|
||||
"color": null,
|
||||
"category_id": null,
|
||||
"tags": [],
|
||||
"is_favorite": false,
|
||||
"children": [
|
||||
{
|
||||
"id": "wc-001abc",
|
||||
"type": "wildcard",
|
||||
"name": "starter-mood",
|
||||
"description": "",
|
||||
"category_id": null,
|
||||
"tags": [],
|
||||
"is_favorite": false,
|
||||
"payload": {
|
||||
"var_binding": "mood",
|
||||
"sub_categories": [],
|
||||
"options": [{ "id": "opt-001a", "value": "serene", "weight": 1, "sub_category": null }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "cb-001abc",
|
||||
"type": "combine",
|
||||
"name": "starter-prompt",
|
||||
"description": "",
|
||||
"category_id": null,
|
||||
"tags": [],
|
||||
"is_favorite": false,
|
||||
"payload": {
|
||||
"template": "$mood, $seed",
|
||||
"output_var": "prompt",
|
||||
"input_vars": ["mood", "seed"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "cn-001abc",
|
||||
"type": "constraint",
|
||||
"name": "starter-constraint",
|
||||
"description": "",
|
||||
"category_id": null,
|
||||
"tags": [],
|
||||
"is_favorite": false,
|
||||
"payload": {
|
||||
"source_wildcard_id": "wc-001abc",
|
||||
"target_wildcard_id": "wc-002def",
|
||||
"exceptions": [],
|
||||
"matrix": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "dr-001abc",
|
||||
"type": "derivation",
|
||||
"name": "starter-derive",
|
||||
"description": "",
|
||||
"category_id": null,
|
||||
"tags": [],
|
||||
"is_favorite": false,
|
||||
"payload": {
|
||||
"rules": [
|
||||
{
|
||||
"id": "rule-001",
|
||||
"branches": [{ "if": { "var": "mood", "eq": "serene" }, "then": "calm" }],
|
||||
"else": { "value": "default" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "fv-001abc",
|
||||
"type": "fixed_values",
|
||||
"name": "starter-seed",
|
||||
"description": "",
|
||||
"category_id": null,
|
||||
"tags": [],
|
||||
"is_favorite": false,
|
||||
"payload": {
|
||||
"entries": [
|
||||
{ "variable_name": "seed", "value": "42" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "wc-001abc",
|
||||
"type": "wildcard",
|
||||
"name": "starter-mood",
|
||||
"description": "",
|
||||
"category_id": null,
|
||||
"tags": [],
|
||||
"is_favorite": false,
|
||||
"payload": {
|
||||
"var_binding": "mood",
|
||||
"sub_categories": [],
|
||||
"options": [
|
||||
{ "id": "opt-001a", "value": "serene", "weight": 1, "sub_category": null },
|
||||
{ "id": "opt-001b", "value": "fierce", "weight": 1, "sub_category": null }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Validator registry: lookup a Zod schema for (kind, subtype, version, mode).
|
||||
*
|
||||
* Strict mode: schema as written (.strict() — unknown keys reject).
|
||||
* Tolerant mode: same schema but with .strip() applied — unknown keys
|
||||
* silently dropped. Tolerant mode is used only on the install-from-
|
||||
* newer-payload path; see spec §3.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { wildcardV1, fixedValuesV1, combineV1, derivationV1, constraintV1 } from "./module/v1";
|
||||
import { bundleV1 } from "./bundle/v1";
|
||||
|
||||
export type ModuleSubtype =
|
||||
| "wildcard"
|
||||
| "fixed_values"
|
||||
| "combine"
|
||||
| "derivation"
|
||||
| "constraint";
|
||||
export type ValidatorKind = "module" | "bundle";
|
||||
export type ValidatorMode = "strict" | "tolerant";
|
||||
|
||||
interface ModuleSpec {
|
||||
kind: "module";
|
||||
subtype: ModuleSubtype;
|
||||
version: number;
|
||||
mode: ValidatorMode;
|
||||
}
|
||||
|
||||
interface BundleSpec {
|
||||
kind: "bundle";
|
||||
version: number;
|
||||
mode: ValidatorMode;
|
||||
}
|
||||
|
||||
export type ValidatorSpec = ModuleSpec | BundleSpec;
|
||||
|
||||
const REGISTRY: Record<string, z.ZodTypeAny> = {
|
||||
"module:wildcard:1": wildcardV1,
|
||||
"module:fixed_values:1": fixedValuesV1,
|
||||
"module:combine:1": combineV1,
|
||||
"module:derivation:1": derivationV1,
|
||||
"module:constraint:1": constraintV1,
|
||||
"bundle::1": bundleV1,
|
||||
};
|
||||
|
||||
export function getValidator(spec: ValidatorSpec): z.ZodTypeAny {
|
||||
const key = spec.kind === "module"
|
||||
? `module:${spec.subtype}:${spec.version}`
|
||||
: `bundle::${spec.version}`;
|
||||
const base = REGISTRY[key];
|
||||
if (!base) {
|
||||
throw new Error(`no validator for ${key}`);
|
||||
}
|
||||
// Tolerant mode rewraps the strict object schema with .strip() (Zod's
|
||||
// default — unknown keys silently dropped instead of rejecting).
|
||||
if (spec.mode === "tolerant" && base instanceof z.ZodObject) {
|
||||
return base.strip();
|
||||
}
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Strict v1 validators per module subtype.
|
||||
*
|
||||
* See community-web spec
|
||||
* docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
|
||||
* §2 #1 — sister is the authority on structural validity. Each subtype
|
||||
* gets its own .strict() Zod schema; the tolerant variant (.strip()) is
|
||||
* built at registry lookup time in ../index.ts.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
const moduleRowBase = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
category_id: z.string().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
is_favorite: z.boolean(),
|
||||
});
|
||||
|
||||
export const wildcardV1 = moduleRowBase.extend({
|
||||
type: z.literal("wildcard"),
|
||||
payload: z.object({
|
||||
var_binding: z.string(),
|
||||
sub_categories: z.array(z.string()),
|
||||
options: z.array(z.object({
|
||||
id: z.string(),
|
||||
value: z.string(),
|
||||
weight: z.number(),
|
||||
sub_category: z.string().nullable(),
|
||||
})),
|
||||
}),
|
||||
}).strict();
|
||||
|
||||
export const fixedValuesV1 = moduleRowBase.extend({
|
||||
type: z.literal("fixed_values"),
|
||||
payload: z.object({
|
||||
entries: z.array(z.object({
|
||||
variable_name: z.string(),
|
||||
value: z.string(),
|
||||
})),
|
||||
}),
|
||||
}).strict();
|
||||
|
||||
export const combineV1 = moduleRowBase.extend({
|
||||
type: z.literal("combine"),
|
||||
payload: z.object({
|
||||
template: z.string(),
|
||||
output_var: z.string(),
|
||||
input_vars: z.array(z.string()),
|
||||
}),
|
||||
}).strict();
|
||||
|
||||
export const derivationV1 = moduleRowBase.extend({
|
||||
type: z.literal("derivation"),
|
||||
payload: z.object({
|
||||
rules: z.array(z.object({
|
||||
id: z.string(),
|
||||
branches: z.array(z.unknown()),
|
||||
else: z.unknown(),
|
||||
})),
|
||||
}),
|
||||
}).strict();
|
||||
|
||||
export const constraintV1 = moduleRowBase.extend({
|
||||
type: z.literal("constraint"),
|
||||
payload: z.object({
|
||||
source_wildcard_id: z.string(),
|
||||
target_wildcard_id: z.string(),
|
||||
exceptions: z.array(z.unknown()),
|
||||
matrix: z.record(z.unknown()),
|
||||
}),
|
||||
}).strict();
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Lazy-read routine — the ONE migration runtime per spec §4 Flow 5.
|
||||
|
||||
Covers all six branches:
|
||||
1. No-op (row at CURRENT, original=NULL)
|
||||
2. NULL-drop optimization (row at CURRENT, original=CURRENT → drop original)
|
||||
3. Forward chain (row < CURRENT, no original)
|
||||
4. Recovery exact catch-up (original exists, original.sv == CURRENT) — THE §4 BUG FIX
|
||||
5. Still-drifted re-project (original.sv > CURRENT)
|
||||
6. Deferred strict-validate failure → tolerant_drift_status='broken'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.db.lazy_migrate import lazy_migrate_row
|
||||
from engine.db.migrations import migrate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
migrate(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _insert_module(conn, *, sv=1, original=None, drift="none", payload=None):
|
||||
conn.execute(
|
||||
"INSERT INTO modules(id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, version, created_at, updated_at, schema_version, "
|
||||
"original_payload_json, tolerant_drift_status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"m1", "wildcard", "n", "", None, "[]", 0,
|
||||
json.dumps(payload or {"id": "m1", "type": "wildcard"}),
|
||||
1, "now", "now", sv,
|
||||
json.dumps(original) if original else None,
|
||||
drift,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_lazy_no_op(db: sqlite3.Connection) -> None:
|
||||
"""Row at CURRENT, no original — return as-is, no write."""
|
||||
_insert_module(db, sv=1)
|
||||
db.commit()
|
||||
before = db.execute("SELECT payload FROM modules WHERE id='m1'").fetchone()["payload"]
|
||||
lazy_migrate_row(db, kind="module", row_id="m1", current_version=1, migrators={}, validators={})
|
||||
after = db.execute("SELECT payload FROM modules WHERE id='m1'").fetchone()["payload"]
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_lazy_null_drop(db: sqlite3.Connection) -> None:
|
||||
"""Row at CURRENT, original.sv == CURRENT → NULL-drop optimization fires."""
|
||||
_insert_module(db, sv=1, original={"id":"m1","type":"wildcard","schema_version":1})
|
||||
db.commit()
|
||||
lazy_migrate_row(db, kind="module", row_id="m1", current_version=1, migrators={}, validators={})
|
||||
row = db.execute("SELECT original_payload_json FROM modules WHERE id='m1'").fetchone()
|
||||
assert row["original_payload_json"] is None
|
||||
|
||||
|
||||
def test_lazy_forward_chain(db: sqlite3.Connection) -> None:
|
||||
"""Row < CURRENT, no original → forward chain v1->v2."""
|
||||
_insert_module(db, sv=1)
|
||||
db.commit()
|
||||
|
||||
def migrate_v1_to_v2(payload, _ctx):
|
||||
return {**payload, "added_in_v2": True}
|
||||
|
||||
lazy_migrate_row(
|
||||
db, kind="module", row_id="m1", current_version=2,
|
||||
migrators={("module", 1): migrate_v1_to_v2},
|
||||
validators={},
|
||||
)
|
||||
row = db.execute("SELECT payload, schema_version FROM modules WHERE id='m1'").fetchone()
|
||||
payload = json.loads(row["payload"])
|
||||
assert payload.get("added_in_v2") is True
|
||||
assert row["schema_version"] == 2
|
||||
|
||||
|
||||
def test_lazy_recovery_exact_catch_up(db: sqlite3.Connection) -> None:
|
||||
"""THE BUG FIX FROM SPEC §4: recovery on exact catch-up.
|
||||
|
||||
Row at v1 (tolerantly installed), original at v2.
|
||||
Extension catches up: CURRENT == 2 == original.schema_version.
|
||||
Routine MUST promote original into payload + NULL original.
|
||||
Without the 'base IS original' write-gate fix, this silently no-op'd
|
||||
in the old broken implementation.
|
||||
"""
|
||||
_insert_module(
|
||||
db, sv=1,
|
||||
original={
|
||||
"id": "m1", "type": "wildcard",
|
||||
"payload": {"var_binding": "x", "options": []},
|
||||
"nsfw": True,
|
||||
"schema_version": 2,
|
||||
},
|
||||
drift="pending",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
def v2_strict(_payload):
|
||||
return True
|
||||
|
||||
lazy_migrate_row(
|
||||
db, kind="module", row_id="m1", current_version=2,
|
||||
migrators={},
|
||||
validators={("module", 2, "wildcard"): v2_strict},
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT payload, schema_version, original_payload_json, tolerant_drift_status "
|
||||
"FROM modules WHERE id='m1'"
|
||||
).fetchone()
|
||||
payload = json.loads(row["payload"])
|
||||
assert payload.get("nsfw") is True
|
||||
assert row["schema_version"] == 2
|
||||
assert row["original_payload_json"] is None
|
||||
assert row["tolerant_drift_status"] == "none"
|
||||
|
||||
|
||||
def test_lazy_still_drifted_reproject(db: sqlite3.Connection) -> None:
|
||||
"""Row at v1, original at v3, CURRENT==2 — re-project tolerantly to v2."""
|
||||
_insert_module(
|
||||
db, sv=1,
|
||||
original={
|
||||
"id": "m1", "type": "wildcard",
|
||||
"payload": {"var_binding": "x", "options": []},
|
||||
"nsfw": True, "tier": "epic",
|
||||
"schema_version": 3,
|
||||
},
|
||||
drift="pending",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
def tolerant_strip_to_v2(payload):
|
||||
out = dict(payload)
|
||||
out.pop("tier", None)
|
||||
return out
|
||||
|
||||
lazy_migrate_row(
|
||||
db, kind="module", row_id="m1", current_version=2,
|
||||
migrators={},
|
||||
validators={("module", 2, "wildcard"): lambda _p: True},
|
||||
tolerant_strip={("module", 2, "wildcard"): tolerant_strip_to_v2},
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT payload, schema_version, original_payload_json, tolerant_drift_status "
|
||||
"FROM modules WHERE id='m1'"
|
||||
).fetchone()
|
||||
payload = json.loads(row["payload"])
|
||||
assert payload.get("nsfw") is True
|
||||
assert "tier" not in payload
|
||||
assert row["schema_version"] == 2
|
||||
assert row["original_payload_json"] is not None
|
||||
assert row["tolerant_drift_status"] == "pending"
|
||||
|
||||
|
||||
def test_lazy_broken_on_deferred_validation_failure(db: sqlite3.Connection) -> None:
|
||||
"""Row at v1, original at v2 (catch-up), deferred strict-validate FAILS.
|
||||
Routine marks row tolerant_drift_status='broken', does NOT touch payload."""
|
||||
_insert_module(db, sv=1, original={"id":"m1","schema_version":2}, drift="pending")
|
||||
db.commit()
|
||||
before_payload = db.execute("SELECT payload FROM modules WHERE id='m1'").fetchone()["payload"]
|
||||
|
||||
def v2_strict_FAIL(_payload):
|
||||
return False
|
||||
|
||||
lazy_migrate_row(
|
||||
db, kind="module", row_id="m1", current_version=2,
|
||||
migrators={},
|
||||
validators={("module", 2, "wildcard"): v2_strict_FAIL},
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT payload, tolerant_drift_status FROM modules WHERE id='m1'"
|
||||
).fetchone()
|
||||
assert row["payload"] == before_payload
|
||||
assert row["tolerant_drift_status"] == "broken"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Migration 014: ALTERs modules + bundles with schema_version, original_payload_json,
|
||||
tolerant_drift_status, schema_migrated_at; backfills schema_version=1 on existing rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.db.migrations import migrate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
migrate(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def test_014_adds_module_columns(db: sqlite3.Connection) -> None:
|
||||
cols = {row["name"] for row in db.execute("PRAGMA table_info(modules)")}
|
||||
assert "schema_version" in cols
|
||||
assert "original_payload_json" in cols
|
||||
assert "tolerant_drift_status" in cols
|
||||
assert "schema_migrated_at" in cols
|
||||
|
||||
|
||||
def test_014_adds_bundle_columns(db: sqlite3.Connection) -> None:
|
||||
cols = {row["name"] for row in db.execute("PRAGMA table_info(bundles)")}
|
||||
assert "schema_version" in cols
|
||||
assert "original_payload_json" in cols
|
||||
assert "tolerant_drift_status" in cols
|
||||
assert "schema_migrated_at" in cols
|
||||
|
||||
|
||||
def test_014_backfills_schema_version_one_on_existing_rows(
|
||||
db: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""Pre-existing module rows get schema_version=1 (the implicit baseline)."""
|
||||
db.execute(
|
||||
"INSERT INTO modules(id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, version, created_at, updated_at) "
|
||||
"VALUES('m1','wildcard','m','',NULL,'[]',0,'{}',1,'2026-01-01','2026-01-01')"
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute(
|
||||
"SELECT schema_version, tolerant_drift_status FROM modules WHERE id='m1'"
|
||||
).fetchone()
|
||||
assert row["schema_version"] == 1
|
||||
assert row["tolerant_drift_status"] == "none"
|
||||
|
||||
|
||||
def test_014_idempotent_rerun(db: sqlite3.Connection) -> None:
|
||||
"""Re-running migrate() doesn't reapply 014 (migrations table guards it)."""
|
||||
before = db.execute("SELECT MAX(version) FROM migrations").fetchone()[0]
|
||||
migrate(db)
|
||||
after = db.execute("SELECT MAX(version) FROM migrations").fetchone()[0]
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_014_bulk_runner_iterates_lazy_routine(db: sqlite3.Connection) -> None:
|
||||
"""When migration 014 runs (or any payload-bump migration), it walks
|
||||
every stale row + invokes lazy_migrate_row. The bulk-at-boot path is
|
||||
eager iteration of the lazy routine — NOT a parallel forward-chain
|
||||
implementation (spec §4 Flow 4)."""
|
||||
# Insert a row we can mark stale (db fixture is function-scoped, fresh DB)
|
||||
db.execute(
|
||||
"INSERT INTO modules(id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, version, created_at, updated_at) "
|
||||
"VALUES('m1','wildcard','m','',NULL,'[]',0,'{}',1,'2026-01-01','2026-01-01')"
|
||||
)
|
||||
db.execute("UPDATE modules SET schema_version = 0 WHERE id = 'm1'")
|
||||
db.commit()
|
||||
|
||||
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
||||
failed = run_bulk_lazy(
|
||||
db, current_version=1, migrators={}, validators={}, tolerant_strip={},
|
||||
)
|
||||
# Failure path runs lazy_migrate_row on each stale row; with no migrators
|
||||
# registered + sv=0→1 needed, this fails to migrate. The row stays at 0.
|
||||
# The test just confirms the iteration happens (failed > 0 indicates
|
||||
# the routine was called for each stale row).
|
||||
assert failed >= 1
|
||||
@@ -30,8 +30,8 @@ def test_migrate_records_version(tmp_path):
|
||||
conn = get_connection(tmp_path / "v2.db")
|
||||
migrate(conn)
|
||||
# Keep this assertion in sync with the highest-numbered migration in
|
||||
# ``engine/db/migrations_sql``. (012_templates.sql is current head.)
|
||||
assert current_version(conn) == 12
|
||||
# ``engine/db/migrations_sql``. (014_payload_schema_version.py is current head.)
|
||||
assert current_version(conn) == 14
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ def test_migrate_creates_bundles_table_with_expected_columns(tmp_path):
|
||||
"id", "name", "description", "color", "category_id",
|
||||
"tags", "is_favorite", "children", "payload_hash",
|
||||
"version", "created_at", "updated_at",
|
||||
# Migration 013 — community install origin.
|
||||
"community_post_slug", "community_version_number",
|
||||
# Migration 014 — payload schema_version + verbatim-local mirror.
|
||||
"schema_version", "original_payload_json",
|
||||
"tolerant_drift_status", "schema_migrated_at",
|
||||
}
|
||||
conn.close()
|
||||
|
||||
@@ -116,6 +121,11 @@ def test_modules_table_has_expected_columns(tmp_path):
|
||||
"id", "type", "name", "description", "category_id",
|
||||
"tags", "is_favorite", "payload", "snapshot_fingerprint",
|
||||
"version", "created_at", "updated_at",
|
||||
# Migration 013 — community install origin.
|
||||
"community_post_slug", "community_version_number",
|
||||
# Migration 014 — payload schema_version + verbatim-local mirror.
|
||||
"schema_version", "original_payload_json",
|
||||
"tolerant_drift_status", "schema_migrated_at",
|
||||
}
|
||||
conn.close()
|
||||
|
||||
@@ -267,7 +277,7 @@ def test_004_is_idempotent(tmp_path):
|
||||
conn = get_connection(tmp_path / "i.db")
|
||||
migrate(conn)
|
||||
migrate(conn) # second call should be a no-op
|
||||
assert current_version(conn) == 12
|
||||
assert current_version(conn) == 14
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user