Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion campaigns/directory.campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 13 additions & 13 deletions campaigns/orlando.campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
46 changes: 46 additions & 0 deletions scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -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)`,
);
19 changes: 16 additions & 3 deletions src/lib/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -159,7 +165,14 @@ export const campaignSchema: z.ZodType<CampaignDefinition> = 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({
Expand Down
146 changes: 146 additions & 0 deletions src/server/entities.ts
Original file line number Diff line number Diff line change
@@ -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<typeof seedFileSchema>;
export type SeedTeamMember = z.infer<typeof seedTeamMemberSchema>;

// ── 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<HandleRecord | null> {
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<Record<string, unknown> | 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<Record<string, unknown>[]> {
return db.query(
`FROM ${COLLECTIONS.identities} WHERE salonHandle = "${salonHandle}" AND identityType = "stylist" ORDER BY displayName`,
);
}

export async function listServiceMenus(
db: NedbClient,
salonHandle: string,
): Promise<Record<string, unknown>[]> {
return db.query(
`FROM ${COLLECTIONS.services} WHERE salonHandle = "${salonHandle}" ORDER BY category`,
);
}

export async function getCity(
db: NedbClient,
slug: string,
): Promise<Record<string, unknown> | null> {
return db.get(COLLECTIONS.cities, slug);
}
Loading
Loading