Implementation & Migration Plan¶
Complete plan for migrating from the legacy graph model to the catalog content/discovery architecture.
Table of Contents¶
- Current State Inventory
- Migration Strategy
- Phase 0: Endpoint Contract Review
- Phase 1: Database Migration
- Phase 2: Shared Modules
- Phase 3: Topics Module + Edge Function
- Phase 4: Tags Module + Edge Function
- Phase 5: Insights Module + Edge Function
- Phase 6: Extend Journey Module
- Phase 7: Content Module Migration
- Phase 8: Explore + Search Migration
- Phase 9: Legacy Deprecation + Cleanup
- Phase 10: SDK Regeneration
- HTTP Test Files
- Reusable Components
Current State Inventory¶
Existing Edge Functions (20 total)¶
| Function | Path | Module | Tables | Status After Migration |
|---|---|---|---|---|
domains |
/domains/* |
src/modules/domains/ |
domains, domain_stats |
Deprecate → replaced by topics |
trails |
via graph | src/modules/trails/ |
trails, trail_stats |
Deprecate → replaced by journey-catalog |
concepts |
/concepts/* |
src/modules/concepts/ |
concepts, concept_stats |
Deprecate → replaced by journey-catalog chapters |
sparks-meta |
/sparks-meta/* |
src/modules/sparks/ |
sparks |
Deprecate → replaced by insights |
beacons |
/beacons/* |
src/modules/beacons/ |
beacons, beacon_stats, spark_beacons |
Deprecate → replaced by tags |
graph |
/graph/* |
src/routes/graph/ |
All legacy tables | Deprecate → broken into individual functions |
breadcrumb |
/breadcrumb/* |
src/routes/graph/breadcrumb.ts |
All legacy tables | Deprecate → breadcrumbs not needed in catalog model |
links |
/links/* |
src/routes/graph/links.ts |
spark_links |
Evaluate → may evolve to insight prerequisites |
learning-path |
/path/* |
src/services/graph.ts |
sparks, spark_links |
Evolve → adapt to insight prerequisites |
recommendations |
/recommendations/* |
src/modules/recommendations/ |
Various | Evolve → adapt to catalog entities |
journey-catalog |
/journeys/* |
src/modules/journeys/ |
catalog_* tables |
Extend → add list, topics, tags |
journeys |
/journeys/* |
src/routes/me/journeys.ts |
journeys (user progress) |
Keep → user journey progress |
content |
/content/* |
src/modules/content/ |
sparks, spark_versions |
Evolve → point to catalog_insights |
explore |
/explore/* |
src/modules/explore/ |
Various legacy | Evolve → use catalog + discovery tables |
search |
/search/* |
src/services/search.ts |
Various legacy | Evolve → search catalog entities |
home |
/home/* |
src/modules/home/ |
domain_stats, etc. |
Evolve → use topics + catalog |
user |
/me/* |
src/routes/me/ |
User tables | Keep — no changes |
auth |
/auth/* |
src/routes/auth/ |
Auth tables | Keep — no changes |
snapshots |
/snapshots/* |
src/routes/snapshots.ts |
snapshots |
Keep — no changes |
metadata |
/metadata/* |
src/routes/metadata.ts |
Static | Keep — no changes |
health |
/health |
Built into createApp |
None | Keep — no changes |
Existing Shared Infrastructure (reuse as-is)¶
| Component | Location | Purpose |
|---|---|---|
createApp() |
functions/_shared/create-app.ts |
App factory with CORS, logging, service auth, health |
cacheMiddleware() |
functions/_shared/cache.ts |
Cache-Control headers (STATIC/MODERATE/DYNAMIC) |
optionalAuthMiddleware() |
functions/_shared/middleware.ts |
JWT extraction for optional auth |
getSupabaseClient() |
functions/_shared/middleware.ts |
Supabase client from request context |
getEnvBindingsSafe() |
functions/_shared/env-validation.ts |
Environment variable validation |
serviceAuthMiddleware() |
src/middleware/service-auth.ts |
Service-to-service auth |
errorResponse() |
src/routes/utils.ts |
Standardised error formatting |
NotFoundError |
src/routes/utils.ts |
404 exception class |
PaginationQuerySchema |
src/schemas/openapi.ts |
Reusable pagination query params |
PaginationMetaSchema |
src/schemas/openapi.ts |
Reusable pagination response meta |
buildPaginationMeta() |
src/modules/shared/ |
Pagination meta builder |
createPaginatedSchema() |
src/schemas/openapi.ts |
Factory for paginated response schemas |
Migration Strategy¶
Guiding Principles¶
- All new endpoints are GET-only — optimised for CDN caching, stale-while-revalidate, and independent scalability.
- Each edge function is independently deployable — no shared runtime state between functions.
- Shared business logic lives in
src/modules/andsrc/modules/shared/— reusable across any function that imports it. - Legacy and new functions coexist during migration — no big bang cutover.
Legacy functions get
Deprecationheaders before removal. - Database migration is safe and locked — uses explicit transactions, advisory locks, and zero-downtime DDL (CREATE TABLE, CREATE INDEX CONCURRENTLY where possible).
- Every new endpoint contract is reviewed before implementation — Phase 0
produces
.httpfiles and schema definitions that stakeholders review.
Migration Order¶
Phase 0: Endpoint contract review (schemas + .http files only)
│
Phase 1: Database migration (tables, indexes, RLS, seed data)
│
Phase 2: Shared modules (reusable pagination, discovery helpers)
│
Phase 3─5: New modules + edge functions (topics, tags, insights) ←── can run in parallel
│
Phase 6: Extend journey-catalog (list, topics, tags sub-routes)
│
Phase 7: Content module migration (sparks → insights)
│
Phase 8: Explore + Search migration (use catalog tables)
│
Phase 9: Legacy deprecation + cleanup
│
Phase 10: SDK regeneration
Phase 0: Endpoint Contract Review¶
Goal: Define every new endpoint's request/response contract BEFORE writing any service logic. Review via
.httpfiles with example payloads and Zod schema definitions.
0.1 — Topics Endpoint Contracts¶
Create schema definitions and HTTP test files for all 4 topics endpoints.
Files to create:
src/modules/topics/schema.ts— Zod schemas only (no service/routes yet)http/topics.http— REST client test file with expected request/response shapes
Schemas to define:
TopicListQuerySchema
- page: number (default 1)
- per_page: number (default 20, max 100)
TopicSlugParamSchema
- slug: string (min 1)
TopicListItemSchema
- id: uuid
- slug: string
- name: string
- description: string | null
- icon: string | null
- color: string | null
- heroImageUrl: string | null
- displayOrder: number
- journeyCount: number ← computed from topic_journeys COUNT
- insightCount: number ← computed from topic_insights COUNT
TopicDetailSchema (extends TopicListItemSchema)
- learningOutcomes: string[]
TopicJourneysQuerySchema
- page: number (default 1)
- per_page: number (default 20, max 100)
- difficulty: enum (optional filter)
- featured_only: boolean (default false)
TopicJourneyItemSchema
- id: uuid
- slug: string
- title: string
- subtitle: string | null
- difficulty: enum
- totalDurationMinutes: number
- isFeatured: boolean
- displayOrder: number
TopicInsightsQuerySchema
- page: number (default 1)
- per_page: number (default 20, max 100)
- difficulty: enum (optional filter)
- featured_only: boolean (default false)
TopicInsightItemSchema
- id: uuid
- slug: string
- title: string
- description: string | null
- difficulty: enum | null
- durationMinutes: number | null
- isFeatured: boolean
- displayOrder: number
HTTP test file (http/topics.http):
### List all published topics
GET {{baseUrl}}/topics/topics
Accept: application/json
### Expected response shape:
# {
# "data": [
# {
# "id": "uuid",
# "slug": "ai-fundamentals",
# "name": "AI Fundamentals",
# "description": "...",
# "icon": "brain",
# "color": "#6366F1",
# "heroImageUrl": null,
# "displayOrder": 0,
# "journeyCount": 3,
# "insightCount": 12
# }
# ],
# "meta": { "page": 1, "per_page": 20, "total": 5, ... }
# }
###
### Get topic details
GET {{baseUrl}}/topics/topics/ai-fundamentals
Accept: application/json
###
### Get journeys for a topic
GET {{baseUrl}}/topics/topics/ai-fundamentals/journeys?per_page=10
Accept: application/json
###
### Get journeys for a topic (featured only)
GET {{baseUrl}}/topics/topics/ai-fundamentals/journeys?featured_only=true
Accept: application/json
###
### Get insights for a topic
GET {{baseUrl}}/topics/topics/ai-fundamentals/insights?per_page=10
Accept: application/json
###
### Get insights for a topic (filter by difficulty)
GET {{baseUrl}}/topics/topics/ai-fundamentals/insights?difficulty=beginner
Accept: application/json
0.2 — Tags Endpoint Contracts¶
Files to create:
src/modules/tags/schema.tshttp/tags.http
Schemas to define:
TagListQuerySchema
- page: number (default 1)
- per_page: number (default 20, max 100)
- category: enum (optional filter: technology | concept | application | level | format | general)
- search: string (optional — ILIKE on name)
TagSlugParamSchema
- slug: string (min 1)
TagListItemSchema
- id: uuid
- slug: string
- name: string
- description: string | null
- category: string
- journeyCount: number ← computed from tag_journeys COUNT
- insightCount: number ← computed from tag_insights COUNT
TagDetailSchema (same as TagListItemSchema)
TagJourneysQuerySchema
- page: number (default 1)
- per_page: number (default 20, max 100)
- difficulty: enum (optional filter)
TagJourneyItemSchema
- id: uuid
- slug: string
- title: string
- subtitle: string | null
- difficulty: enum
- totalDurationMinutes: number
TagInsightsQuerySchema
- page: number (default 1)
- per_page: number (default 20, max 100)
- difficulty: enum (optional filter)
TagInsightItemSchema
- id: uuid
- slug: string
- title: string
- description: string | null
- difficulty: enum | null
- durationMinutes: number | null
HTTP test file (http/tags.http):
### List all tags
GET {{baseUrl}}/tags/tags
Accept: application/json
###
### List tags filtered by category
GET {{baseUrl}}/tags/tags?category=technology
Accept: application/json
###
### Search tags by name
GET {{baseUrl}}/tags/tags?search=python
Accept: application/json
###
### Get tag details
GET {{baseUrl}}/tags/tags/machine-learning
Accept: application/json
###
### Get journeys with this tag
GET {{baseUrl}}/tags/tags/machine-learning/journeys
Accept: application/json
###
### Get insights with this tag
GET {{baseUrl}}/tags/tags/machine-learning/insights
Accept: application/json
0.3 — Insights Endpoint Contracts¶
Files to create:
src/modules/insights/schema.tshttp/insights.http
Schemas to define:
InsightListQuerySchema
- page: number (default 1)
- per_page: number (default 20, max 100)
- difficulty: enum (optional filter)
- topic: string (optional — filter by topic slug via topic_insights)
- tag: string (optional — filter by tag slug via tag_insights)
InsightSlugParamSchema
- slug: string (min 1)
InsightListItemSchema
- id: uuid
- slug: string
- title: string
- description: string | null
- difficulty: enum | null
- durationMinutes: number | null
- wordCount: number | null
- isPremium: boolean
InsightDetailSchema (extends InsightListItemSchema)
- topicCount: number ← computed from topic_insights
- tagCount: number ← computed from tag_insights
- chapterCount: number ← computed from catalog_chapter_insights (how many chapters contain this)
InsightTopicsResponseSchema
- topics: array of { id, slug, name, icon, color }
InsightTagsResponseSchema
- tags: array of { id, slug, name, category }
HTTP test file (http/insights.http):
### List all published insights
GET {{baseUrl}}/insights/insights
Accept: application/json
###
### List insights filtered by difficulty
GET {{baseUrl}}/insights/insights?difficulty=beginner
Accept: application/json
###
### List insights filtered by topic
GET {{baseUrl}}/insights/insights?topic=ai-fundamentals
Accept: application/json
###
### List insights filtered by tag
GET {{baseUrl}}/insights/insights?tag=python
Accept: application/json
###
### Get insight metadata
GET {{baseUrl}}/insights/insights/what-is-an-ai-agent
Accept: application/json
###
### Get topics for an insight
GET {{baseUrl}}/insights/insights/what-is-an-ai-agent/topics
Accept: application/json
###
### Get tags for an insight
GET {{baseUrl}}/insights/insights/what-is-an-ai-agent/tags
Accept: application/json
0.4 — Journey Extension Contracts¶
Files to extend:
src/modules/journeys/schema.ts— Add list and reverse-lookup schemashttp/journeys.http— Extend with new endpoints
New schemas to define:
JourneyListQuerySchema
- page: number (default 1)
- per_page: number (default 20, max 100)
- difficulty: enum (optional filter)
- topic: string (optional — filter by topic slug via topic_journeys)
- tag: string (optional — filter by tag slug via tag_journeys)
- sort: enum (optional — recent | popular | alphabetical)
JourneyListItemSchema
- id: uuid
- slug: string
- title: string
- subtitle: string | null
- difficulty: enum
- totalDurationMinutes: number
- isPremium: boolean
- chapterCount: number
- insightCount: number
JourneyTopicsResponseSchema
- topics: array of { id, slug, name, icon, color }
JourneyTagsResponseSchema
- tags: array of { id, slug, name, category }
HTTP test additions (http/journeys.http):
### List all published journeys
GET {{baseUrl}}/journey-catalog/journeys
Accept: application/json
###
### List journeys filtered by difficulty
GET {{baseUrl}}/journey-catalog/journeys?difficulty=intermediate
Accept: application/json
###
### List journeys filtered by topic
GET {{baseUrl}}/journey-catalog/journeys?topic=ai-fundamentals
Accept: application/json
###
### List journeys filtered by tag
GET {{baseUrl}}/journey-catalog/journeys?tag=python
Accept: application/json
###
### Get topics for a journey (reverse lookup)
GET {{baseUrl}}/journey-catalog/journeys/agentic-ai-mastery/topics
Accept: application/json
###
### Get tags for a journey (reverse lookup)
GET {{baseUrl}}/journey-catalog/journeys/agentic-ai-mastery/tags
Accept: application/json
0.5 — Review Checklist¶
Before proceeding to Phase 1, review each contract against these criteria:
- Every endpoint is GET-only
- Every response follows
{ data, meta }(lists) or{ data }(details) convention - Every list endpoint supports
pageandper_pagepagination - Every filterable field uses query parameters (not path segments)
- All slugs follow
^[a-z0-9]+(-[a-z0-9]+)*$format - All response field names use camelCase (not snake_case)
- No endpoint requires authentication (all public or optional auth)
- Cache policy is assigned (STATIC for rarely-changing, MODERATE for content, DYNAMIC for filtered)
- Error responses follow
{ error: { code, message } }convention - No endpoint returns content body (that's the
contentfunction's job) - Reverse-lookup responses are simple arrays (not paginated — small cardinality)
Phase 1: Database Migration¶
Goal: Create all discovery tables with proper locking, indexes, RLS, and seed data.
1.1 — Migration File¶
File: supabase/migrations/YYYYMMDD_add_discovery_layer.sql
The migration uses an explicit transaction with an advisory lock to ensure no concurrent migrations can run and all changes are atomic.
-- ============================================
-- DISCOVERY LAYER MIGRATION
-- ============================================
-- Adds Topics and Tags as discovery mechanisms for catalog content.
-- Uses advisory lock to prevent concurrent migration execution.
--
-- Tables created:
-- topics, tags (discovery entities)
-- topic_journeys, topic_insights (topic associations)
-- tag_journeys, tag_insights (tag associations)
--
-- All operations are CREATE (additive only) — no existing tables modified.
-- ============================================
BEGIN;
-- Advisory lock to prevent concurrent migrations
-- Uses a fixed lock ID derived from the migration name
SELECT pg_advisory_xact_lock(hashtext('add_discovery_layer'));
-- ============================================
-- 1. TOPICS TABLE
-- ============================================
CREATE TABLE IF NOT EXISTS topics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
icon VARCHAR(50),
color VARCHAR(7),
hero_image_url TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
learning_outcomes JSONB DEFAULT '[]'::jsonb,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT topics_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
CONSTRAINT topics_color_format CHECK (color IS NULL OR color ~ '^#[0-9A-Fa-f]{6}$')
);
CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug);
CREATE INDEX IF NOT EXISTS idx_topics_status ON topics(status) WHERE status = 'published';
CREATE INDEX IF NOT EXISTS idx_topics_display_order ON topics(display_order)
WHERE status = 'published';
COMMENT ON TABLE topics IS
'Curated, user-facing discovery categories for browsing content by subject area';
-- ============================================
-- 2. TAGS TABLE
-- ============================================
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(200),
category VARCHAR(50) NOT NULL DEFAULT 'general'
CHECK (category IN (
'technology', 'concept', 'application',
'level', 'format', 'general'
)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT tags_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$')
);
CREATE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug);
CREATE INDEX IF NOT EXISTS idx_tags_category ON tags(category);
CREATE INDEX IF NOT EXISTS idx_tags_name_trgm ON tags USING GIN(name gin_trgm_ops);
COMMENT ON TABLE tags IS
'System-facing classification keywords for programmatic discovery and faceted search';
-- ============================================
-- 3. TOPIC_JOURNEYS JUNCTION
-- ============================================
CREATE TABLE IF NOT EXISTS topic_journeys (
topic_id UUID NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
journey_id UUID NOT NULL REFERENCES catalog_journeys(id) ON DELETE CASCADE,
display_order INTEGER NOT NULL DEFAULT 0,
is_featured BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (topic_id, journey_id)
);
CREATE INDEX IF NOT EXISTS idx_topic_journeys_journey
ON topic_journeys(journey_id);
CREATE INDEX IF NOT EXISTS idx_topic_journeys_order
ON topic_journeys(topic_id, display_order);
COMMENT ON TABLE topic_journeys IS
'Many-to-many: which journeys appear under which topics';
-- ============================================
-- 4. TOPIC_INSIGHTS JUNCTION
-- ============================================
CREATE TABLE IF NOT EXISTS topic_insights (
topic_id UUID NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
insight_id UUID NOT NULL REFERENCES catalog_insights(id) ON DELETE CASCADE,
display_order INTEGER NOT NULL DEFAULT 0,
is_featured BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (topic_id, insight_id)
);
CREATE INDEX IF NOT EXISTS idx_topic_insights_insight
ON topic_insights(insight_id);
CREATE INDEX IF NOT EXISTS idx_topic_insights_order
ON topic_insights(topic_id, display_order);
COMMENT ON TABLE topic_insights IS
'Many-to-many: which standalone insights appear under which topics';
-- ============================================
-- 5. TAG_JOURNEYS JUNCTION
-- ============================================
CREATE TABLE IF NOT EXISTS tag_journeys (
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
journey_id UUID NOT NULL REFERENCES catalog_journeys(id) ON DELETE CASCADE,
PRIMARY KEY (tag_id, journey_id)
);
CREATE INDEX IF NOT EXISTS idx_tag_journeys_journey
ON tag_journeys(journey_id);
COMMENT ON TABLE tag_journeys IS
'Many-to-many: which tags classify which journeys';
-- ============================================
-- 6. TAG_INSIGHTS JUNCTION
-- ============================================
CREATE TABLE IF NOT EXISTS tag_insights (
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
insight_id UUID NOT NULL REFERENCES catalog_insights(id) ON DELETE CASCADE,
PRIMARY KEY (tag_id, insight_id)
);
CREATE INDEX IF NOT EXISTS idx_tag_insights_insight
ON tag_insights(insight_id);
COMMENT ON TABLE tag_insights IS
'Many-to-many: which tags classify which insights';
-- ============================================
-- 7. ROW LEVEL SECURITY
-- ============================================
ALTER TABLE topics ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view published topics"
ON topics FOR SELECT USING (status = 'published');
CREATE POLICY "Service role has full access to topics"
ON topics FOR ALL TO service_role USING (true) WITH CHECK (true);
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view tags"
ON tags FOR SELECT USING (true);
CREATE POLICY "Service role has full access to tags"
ON tags FOR ALL TO service_role USING (true) WITH CHECK (true);
ALTER TABLE topic_journeys ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view topic_journeys"
ON topic_journeys FOR SELECT USING (true);
CREATE POLICY "Service role manages topic_journeys"
ON topic_journeys FOR ALL TO service_role
USING (true) WITH CHECK (true);
ALTER TABLE topic_insights ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view topic_insights"
ON topic_insights FOR SELECT USING (true);
CREATE POLICY "Service role manages topic_insights"
ON topic_insights FOR ALL TO service_role
USING (true) WITH CHECK (true);
ALTER TABLE tag_journeys ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view tag_journeys"
ON tag_journeys FOR SELECT USING (true);
CREATE POLICY "Service role manages tag_journeys"
ON tag_journeys FOR ALL TO service_role
USING (true) WITH CHECK (true);
ALTER TABLE tag_insights ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view tag_insights"
ON tag_insights FOR SELECT USING (true);
CREATE POLICY "Service role manages tag_insights"
ON tag_insights FOR ALL TO service_role
USING (true) WITH CHECK (true);
-- ============================================
-- 8. UPDATE TRIGGERS
-- ============================================
CREATE TRIGGER update_topics_updated_at
BEFORE UPDATE ON topics
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_tags_updated_at
BEFORE UPDATE ON tags
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================
-- 9. TOPIC STATS VIEW
-- ============================================
-- Provides pre-aggregated counts for topic listing.
-- Uses LEFT JOIN to include topics with zero associations.
CREATE OR REPLACE VIEW topic_stats AS
SELECT
t.id,
t.slug,
t.name,
t.description,
t.icon,
t.color,
t.hero_image_url,
t.display_order,
t.learning_outcomes,
t.status,
t.created_at,
t.updated_at,
COUNT(DISTINCT tj.journey_id) AS journey_count,
COUNT(DISTINCT ti.insight_id) AS insight_count
FROM topics t
LEFT JOIN topic_journeys tj ON t.id = tj.topic_id
LEFT JOIN topic_insights ti ON t.id = ti.topic_id
GROUP BY t.id;
-- ============================================
-- 10. TAG STATS VIEW
-- ============================================
CREATE OR REPLACE VIEW tag_stats AS
SELECT
tg.id,
tg.slug,
tg.name,
tg.description,
tg.category,
tg.created_at,
tg.updated_at,
COUNT(DISTINCT tj.journey_id) AS journey_count,
COUNT(DISTINCT ti.insight_id) AS insight_count
FROM tags tg
LEFT JOIN tag_journeys tj ON tg.id = tj.tag_id
LEFT JOIN tag_insights ti ON tg.id = ti.tag_id
GROUP BY tg.id;
COMMIT;
1.2 — Data Seed Migration¶
File: supabase/migrations/YYYYMMDD_seed_discovery_data.sql
Seeds initial topics from existing domains data and tags from existing
beacons data. This runs in a separate migration so the schema migration is
clean.
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('seed_discovery_data'));
-- ============================================
-- SEED TOPICS FROM DOMAINS
-- ============================================
-- Copies published domains into topics, preserving slug/name/icon/color.
-- Only runs if topics table is empty (idempotent).
INSERT INTO topics (slug, name, description, icon, color, display_order, learning_outcomes, status)
SELECT
d.slug,
d.name,
d.description,
d.icon,
d.color,
ROW_NUMBER() OVER (ORDER BY ds.spark_count DESC) - 1 AS display_order,
COALESCE(d.learning_outcomes, '[]'::jsonb),
'published'
FROM domains d
JOIN domain_stats ds ON d.id = ds.id
WHERE d.is_published = true
AND d.snapshot_id = (SELECT snapshot_id FROM current_snapshot WHERE id = 1)
AND NOT EXISTS (SELECT 1 FROM topics LIMIT 1)
ORDER BY ds.spark_count DESC;
-- ============================================
-- SEED TAGS FROM BEACONS
-- ============================================
INSERT INTO tags (slug, name, description, category)
SELECT
b.slug,
b.name,
b.description,
b.category::text
FROM beacons b
WHERE b.snapshot_id = (SELECT snapshot_id FROM current_snapshot WHERE id = 1)
AND NOT EXISTS (SELECT 1 FROM tags LIMIT 1);
COMMIT;
1.3 — Database Lock Strategy¶
The migration uses these safety mechanisms:
| Mechanism | Purpose |
|---|---|
BEGIN / COMMIT |
All DDL is atomic — either all tables are created or none |
pg_advisory_xact_lock() |
Prevents concurrent migration execution |
CREATE TABLE IF NOT EXISTS |
Idempotent — safe to re-run |
CREATE INDEX IF NOT EXISTS |
Idempotent index creation |
No ALTER TABLE on existing tables |
Zero risk to existing data |
No DROP statements |
Nothing is removed during creation |
| Separate seed migration | Schema changes are isolated from data operations |
What is NOT locked:
- Existing tables are not touched (no
ALTER TABLE, noDROP COLUMN) - No existing indexes are modified
- No existing RLS policies are changed
- No existing views are modified
Phase 2: Shared Modules¶
Goal: Extract reusable patterns into shared modules before building entity modules.
2.1 — Discovery Helpers¶
File: src/modules/shared/discovery.ts
Reusable functions for querying junction tables that follow the same pattern across all discovery endpoints.
/**
* Shared helpers for discovery context queries.
*
* These functions standardise how junction tables are queried
* for both forward lookups (topic → journeys) and reverse lookups
* (journey → topics). All queries are GET-optimised read-only
* operations designed for CDN caching.
*/
// getJourneysForDiscoveryEntity(supabase, junctionTable, entityIdColumn, entityId, filters)
// getInsightsForDiscoveryEntity(supabase, junctionTable, entityIdColumn, entityId, filters)
// getTopicsForEntity(supabase, entityTable, entityIdColumn, entityId)
// getTagsForEntity(supabase, entityTable, entityIdColumn, entityId)
2.2 — Reusable Response Schemas¶
File: src/modules/shared/schemas.ts
/**
* Shared schemas for discovery and reverse-lookup responses.
* Used across topics, tags, insights, and journey modules.
*/
// TopicRefSchema — { id, slug, name, icon, color }
// TagRefSchema — { id, slug, name, category }
// JourneyRefSchema — { id, slug, title, subtitle, difficulty, totalDurationMinutes }
// InsightRefSchema — { id, slug, title, description, difficulty, durationMinutes }
Phase 3: Topics Module + Edge Function¶
Goal: Build the complete topics module following established patterns.
3.1 — Module Files¶
| File | Purpose | Key Exports |
|---|---|---|
src/modules/topics/schema.ts |
Zod schemas (from Phase 0) | All Topic*Schema |
src/modules/topics/types.ts |
Service interface | ITopicService |
src/modules/topics/service.ts |
Business logic | createTopicService() |
src/modules/topics/routes.ts |
OpenAPI route definitions | Default Hono app |
src/modules/topics/index.ts |
Module barrel export | Re-exports routes + service |
3.2 — Service Methods¶
interface ITopicService {
/** GET /topics — List published topics with journey/insight counts */
list(query: TopicListQuery): Promise<PaginatedResponse<TopicListItem>>;
/** GET /topics/:slug — Get topic details */
getBySlug(slug: string): Promise<TopicDetail | null>;
/** GET /topics/:slug/journeys — Journeys curated under this topic */
getJourneys(
slug: string,
query: TopicJourneysQuery,
): Promise<PaginatedResponse<TopicJourneyItem>>;
/** GET /topics/:slug/insights — Insights curated under this topic */
getInsights(
slug: string,
query: TopicInsightsQuery,
): Promise<PaginatedResponse<TopicInsightItem>>;
}
3.3 — Edge Function¶
File: functions/topics/index.ts
import { createApp } from "@shared/create-app.ts";
import { getEnvBindingsSafe } from "@shared/env-validation.ts";
import {
CACHE_MODERATE,
CACHE_STATIC,
cacheMiddleware,
} from "@shared/cache.ts";
import topicsRoutes from "@domains/graph/topics/index.ts";
const app = createApp({
functionName: "topics",
includeHealthCheck: true,
});
// Topic listing and details rarely change
app.use("/topics", cacheMiddleware(CACHE_STATIC));
app.use("/topics/:slug", cacheMiddleware(CACHE_STATIC));
// Sub-resources change more frequently
app.use("/topics/:slug/journeys", cacheMiddleware(CACHE_MODERATE));
app.use("/topics/:slug/insights", cacheMiddleware(CACHE_MODERATE));
app.route("/topics", topicsRoutes);
const bindings = getEnvBindingsSafe();
Deno.serve((req) => app.fetch(req, bindings));
3.4 — Route Definitions (4 GET endpoints)¶
| Route | operationId | Cache | Query Params |
|---|---|---|---|
GET / |
v1_list_topics |
STATIC | page, per_page |
GET /:slug |
v1_get_topic |
STATIC | — |
GET /:slug/journeys |
v1_get_topic_journeys |
MODERATE | page, per_page, difficulty, featured_only |
GET /:slug/insights |
v1_get_topic_insights |
MODERATE | page, per_page, difficulty, featured_only |
Phase 4: Tags Module + Edge Function¶
Goal: Build the complete tags module. Same pattern as topics.
4.1 — Module Files¶
Same structure as topics:
src/modules/tags/{schema,types,service,routes,index}.ts
4.2 — Service Methods¶
interface ITagService {
/** GET /tags — List tags with usage counts */
list(query: TagListQuery): Promise<PaginatedResponse<TagListItem>>;
/** GET /tags/:slug — Get tag details */
getBySlug(slug: string): Promise<TagDetail | null>;
/** GET /tags/:slug/journeys — Journeys classified with this tag */
getJourneys(
slug: string,
query: TagJourneysQuery,
): Promise<PaginatedResponse<TagJourneyItem>>;
/** GET /tags/:slug/insights — Insights classified with this tag */
getInsights(
slug: string,
query: TagInsightsQuery,
): Promise<PaginatedResponse<TagInsightItem>>;
}
4.3 — Edge Function¶
File: functions/tags/index.ts
Same pattern as topics. Uses CACHE_STATIC for list/detail, CACHE_MODERATE
for sub-resources.
4.4 — Route Definitions (4 GET endpoints)¶
| Route | operationId | Cache | Query Params |
|---|---|---|---|
GET / |
v1_list_tags |
STATIC | page, per_page, category, search |
GET /:slug |
v1_get_tag |
STATIC | — |
GET /:slug/journeys |
v1_get_tag_journeys |
MODERATE | page, per_page, difficulty |
GET /:slug/insights |
v1_get_tag_insights |
MODERATE | page, per_page, difficulty |
Phase 5: Insights Module + Edge Function¶
Goal: Build standalone insight metadata endpoints (distinct from content delivery).
5.1 — Module Files¶
src/modules/insights/{schema,types,service,routes,index}.ts
5.2 — Service Methods¶
interface IInsightService {
/** GET /insights — List published insights with filters */
list(query: InsightListQuery): Promise<PaginatedResponse<InsightListItem>>;
/** GET /insights/:slug — Get insight metadata */
getBySlug(slug: string): Promise<InsightDetail | null>;
/** GET /insights/:slug/topics — Topics this insight belongs to */
getTopics(slug: string): Promise<TopicRef[]>;
/** GET /insights/:slug/tags — Tags on this insight */
getTags(slug: string): Promise<TagRef[]>;
}
5.3 — Edge Function¶
File: functions/insights/index.ts
Uses CACHE_MODERATE for all endpoints since insight metadata changes more
frequently than topics/tags.
5.4 — Route Definitions (4 GET endpoints)¶
| Route | operationId | Cache | Query Params |
|---|---|---|---|
GET / |
v1_list_insights |
MODERATE | page, per_page, difficulty, topic, tag |
GET /:slug |
v1_get_insight |
MODERATE | — |
GET /:slug/topics |
v1_get_insight_topics |
MODERATE | — |
GET /:slug/tags |
v1_get_insight_tags |
MODERATE | — |
Design note on reverse lookups: The /topics and /tags sub-routes return
simple arrays (not paginated) because the cardinality is small — an insight
typically belongs to 1-5 topics and has 3-10 tags.
Phase 6: Extend Journey Module¶
Goal: Add list endpoint and reverse-lookup sub-routes to the existing journey-catalog function.
6.1 — New Service Methods¶
Add to the existing JourneyService:
// Add to IJourneyService
list(query: JourneyListQuery): Promise<PaginatedResponse<JourneyListItem>>;
getTopics(journeyId: string): Promise<TopicRef[]>;
getTags(journeyId: string): Promise<TagRef[]>;
6.2 — New Routes (3 GET endpoints)¶
Add to src/modules/journeys/routes.ts:
| Route | operationId | Cache | Query Params |
|---|---|---|---|
GET / |
v1_list_journeys |
MODERATE | page, per_page, difficulty, topic, tag, sort |
GET /:id/topics |
v1_get_journey_topics |
MODERATE | — |
GET /:id/tags |
v1_get_journey_tags |
MODERATE | — |
6.3 — Edge Function Update¶
Update functions/journey-catalog/index.ts to mount the extended routes. The
existing GET /:id and GET /:id/chapters routes remain unchanged.
Phase 7: Content Module Migration¶
Goal: Migrate the content delivery module from
sparkstocatalog_insights.
7.1 — Content Module Changes¶
The existing content module (src/modules/content/) currently queries the
sparks table. It needs to:
- Add new routes at
/insights/{slug}that querycatalog_insights - Keep existing
/sparks/{slug}routes functional (backward compatibility) - Map the
catalog_insightscontent fields to the same response format
7.2 — Edge Function¶
The existing functions/content/index.ts mounts content routes. Add the
insights path:
// Existing: app.route("/sparks", contentRoutes);
// New: app.route("/insights", insightContentRoutes);
7.3 — Service Changes¶
The content service needs a parallel path for catalog_insights. Since catalog_insights may need content fields (content_md, content_html) added, evaluate whether to:
- Add content columns to
catalog_insightsdirectly - Create a separate
catalog_insight_contenttable - Bridge through existing
sparkstable via a mapping table
Decision deferred to implementation — depends on content ingestion pipeline.
Phase 8: Explore + Search Migration¶
Goal: Update explore and search to use catalog entities + discovery tables.
8.1 — Explore Module Changes¶
The explore module currently queries domain_stats, trail_stats,
concept_stats, sparks. Migration:
| Current Query | New Query |
|---|---|
domain_stats |
topic_stats |
trail_stats |
catalog_journeys + counts |
concept_stats |
Not applicable (chapters not discoverable) |
sparks |
catalog_insights |
Facet additions:
- Add
topicfilter (viatopic_journeys/topic_insights) - Add
tagfilter (viatag_journeys/tag_insights) - Remove
domainfilter (replaced bytopic) - Remove
trailfilter (usetopicinstead)
8.2 — Search Module Changes¶
The search service currently uses search_vector on legacy tables. Migration:
- Add full-text search columns to
catalog_journeysandcatalog_insights - Update search service to query catalog tables
- Add topic/tag filtering to search results
- Keep legacy search functional during transition
New search targets:
catalog_journeys.search_vector(title, description)catalog_insights.search_vector(title, description)topics.name(for topic suggestions in autocomplete)
8.3 — Database Changes for Search¶
-- Add search vectors to catalog tables
ALTER TABLE catalog_journeys ADD COLUMN IF NOT EXISTS
search_vector tsvector;
ALTER TABLE catalog_insights ADD COLUMN IF NOT EXISTS
search_vector tsvector;
-- Create GIN indexes
CREATE INDEX IF NOT EXISTS idx_catalog_journeys_search
ON catalog_journeys USING GIN(search_vector);
CREATE INDEX IF NOT EXISTS idx_catalog_insights_search
ON catalog_insights USING GIN(search_vector);
-- Auto-update triggers
CREATE OR REPLACE FUNCTION update_catalog_journey_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER catalog_journeys_search_update
BEFORE INSERT OR UPDATE OF title, description
ON catalog_journeys
FOR EACH ROW EXECUTE FUNCTION update_catalog_journey_search_vector();
Phase 9: Legacy Deprecation + Cleanup¶
Goal: Mark legacy endpoints as deprecated, then remove after client migration.
9.1 — Deprecation Headers¶
Add to all legacy edge functions:
// In each legacy function (domains, trails, concepts, sparks-meta, beacons, graph)
app.use("/*", async (c, next) => {
await next();
c.header("Deprecation", "true");
c.header("Sunset", "2026-06-01"); // 3 months notice
c.header("Link", '</topics>; rel="successor-version"');
});
9.2 — Legacy Function → Replacement Mapping¶
| Legacy Function | Replacement | Migration Path |
|---|---|---|
domains |
topics |
Update SDK clients to use /topics |
trails → list |
journey-catalog → GET /journeys |
Update SDK |
trails → detail |
journey-catalog → GET /journeys/:id |
Update SDK |
concepts → list |
journey-catalog → GET /journeys/:id/chapters |
Chapters via journey |
sparks-meta → list |
insights → GET /insights |
Update SDK |
sparks-meta → detail |
insights → GET /insights/:slug |
Update SDK |
beacons |
tags |
Update SDK clients to use /tags |
graph |
Individual functions | Already decomposed |
breadcrumb |
Not needed | Breadcrumbs computed client-side |
links |
Future insight prerequisites | Evolve separately |
9.3 — Cleanup (after sunset date)¶
- Remove legacy edge function directories
- Remove legacy module directories (
src/modules/legacy-*) - Remove legacy route files
- Drop legacy database views (
domain_stats,trail_stats,concept_stats,beacon_stats) - Evaluate whether to drop legacy tables or keep for historical reference
Do NOT drop legacy tables in the same migration as the cleanup. Keep them for at least one more release cycle in case rollback is needed.
Phase 10: SDK Regeneration¶
Goal: Regenerate the OpenAPI spec and TypeScript SDK with new endpoints.
10.1 — Steps¶
- Run
deno task openapi:offlineto regeneratemcp/openapi.json - Verify all new endpoints appear with correct operationIds
- Run
deno task sdk:buildto regenerate SDK - Verify SDK exports new endpoint classes:
Topics,Tags,Insights - Verify SDK preserves existing endpoint classes with deprecation markers
- Update
sdk/SDK_REFERENCE.mdwith new endpoint documentation
10.2 — SDK Client Usage (target state)¶
import { createClient } from "@musingly-ai/core";
const client = createClient({ baseUrl: "https://api.musingly.ai" });
// Discovery
const { data: topics } = await client.topics.listTopics();
const { data: journeys } = await client.topics.getTopicJourneys(
"ai-fundamentals",
);
// Content browsing
const { data: insight } = await client.insights.getInsight(
"what-is-an-ai-agent",
);
const { data: tags } = await client.insights.getInsightTags(
"what-is-an-ai-agent",
);
// Journey deep-dive
const { data: chapters } = await client.journeys.getJourneyChapters(
"agentic-ai-mastery",
);
HTTP Test Files¶
Complete set of HTTP test files for all new and modified endpoints:
| File | Endpoints Tested | Phase |
|---|---|---|
http/topics.http |
4 GET endpoints | Phase 0 (contract) → Phase 3 (validation) |
http/tags.http |
4 GET endpoints | Phase 0 (contract) → Phase 4 (validation) |
http/insights.http |
4 GET endpoints | Phase 0 (contract) → Phase 5 (validation) |
http/journeys.http (extend) |
3 new GET endpoints | Phase 0 (contract) → Phase 6 (validation) |
Reusable Components¶
Shared Between All New Modules¶
| Component | Location | Used By |
|---|---|---|
createApp() |
functions/_shared/create-app.ts |
All edge functions |
cacheMiddleware() |
functions/_shared/cache.ts |
All edge functions |
getEnvBindingsSafe() |
functions/_shared/env-validation.ts |
All edge functions |
PaginationQuerySchema |
src/schemas/openapi.ts |
All list endpoints |
buildPaginationMeta() |
src/modules/shared/ |
All list endpoints |
errorResponse() |
src/routes/utils.ts |
All route handlers |
NotFoundError |
src/routes/utils.ts |
All detail endpoints |
getSupabaseClient() |
src/routes/utils.ts |
All service instantiation |
New Shared Components (Phase 2)¶
| Component | Location | Used By |
|---|---|---|
TopicRefSchema |
src/modules/shared/schemas.ts |
insights, journeys (reverse lookups) |
TagRefSchema |
src/modules/shared/schemas.ts |
insights, journeys (reverse lookups) |
JourneyRefSchema |
src/modules/shared/schemas.ts |
topics, tags (forward lookups) |
InsightRefSchema |
src/modules/shared/schemas.ts |
topics, tags (forward lookups) |
getTopicsForEntity() |
src/modules/shared/discovery.ts |
insights, journeys |
getTagsForEntity() |
src/modules/shared/discovery.ts |
insights, journeys |
getJourneysForDiscovery() |
src/modules/shared/discovery.ts |
topics, tags |
getInsightsForDiscovery() |
src/modules/shared/discovery.ts |
topics, tags |
Independence Guarantee¶
Each edge function is independently deployable because:
- No shared runtime state — each function creates its own Hono app via
createApp() - No shared database connections — each request creates a fresh Supabase client
- Modules are imported at build time — Deno bundles only the code each function needs
- Cache policies are per-function — each function chooses its own cache strategy
- Health checks are per-function — each function has
GET /{name}/health - Business logic is in modules — functions are thin wrappers around modules
What IS shared (at build time, not runtime):
- TypeScript types and Zod schemas (compile-time only)
- Service classes (instantiated per-request, not per-function)
- Utility functions (pure functions, no state)
- Middleware factories (return new middleware per invocation)
Summary: Total Work Items¶
| Phase | Files Created | Files Modified | Endpoints | Tables |
|---|---|---|---|---|
| Phase 0 | 4 schema files, 4 .http files | — | — (contracts only) | — |
| Phase 1 | 2 migration files | — | — | 6 tables, 2 views |
| Phase 2 | 2 shared modules | — | — | — |
| Phase 3 | 5 module files, 1 function file | — | 4 GET | — |
| Phase 4 | 5 module files, 1 function file | — | 4 GET | — |
| Phase 5 | 5 module files, 1 function file | — | 4 GET | — |
| Phase 6 | — | 3 module files, 1 function file | 3 GET | — |
| Phase 7 | 1 module file | 2 module files, 1 function file | 1 GET (new path) | — |
| Phase 8 | 1 migration file | 2 service files | — (modified) | 2 columns, 2 indexes |
| Phase 9 | — | ~8 legacy functions | — (deprecated) | — |
| Phase 10 | — | SDK regeneration | — | — |
| Total | ~30 new files | ~20 modified files | 16 new GET endpoints | 6 new tables |