From 13b69aa3f88b86204814040c824de8c299b7d60b Mon Sep 17 00:00:00 2001 From: Eth-Interchained Date: Tue, 7 Jul 2026 16:52:15 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20entity=20layer=20+=20provenance-chained?= =?UTF-8?q?=20seed=20loader=20=E2=80=94=20Mint's=20real=20data=20flows=20i?= =?UTF-8?q?nto=20NEDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 opens: the engine becomes the source of truth for salon data. - src/server/entities.ts: collections, seed-file zod schemas (the salon.template.json contract), slugify/categorySlug, typed reads the render plane consumes (resolveHandle, getIdentityByHandle, listTeam, listServiceMenus, getCity) - src/server/seed.ts: content-addressed, idempotent loader — every run writes a seed_runs provenance root (id = sha256(file)[:16]); every doc chains caused_by to it; idem keys are content-addressed so re-running an unchanged seed writes NOTHING; duplicate handles fail loud - Entities stored identity-SHAPED (identityType/handle/displayName/ entity) so Phase 2's registry formalizes without migration; relations are flat keys (salonHandle, cityId) queried via NQL WHERE — the client doesn't wrap /link//neighbors yet (queued upstream for nedb-engine-client; flat keys are the honest Phase-1 model) - scripts/seed.ts (npm run seed): summary + writes-this-run counter (0 = idempotent no-op) + verify() gate — a seed run that can't prove integrity fails - campaigns: geography cities are now {slug, name} — display names are curated, never slug-derived (Dr. Phillips) - indexes at seed time: identities.identityType, identities.salonHandle, services.salonHandle Verified locally: tsc clean · unit 24/24 · live 3/3 vs nedbd 2.6.1 — seed test proves: 8 identities (salon + 7-member roster) + 8 menus + 13 cities land, real NAP intact, WHERE on flat keys works on the real engine, re-run seq delta = 0, verify green · seed CLI smoke (38 writes, verify ok) · portal build clean. Co-Authored-By: Claude Fable 5 --- campaigns/directory.campaign.ts | 13 +- campaigns/orlando.campaign.ts | 26 ++-- package.json | 3 +- scripts/seed.ts | 46 ++++++ src/lib/campaign.ts | 19 ++- src/server/entities.ts | 146 +++++++++++++++++++ src/server/seed.ts | 248 ++++++++++++++++++++++++++++++++ test/api.test.ts | 44 ++++++ test/campaign.test.ts | 5 +- test/seed.test.ts | 57 ++++++++ tsconfig.json | 2 +- 11 files changed, 589 insertions(+), 20 deletions(-) create mode 100644 scripts/seed.ts create mode 100644 src/server/entities.ts create mode 100644 src/server/seed.ts create mode 100644 test/seed.test.ts diff --git a/campaigns/directory.campaign.ts b/campaigns/directory.campaign.ts index e116f25..38523fd 100644 --- a/campaigns/directory.campaign.ts +++ b/campaigns/directory.campaign.ts @@ -49,7 +49,18 @@ export const directory = defineCampaign({ geography: { country: "US", state: "FL", - cities: ["orlando", "winter-park", "maitland", "altamonte-springs", "oviedo", "winter-garden", "lake-mary", "longwood", "sanford", "windermere"], + cities: [ + { slug: "orlando", name: "Orlando" }, + { slug: "winter-park", name: "Winter Park" }, + { slug: "maitland", name: "Maitland" }, + { slug: "altamonte-springs", name: "Altamonte Springs" }, + { slug: "oviedo", name: "Oviedo" }, + { slug: "winter-garden", name: "Winter Garden" }, + { slug: "lake-mary", name: "Lake Mary" }, + { slug: "longwood", name: "Longwood" }, + { slug: "sanford", name: "Sanford" }, + { slug: "windermere", name: "Windermere" }, + ], }, conversion: { primaryGoal: "Salon owner claims a listing", diff --git a/campaigns/orlando.campaign.ts b/campaigns/orlando.campaign.ts index 5e41e66..537f309 100644 --- a/campaigns/orlando.campaign.ts +++ b/campaigns/orlando.campaign.ts @@ -49,19 +49,19 @@ export const orlando = defineCampaign({ country: "US", state: "FL", cities: [ - "orlando", - "winter-park", - "maitland", - "altamonte-springs", - "college-park", - "baldwin-park", - "oviedo", - "winter-garden", - "lake-mary", - "longwood", - "sanford", - "dr-phillips", - "windermere", + { slug: "orlando", name: "Orlando" }, + { slug: "winter-park", name: "Winter Park" }, + { slug: "maitland", name: "Maitland" }, + { slug: "altamonte-springs", name: "Altamonte Springs" }, + { slug: "college-park", name: "College Park" }, + { slug: "baldwin-park", name: "Baldwin Park" }, + { slug: "oviedo", name: "Oviedo" }, + { slug: "winter-garden", name: "Winter Garden" }, + { slug: "lake-mary", name: "Lake Mary" }, + { slug: "longwood", name: "Longwood" }, + { slug: "sanford", name: "Sanford" }, + { slug: "dr-phillips", name: "Dr. Phillips" }, + { slug: "windermere", name: "Windermere" }, ], }, conversion: { diff --git a/package.json b/package.json index 4069ae0..ddf0545 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "start": "NODE_ENV=production tsx server.ts", "audit": "portal audit", "guard": "portal guard", + "seed": "tsx scripts/seed.ts", "typecheck": "tsc --noEmit", - "test": "tsx --test test/campaign.test.ts test/config.test.ts test/render.test.ts", + "test": "tsx --test test/campaign.test.ts test/config.test.ts test/render.test.ts test/seed.test.ts", "test:api": "tsx --test test/api.test.ts" }, "dependencies": { diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..7f47b7d --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,46 @@ +/** + * `npm run seed` — load data/seeds/*.json into the configured engine. + * + * Provenance-chained, content-addressed, idempotent: re-running an + * unchanged seed writes nothing. Ends with verify() because a seed run + * that can't prove integrity didn't happen. + */ + +import { CAMPAIGNS } from "../campaigns"; +import { loadConfig } from "../src/server/config"; +import { createDb, ensureDatabase } from "../src/server/db"; +import { seedAll } from "../src/server/seed"; + +const cfg = loadConfig(); +const db = createDb(cfg); + +const started = Date.now(); +console.log(`⬡ seeding ${cfg.nedbDb} @ ${cfg.nedbUrl} from ${cfg.seedDir}`); + +const ok = await ensureDatabase(db, cfg.nedbDb); +if (!ok) { + console.error("✗ engine unreachable — is nedbd running?"); + process.exit(1); +} + +const before = await db.seq(); +const result = await seedAll(db, Object.values(CAMPAIGNS), cfg.seedDir); +const after = await db.seq(); + +for (const s of result.summaries) { + console.log( + ` ${s.file}: salon=${s.salonHandle} identities=${s.identities} menus=${s.serviceMenus} (${s.runId})`, + ); +} +console.log(` cities: ${result.cities} · indexes: ${result.indexes.join(", ")}`); +console.log(` writes this run: ${after - before} (0 = idempotent no-op)`); + +const v = await db.verify(); +if (!v.ok) { + console.error(`✗ verify FAILED — tampered: ${v.tampered.join(", ")}`); + process.exit(1); +} +const checked = v.objects_checked != null ? `${v.objects_checked} objects checked, ` : ""; +console.log( + `✓ verify ok — tamper-evident, ${checked}head ${v.head.slice(0, 12)}… (${Date.now() - started}ms)`, +); diff --git a/src/lib/campaign.ts b/src/lib/campaign.ts index a9ad79a..2c28ea7 100644 --- a/src/lib/campaign.ts +++ b/src/lib/campaign.ts @@ -76,11 +76,17 @@ export interface CampaignSeo { robots: RobotsPolicy; } +export interface CityRef { + /** Slug into the cities collection (and the URL segment). */ + slug: string; + /** Display name — never derived from the slug (Dr. Phillips ≠ Dr-phillips). */ + name: string; +} + export interface CampaignGeography { country: string; state: string; - /** Slugs into the cities collection. */ - cities: string[]; + cities: CityRef[]; } export interface CampaignConversion { @@ -159,7 +165,14 @@ export const campaignSchema: z.ZodType = z.object({ .object({ country: z.string().length(2), state: z.string().min(2), - cities: z.array(z.string().regex(/^[a-z0-9-]+$/)).min(1), + cities: z + .array( + z.object({ + slug: z.string().regex(/^[a-z0-9-]+$/), + name: z.string().min(1), + }), + ) + .min(1), }) .optional(), conversion: z.object({ diff --git a/src/server/entities.ts b/src/server/entities.ts new file mode 100644 index 0000000..bad6d3a --- /dev/null +++ b/src/server/entities.ts @@ -0,0 +1,146 @@ +/** + * Entity layer — collections, schemas, and typed reads. + * + * Phase 1 stores entities as identity-SHAPED documents (identityType, + * handle, displayName, entity payload) in the identities collection — + * the same shape the Links manifest formalizes. Phase 2's registry + * (defineIdentityType, upstreamed to nedb-links) adds validation and + * renderer hooks WITHOUT a migration: the documents are already right. + * + * Relations are FLAT top-level keys (salonHandle, cityId) queried with + * NQL WHERE. The engine's typed DAG edges (/link, /neighbors) are the + * Phase-2+ upgrade — blocked today because nedb-engine-client doesn't + * wrap them yet (queued upstream; the flagship feeds the library). + */ + +import { z } from "zod"; +import type { NedbClient } from "nedb-engine-client"; + +export const COLLECTIONS = { + identities: "identities", + handles: "handles", + cities: "cities", + services: "services", + seedRuns: "seed_runs", + events: "events", +} as const; + +// ── Seed-file schemas (data/seeds/*.json — salon.template.json shape) ─────── + +export const seedTeamMemberSchema = z.object({ + name: z.string().min(1), + title: z.string().min(1), + roles: z.array(z.string()).default([]), + owner: z.boolean().default(false), + isProvider: z.boolean().default(false), + bio: z.string().optional(), + instagram: z.string().optional(), + bookable: z.boolean().optional(), + level: z.number().nullable().optional(), +}); + +export const seedServiceItemSchema = z.object({ + name: z.string().min(1), + price: z.string().min(1), + group: z.string().optional(), + note: z.string().optional(), +}); + +export const seedSalonSchema = z.object({ + identityType: z.literal("salon"), + handle: z.string().regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/), + displayName: z.string().min(1), + status: z.enum(["draft", "published"]).default("draft"), + entity: z.record(z.unknown()), +}); + +/** The generic salon seed contract (salon.template.json). Unknown extra + * keys pass through untouched — the seed is data, not our enum. */ +export const seedFileSchema = z.object({ + retrievedAt: z.string().min(4), + sources: z.record(z.unknown()).optional(), + salon: seedSalonSchema, + team: z.array(seedTeamMemberSchema).default([]), + services: z + .record( + z.union([ + z.object({ + source: z.string().optional(), + $note: z.string().optional(), + items: z.array(seedServiceItemSchema), + }), + z.string(), // "$note"-style annotations + ]), + ) + .default({}), + promotions: z.array(z.record(z.unknown())).default([]), +}); + +export type SeedFile = z.infer; +export type SeedTeamMember = z.infer; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +export function slugify(input: string): string { + return input + .toLowerCase() + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") // strip diacritics + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Kebab-case a seed services category key: hairDesign → hair-design. */ +export function categorySlug(key: string): string { + return slugify(key.replace(/([a-z0-9])([A-Z])/g, "$1-$2")); +} + +// ── Typed reads (the render plane consumes these in PR-6) ─────────────────── + +export interface HandleRecord { + identityId: string; + status: "active" | "redirect"; + redirectTo?: string; +} + +export async function resolveHandle( + db: NedbClient, + handle: string, +): Promise { + const doc = await db.get(COLLECTIONS.handles, handle); + return doc ? (doc as unknown as HandleRecord) : null; +} + +export async function getIdentityByHandle( + db: NedbClient, + handle: string, +): Promise | null> { + const rec = await resolveHandle(db, handle); + if (!rec || rec.status !== "active") return null; + return db.get(COLLECTIONS.identities, rec.identityId); +} + +export async function listTeam( + db: NedbClient, + salonHandle: string, +): Promise[]> { + return db.query( + `FROM ${COLLECTIONS.identities} WHERE salonHandle = "${salonHandle}" AND identityType = "stylist" ORDER BY displayName`, + ); +} + +export async function listServiceMenus( + db: NedbClient, + salonHandle: string, +): Promise[]> { + return db.query( + `FROM ${COLLECTIONS.services} WHERE salonHandle = "${salonHandle}" ORDER BY category`, + ); +} + +export async function getCity( + db: NedbClient, + slug: string, +): Promise | null> { + return db.get(COLLECTIONS.cities, slug); +} diff --git a/src/server/seed.ts b/src/server/seed.ts new file mode 100644 index 0000000..2b3ee32 --- /dev/null +++ b/src/server/seed.ts @@ -0,0 +1,248 @@ +/** + * Seed loader — data/seeds/*.json → NEDB, with provenance. + * + * Every run writes (or no-ops onto) a seed_runs document whose id is + * content-addressed from the file bytes; every document written by that + * run chains caused_by to it. TRACE on any entity reconstructs which + * file, which content hash, which run put it there. Idempotency is + * content-addressed too: re-running an unchanged seed writes NOTHING + * (asserted by sequence number in the live suite); editing the file + * yields new idem keys and the changed docs update. + * + * Real data only: the loader validates shape, never invents fields. + */ + +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import type { NedbClient } from "nedb-engine-client"; + +import type { CampaignDefinition } from "../lib/campaign"; +import { + categorySlug, + COLLECTIONS, + seedFileSchema, + slugify, + type SeedFile, + type SeedTeamMember, +} from "./entities"; + +export interface SeedSummary { + file: string; + runId: string; + salonHandle: string; + identities: number; + serviceMenus: number; + cities: number; +} + +function sha256(data: string | Buffer): string { + return createHash("sha256").update(data).digest("hex"); +} + +/** Deterministic, immutable identity id from the (globally unique) handle. */ +export function identityId(handle: string): string { + return `idn_${sha256(handle).slice(0, 20)}`; +} + +/** Content-addressed idempotency key: same file content + same doc = no-op. */ +export function idemKey(fileHash: string, coll: string, id: string): string { + return sha256(`${fileHash}:${coll}:${id}`).slice(0, 24); +} + +export function listSeedFiles(dir: string): string[] { + return readdirSync(dir) + .filter((f) => f.endsWith(".json") && !f.includes("template")) + .sort() + .map((f) => join(dir, f)); +} + +export function parseSeedFile(path: string): { seed: SeedFile; raw: string } { + const raw = readFileSync(path, "utf8"); + const seed = seedFileSchema.parse(JSON.parse(raw)); + return { seed, raw }; +} + +function teamIdentityDoc( + member: SeedTeamMember, + salonHandle: string, +): { handle: string; doc: Record } { + const handle = slugify(member.name); + return { + handle, + doc: { + identityType: "stylist", + handle, + displayName: member.name, + status: "draft", + salonHandle, + entity: { + title: member.title, + roles: member.roles, + owner: member.owner, + isProvider: member.isProvider, + ...(member.bio ? { bio: member.bio } : {}), + ...(member.level != null ? { level: member.level } : {}), + }, + }, + }; +} + +async function seedOneFile( + db: NedbClient, + path: string, +): Promise { + const file = path.split("/").pop() ?? path; + const { seed, raw } = parseSeedFile(path); + const fileHash = sha256(raw); + const runId = `run_${fileHash.slice(0, 16)}`; + const evidence = `seed:${file}@${fileHash.slice(0, 8)}`; + + // 1. Provenance root — everything this run writes chains to it. + const run = await db.put( + COLLECTIONS.seedRuns, + runId, + { file, sha256: fileHash, retrievedAt: seed.retrievedAt, kind: "salon-seed" }, + { idem: idemKey(fileHash, COLLECTIONS.seedRuns, runId), evidence }, + ); + const rootHash = typeof run.doc._hash === "string" ? [run.doc._hash as string] : []; + const opts = (coll: string, id: string) => ({ + causedBy: rootHash, + idem: idemKey(fileHash, coll, id), + evidence, + }); + + let identities = 0; + + // 2. The salon identity + its handle. + const salonId = identityId(seed.salon.handle); + await db.put( + COLLECTIONS.identities, + salonId, + { + identityType: seed.salon.identityType, + handle: seed.salon.handle, + displayName: seed.salon.displayName, + status: seed.salon.status, + cityId: + typeof (seed.salon.entity as { cityId?: unknown }).cityId === "string" + ? ((seed.salon.entity as { cityId: string }).cityId) + : null, + entity: seed.salon.entity, + }, + opts(COLLECTIONS.identities, salonId), + ); + identities += 1; + await db.put( + COLLECTIONS.handles, + seed.salon.handle, + { identityId: salonId, status: "active" }, + opts(COLLECTIONS.handles, seed.salon.handle), + ); + + // 3. Team → stylist identities + handles. Duplicate handles are a seed + // bug — fail loud, never silently overwrite. + const seen = new Set([seed.salon.handle]); + for (const member of seed.team) { + const { handle, doc } = teamIdentityDoc(member, seed.salon.handle); + if (seen.has(handle)) { + throw new Error(`${file}: duplicate handle "${handle}" — add a disambiguator to the name`); + } + seen.add(handle); + const id = identityId(handle); + await db.put(COLLECTIONS.identities, id, doc, opts(COLLECTIONS.identities, id)); + await db.put( + COLLECTIONS.handles, + handle, + { identityId: id, status: "active" }, + opts(COLLECTIONS.handles, handle), + ); + identities += 1; + } + + // 4. Service menus — one doc per category, render-ready price tables. + let serviceMenus = 0; + for (const [key, value] of Object.entries(seed.services)) { + if (typeof value === "string" || key.startsWith("$")) continue; + const slug = categorySlug(key); + const docId = `${seed.salon.handle}:${slug}`; + await db.put( + COLLECTIONS.services, + docId, + { + category: slug, + categoryLabel: key, + salonHandle: seed.salon.handle, + source: value.source ?? null, + items: value.items, + }, + opts(COLLECTIONS.services, docId), + ); + serviceMenus += 1; + } + + return { file, runId, salonHandle: seed.salon.handle, identities, serviceMenus, cities: 0 }; +} + +/** Cities come from campaign geography (config, not salon files) — deduped + * across campaigns, tagged with every campaign that claims them. */ +async function seedCities( + db: NedbClient, + campaigns: CampaignDefinition[], +): Promise { + const bySlug = new Map(); + for (const c of campaigns) { + if (!c.geography) continue; + for (const city of c.geography.cities) { + const existing = bySlug.get(city.slug); + if (existing) { + if (!existing.campaigns.includes(c.id)) existing.campaigns.push(c.id); + } else { + bySlug.set(city.slug, { + slug: city.slug, + name: city.name, + state: c.geography.state, + country: c.geography.country, + campaigns: [c.id], + }); + } + } + } + const cities = [...bySlug.values()]; + const listHash = sha256(JSON.stringify(cities)); + for (const city of cities) { + await db.put(COLLECTIONS.cities, city.slug, city, { + idem: idemKey(listHash, COLLECTIONS.cities, city.slug), + evidence: `campaign-geography@${listHash.slice(0, 8)}`, + }); + } + return cities.length; +} + +export interface SeedAllResult { + summaries: SeedSummary[]; + cities: number; + indexes: string[]; +} + +export async function seedAll( + db: NedbClient, + campaigns: CampaignDefinition[], + seedDir: string, +): Promise { + const dir = resolve(process.cwd(), seedDir); + const summaries: SeedSummary[] = []; + for (const path of listSeedFiles(dir)) { + summaries.push(await seedOneFile(db, path)); + } + const cities = await seedCities(db, campaigns); + + // Indexes for the render plane's WHERE/ORDER BY paths. Idempotent. + const indexes = ["identities.identityType", "identities.salonHandle", "services.salonHandle"]; + await db.createIndex(COLLECTIONS.identities, "identityType", "eq"); + await db.createIndex(COLLECTIONS.identities, "salonHandle", "eq"); + await db.createIndex(COLLECTIONS.services, "salonHandle", "eq"); + + return { summaries, cities, indexes }; +} diff --git a/test/api.test.ts b/test/api.test.ts index e4701ec..87a5ea6 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -120,3 +120,47 @@ test("unknown /api/* routes 404 as JSON, not the SPA shell", async () => { const j = (await r.json()) as { error: string }; assert.equal(j.error, "not found"); }); + +test("seed loader: real file → real engine, provenance-chained, idempotent", async () => { + const { createDb } = await import("../src/server/db"); + const { seedAll } = await import("../src/server/seed"); + const { getIdentityByHandle, listTeam, listServiceMenus, getCity } = await import( + "../src/server/entities" + ); + + const db = createDb({ nedbUrl: NEDB_TEST_URL, nedbDb: TEST_DB, nedbToken: undefined }); + + // first run — Mint's real data lands + const result = await seedAll(db, Object.values(CAMPAIGNS), "data/seeds"); + assert.equal(result.summaries.length, 1, "one real seed file"); + assert.equal(result.summaries[0].salonHandle, "mint-on-the-avenue"); + assert.equal(result.summaries[0].identities, 8, "1 salon + 7 team"); + assert.ok(result.summaries[0].serviceMenus >= 6, "menu categories"); + assert.equal(result.cities, 13, "13 unique cities across campaigns (directory ⊂ orlando)"); + + // reads the render plane will use + const salon = await getIdentityByHandle(db, "mint-on-the-avenue"); + assert.ok(salon, "salon resolves via handle"); + assert.equal(salon?.identityType, "salon"); + const nap = (salon?.entity as { nap?: { phone?: string } })?.nap; + assert.equal(nap?.phone, "+1-407-645-2264", "real NAP data intact"); + + // flat-key WHERE queries — the Phase-1 relation model, proven on the engine + const team = await listTeam(db, "mint-on-the-avenue"); + assert.equal(team.length, 7, "WHERE salonHandle + identityType works"); + const menus = await listServiceMenus(db, "mint-on-the-avenue"); + assert.ok(menus.some((m) => m.category === "hair-color"), "hair-color menu present"); + + const city = await getCity(db, "dr-phillips"); + assert.equal(city?.name, "Dr. Phillips", "curated city display name"); + + // idempotency: re-running the unchanged seed writes NOTHING + const before = await db.seq(); + await seedAll(db, Object.values(CAMPAIGNS), "data/seeds"); + const after = await db.seq(); + assert.equal(after, before, `re-run must be a no-op (wrote ${after - before})`); + + // and the whole database still proves integrity + const v = await db.verify(); + assert.equal(v.ok, true, "verify green after seeding"); +}); diff --git a/test/campaign.test.ts b/test/campaign.test.ts index 93944c5..811170f 100644 --- a/test/campaign.test.ts +++ b/test/campaign.test.ts @@ -29,9 +29,12 @@ test("locked decision: domains match the spec", () => { test("orlando targets exactly the 13 locked cities", () => { const cities = CAMPAIGNS.orlando.geography?.cities ?? []; assert.equal(cities.length, 13); + const slugs = cities.map((c) => c.slug); for (const expected of ["orlando", "winter-park", "maitland", "dr-phillips", "windermere"]) { - assert.ok(cities.includes(expected), `missing city: ${expected}`); + assert.ok(slugs.includes(expected), `missing city: ${expected}`); } + // display names are curated, never slug-derived + assert.equal(cities.find((c) => c.slug === "dr-phillips")?.name, "Dr. Phillips"); }); test("orlando anchors on Mint on the Avenue", () => { diff --git a/test/seed.test.ts b/test/seed.test.ts new file mode 100644 index 0000000..d3cbc68 --- /dev/null +++ b/test/seed.test.ts @@ -0,0 +1,57 @@ +/** + * Seed loader — unit suite. Pure pieces only; the live proof (real file → + * real engine → idempotent re-run) lives in test/api.test.ts's suite + * alongside the storefront matrix. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { categorySlug, seedFileSchema, slugify } from "../src/server/entities"; +import { idemKey, identityId, listSeedFiles, parseSeedFile } from "../src/server/seed"; + +test("slugify: names → handles, diacritics and punctuation handled", () => { + assert.equal(slugify("Sonia Taylor"), "sonia-taylor"); + assert.equal(slugify("Dr. Phillips"), "dr-phillips"); + assert.equal(slugify("Samantha Herrman"), "samantha-herrman"); + assert.equal(slugify(" Lian Ortiz "), "lian-ortiz"); +}); + +test("categorySlug: camelCase seed keys → kebab slugs", () => { + assert.equal(categorySlug("hairDesign"), "hair-design"); + assert.equal(categorySlug("hairColor"), "hair-color"); + assert.equal(categorySlug("mintMen"), "mint-men"); + assert.equal(categorySlug("texture"), "texture"); +}); + +test("identityId is deterministic and idn_-prefixed", () => { + const a = identityId("mint-on-the-avenue"); + assert.equal(a, identityId("mint-on-the-avenue")); + assert.match(a, /^idn_[0-9a-f]{20}$/); + assert.notEqual(a, identityId("sonia-taylor")); +}); + +test("idemKey is content-addressed: same content = same key, new content = new key", () => { + const k1 = idemKey("filehash-a", "identities", "idn_x"); + assert.equal(k1, idemKey("filehash-a", "identities", "idn_x")); + assert.notEqual(k1, idemKey("filehash-b", "identities", "idn_x")); + assert.notEqual(k1, idemKey("filehash-a", "handles", "idn_x")); +}); + +test("listSeedFiles skips templates, real seed parses + validates", () => { + const files = listSeedFiles("data/seeds"); + assert.ok(files.some((f) => f.endsWith("mint-on-the-avenue.json"))); + assert.ok(!files.some((f) => f.includes("template")), "template must be skipped"); + + const { seed } = parseSeedFile(files.find((f) => f.endsWith("mint-on-the-avenue.json"))!); + assert.equal(seed.salon.handle, "mint-on-the-avenue"); + assert.equal(seed.salon.identityType, "salon"); + assert.equal(seed.team.length, 7, "canonical 7-member roster"); + assert.ok(Object.keys(seed.services).length >= 6, "service categories present"); +}); + +test("seed schema rejects invented shapes loudly", () => { + const bad = { retrievedAt: "2026-07-07", salon: { identityType: "salon", handle: "UPPER CASE", displayName: "x", entity: {} } }; + const parsed = seedFileSchema.safeParse(bad); + assert.equal(parsed.success, false, "invalid handle must fail validation"); +}); diff --git a/tsconfig.json b/tsconfig.json index 808d22c..dc6ae3e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "types": ["node"], "paths": { "@/*": ["./src/*"] } }, - "include": ["src", "routes", "campaigns", "test", "app.contract.ts", "server.ts", "vite.config.ts"] + "include": ["src", "routes", "campaigns", "test", "scripts", "app.contract.ts", "server.ts", "vite.config.ts"] }