@peripheries/engine 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/template/cli.mjs +2 -2
package/package.json
CHANGED
package/src/template/cli.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import{execSync}from"node:child_process";function parseArgs(argv){const flags=new Set;const values=new Map;const positional=[];for(let i=0;i<argv.length;i++){const token=argv[i];if(!token.startsWith("--")){positional.push(token);continue}const eq=token.indexOf("=");if(eq!==-1){values.set(token.slice(2,eq),token.slice(eq+1));continue}const key=token.slice(2);const next=argv[i+1];if(next!==void 0&&!next.startsWith("--")){values.set(key,next);i++}else{flags.add(key)}}return{command:positional[0]??"",targetEnv:resolveTargetEnv(flags,values),layer:values.get("layer")??null,name:values.get("name")??null,check:values.get("check")??null,version:values.get("version")??null,dryRun:flags.has("dry-run"),offline:flags.has("offline"),json:flags.has("json"),overwrite:flags.has("overwrite"),confirmProd:values.get("confirm-prod")??null,force:flags.has("force"),flags,values}}function resolveTargetEnv(flags,values){const fromValue=values.get("env")??null;const aliases=[];if(flags.has("develop"))aliases.push("develop");if(flags.has("prod"))aliases.push("prod");if(aliases.length>1){throw new Error("Pass at most one of --develop / --prod.")}if(fromValue!==null){if(fromValue!=="local"&&fromValue!=="develop"&&fromValue!=="prod"){throw new Error(`--env must be local | develop | prod (got "${fromValue}").`)}if(aliases.length===1&&aliases[0]!==fromValue){throw new Error(`--env ${fromValue} conflicts with --${aliases[0]}. Pass one.`)}return fromValue}return aliases[0]??"local"}function assertWriteAllowed(args,options){if(args.flags.has("replace-existing")){if(args.targetEnv==="prod"){throw new Error("--replace-existing is never allowed against prod.")}if(args.layer!=="dev-workbooks"&&args.layer!=="dev-client-data"||!args.name){throw new Error("--replace-existing requires --layer dev-workbooks|dev-client-data and --name <fixture> \u2014 it deletes a member's seeded rows, so it is never run across every client at once.")}}if(!options.requiresWrite||args.dryRun||args.offline)return;if(args.targetEnv==="local")return;if(!args.overwrite){throw new Error(`Writing to ${args.targetEnv} requires --overwrite. Run with --dry-run first and read the report.`)}if(args.targetEnv==="prod"){if(args.confirmProd!==options.orgSlug){throw new Error(`Writing to prod requires --confirm-prod ${options.orgSlug}. This is a deliberate speed bump: prod org config is live client-facing data.`)}if(!args.force&&isGitTreeDirty(options.repoRoot)){throw new Error("Refusing to write to prod from a dirty git tree \u2014 the files being uploaded would not match any commit, so the write could not be reproduced or reviewed. Commit first, or pass --force if you know exactly why.")}}}function isGitTreeDirty(repoRoot){try{const out=execSync("git status --porcelain",{cwd:repoRoot,encoding:"utf8",stdio:["ignore","pipe","ignore"]});return out.trim().length>0}catch{return false}}import{existsSync as existsSync11,readFileSync as readFileSync10}from"node:fs";import{join as join11}from"node:path";import{createClient}from"@supabase/supabase-js";import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";var ENV_FILE={local:".env.local",develop:".env.develop",prod:".env.prod"};function loadEnv(target,repoRoot){const primary=resolve(repoRoot,ENV_FILE[target]);const fallback=target==="local"?resolve(repoRoot,".env"):null;const fileToLoad=existsSync(primary)?primary:fallback&&existsSync(fallback)?fallback:null;if(!fileToLoad)return null;for(const rawLine of readFileSync(fileToLoad,"utf8").split("\n")){const line=rawLine.trim();if(!line||line.startsWith("#"))continue;const match=line.match(/^([^=]+)=(.*)$/);if(!match)continue;const key=match[1].trim();let value=match[2].trim();if(value.startsWith('"')&&value.endsWith('"')||value.startsWith("'")&&value.endsWith("'")){value=value.slice(1,-1)}if(process.env[key]===void 0)process.env[key]=value}return fileToLoad}function getSupabaseConfig(){const url=process.env.SUPABASE_URL;const key=process.env.SUPABASE_SECRET_KEY||process.env.SUPABASE_SERVICE_KEY||process.env.SUPABASE_SERVICE_ROLE_KEY;if(!url){throw new Error("SUPABASE_URL not set. Add it to the env file for this target, or pass --offline to work without a database.")}if(!key){throw new Error("SUPABASE_SECRET_KEY (or SUPABASE_SERVICE_KEY / SUPABASE_SERVICE_ROLE_KEY) not set. Template writes need the secret key \u2014 the publishable key cannot pass RLS on org config tables.")}return{url,key}}function createSupabaseClient(target,repoRoot){loadEnv(target,repoRoot);const{url,key}=getSupabaseConfig();return createClient(url,key,{auth:{persistSession:false,autoRefreshToken:false}})}import{existsSync as existsSync3}from"node:fs";import{join as join3}from"node:path";import{pathToFileURL}from"node:url";import{existsSync as existsSync2,readdirSync,readFileSync as readFileSync2}from"node:fs";import{join,resolve as resolve2}from"node:path";function typeName(value){if(value===null)return"null";if(Array.isArray(value))return"array";return typeof value}function checkField(key,spec,raw,errors){const present=raw!==void 0&&raw!==null;if(!present){if(spec.required){errors.push(`"${key}" is required`);return void 0}return"default"in spec?spec.default:void 0}switch(spec.type){case"string":if(typeof raw!=="string"){errors.push(`"${key}" must be a string (got ${typeName(raw)})`);return void 0}if(spec.minLength!==void 0&&raw.length<spec.minLength){errors.push(`"${key}" must be at least ${spec.minLength} character(s)`);return void 0}return raw;case"boolean":if(typeof raw!=="boolean"){errors.push(`"${key}" must be a boolean (got ${typeName(raw)})`);return void 0}return raw;case"number":if(typeof raw!=="number"||Number.isNaN(raw)){errors.push(`"${key}" must be a number (got ${typeName(raw)})`);return void 0}return raw;case"enum":if(typeof raw!=="string"||!spec.values.includes(raw)){errors.push(`"${key}" must be one of: ${spec.values.join(", ")} (got ${JSON.stringify(raw)})`);return void 0}return raw;case"object":if(typeName(raw)!=="object"){errors.push(`"${key}" must be an object (got ${typeName(raw)})`);return void 0}return raw;case"array":if(!Array.isArray(raw)){errors.push(`"${key}" must be an array (got ${typeName(raw)})`);return void 0}return raw;case"any":return raw}}function validateShape(raw,spec){const errors=[];if(typeName(raw)!=="object"){return{ok:false,value:null,errors:[`expected a JSON object, got ${typeName(raw)}`]}}const input=raw;const out={};for(const[key,fieldSpec]of Object.entries(spec.fields)){const value=checkField(key,fieldSpec,input[key],errors);if(value!==void 0)out[key]=value}const known=new Set([...Object.keys(spec.fields),...spec.ignored??[]]);for(const key of Object.keys(input)){if(spec.allowUnknown){if(!known.has(key))out[key]=input[key]}else if(!known.has(key)){errors.push(`unknown key "${key}" \u2014 not written to the database. Add it to the layer schema, or move this file out of the layer.`)}}return errors.length>0?{ok:false,value:null,errors}:{ok:true,value:out,errors:[]}}var REPO_ROOT=resolve2(process.env.ENGINE_ROOT??process.cwd());var TEMPLATE_ROOT=resolve2(REPO_ROOT,"template");var CONFIG_DIR=join(TEMPLATE_ROOT,"config");var DATA_DIR=join(TEMPLATE_ROOT,"data");var SEED_DIR=join(TEMPLATE_ROOT,"seed");var MAPPINGS_DIR=join(TEMPLATE_ROOT,"mappings");var cachedOrgConfig=null;function loadOrgConfig(){if(cachedOrgConfig)return cachedOrgConfig;const path=join(CONFIG_DIR,"org.json");if(!existsSync2(path)){throw new Error(`template/config/org.json not found at ${path}`)}const parsed=JSON.parse(readFileSync2(path,"utf8"));const result=validateShape(parsed,{fields:{slug:{type:"string",required:true,minLength:1},name:{type:"string",required:true,minLength:1},advisor:{type:"boolean",required:true},advisor_org_slug:{type:"string"},bundle_name:{type:"string",default:"main"},asset_bucket:{type:"string"},description:{type:"string"}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`template/config/org.json is invalid:
|
|
3
|
-
- ${result.errors.join("\n - ")}`)}if(!result.value.advisor&&!result.value.advisor_org_slug){console.warn("[template] config/org.json: client org has no advisor_org_slug \u2014 the org layer will not link an advisor org.")}cachedOrgConfig=result.value;return result.value}var cachedTemplatesConfig=null;function loadTemplatesConfig(){if(cachedTemplatesConfig)return cachedTemplatesConfig;const path=join(CONFIG_DIR,"templates.json");if(!existsSync2(path)){cachedTemplatesConfig={org_template:null,project_templates:[]};return cachedTemplatesConfig}const parsed=JSON.parse(readFileSync2(path,"utf8"));const outer=validateShape(parsed,{fields:{org_template:{type:"any"},project_templates:{type:"array",default:[]},form_template_process:{type:"string"}},ignored:["_comment"]});if(!outer.ok||!outer.value){throw new Error(`template/config/templates.json is invalid:
|
|
4
|
-
- ${outer.errors.join("\n - ")}`)}let orgTemplate=null;if(outer.value.org_template){const result=validateShape(outer.value.org_template,{fields:{slug:{type:"string",required:true,minLength:1},bundle_name:{type:"string",default:"main"},layout_bundles:{type:"object"}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`templates.json org_template is invalid: ${result.errors.join("; ")}`)}orgTemplate=result.value}const projectTemplates=[];(outer.value.project_templates??[]).forEach((raw,index)=>{const result=validateShape(raw,{fields:{slug:{type:"string",required:true,minLength:1},bundle_name:{type:"string",default:"main"},skip_pages:{type:"boolean",default:false},page_template_process:{type:"string",minLength:36}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`templates.json project_templates[${index}] is invalid: ${result.errors.join("; ")}`)}const pageProcess=result.value.page_template_process;if(pageProcess!==void 0&&!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(pageProcess)){throw new Error(`templates.json project_templates[${index}].page_template_process must be a process id (a uuid)`)}projectTemplates.push(pageProcess===void 0?result.value:{...result.value,page_template_process:pageProcess.toLowerCase()})});const formProcess=outer.value.form_template_process;if(formProcess!==void 0&&!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(formProcess)){throw new Error("templates.json form_template_process must be a process id (a uuid)")}cachedTemplatesConfig={org_template:orgTemplate,project_templates:projectTemplates,...formProcess?{form_template_process:formProcess.toLowerCase()}:{}};return cachedTemplatesConfig}function orgTemplatePath(slug){return join(REPO_ROOT,"src","org_templates",slug,"template.json")}function projectTemplateDir(slug){return join(REPO_ROOT,"src","project_templates",slug)}function projectTemplatePages(slug){const pagesDir=join(projectTemplateDir(slug),"pages");if(!existsSync2(pagesDir))return[];return readdirSync(pagesDir,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>({page:e.name,path:join(pagesDir,e.name,"page.json")})).filter(p=>existsSync2(p.path)).sort((a,b)=>a.page.localeCompare(b.page))}function layerDir(sourceDir){return sourceDir?join(TEMPLATE_ROOT,sourceDir):null}function listJsonFiles(dir){if(!dir||!existsSync2(dir))return[];return readdirSync(dir).filter(f=>f.endsWith(".json")).sort()}function readJsonFile(path){try{return JSON.parse(readFileSync2(path,"utf8"))}catch(error){throw new Error(`${path}: not valid JSON \u2014 ${error instanceof Error?error.message:String(error)}`)}}import{join as join2}from"node:path";var LIST_KEY="bundle_refs";var SINGLE_KEY="bundle_ref";function describe(value){if(value===null)return"null";if(Array.isArray(value))return"an array";return typeof value}function refError(ref2,path,orgSlug){if(typeof ref2==="string"){if(ref2===orgSlug)return null;return`${path}: bare-string bundle ref "${ref2}" is an ORG SLUG, and it is not this org's ("${orgSlug}") \u2014 did you mean { "bundle": "${ref2}" }? A bare string resolves to that org's main bundle, so a lineage name here silently falls through to main and the page renders with the wrong components. For a deliberate cross-org ref, name the lineage explicitly: { "bundle": "<lineage>", "org_slug": "${ref2}" }.`}if(typeof ref2!=="object"||ref2===null||Array.isArray(ref2)){return`${path}: a bundle ref must be a string or an object, got ${describe(ref2)}`}const obj=ref2;if("bundle"in obj){if(typeof obj.bundle!=="string"||obj.bundle.length===0){return`${path}: { "bundle": \u2026 } must name a lineage as a non-empty string, got ${describe(obj.bundle)}`}if("org_slug"in obj&&typeof obj.org_slug!=="string"){return`${path}: "org_slug" must be a string when present, got ${describe(obj.org_slug)}`}return null}if("bundle_id"in obj){if(typeof obj.bundle_id!=="string"||obj.bundle_id.length===0){return`${path}: { "bundle_id": \u2026 } must be a non-empty string`}return null}return`${path}: object bundle ref has neither "bundle" nor "bundle_id" (keys: ${Object.keys(obj).join(", ")||"none"}) \u2014 the union is string | { bundle_id } | { bundle, org_slug? }`}function bundleRefErrors(def,orgSlug){const errors=[];let visited=0;const walk=(node,path)=>{if(Array.isArray(node)){node.forEach((item,i)=>walk(item,`${path}[${i}]`));return}if(typeof node!=="object"||node===null)return;for(const[key,value]of Object.entries(node)){const here=path?`${path}.${key}`:key;if(key===LIST_KEY){if(!Array.isArray(value)){errors.push(`${here}: "${LIST_KEY}" must be an array, got ${describe(value)}`);continue}value.forEach((ref2,i)=>{visited+=1;const error=refError(ref2,`${here}[${i}]`,orgSlug);if(error)errors.push(error)});continue}if(key===SINGLE_KEY){if(value===void 0||value===null)continue;visited+=1;const error=refError(value,here,orgSlug);if(error)errors.push(error);continue}walk(value,here)}};walk(def,"");return{errors,visited}}function describeKey(naturalKey){return Object.entries(naturalKey).map(([k,v])=>`${k}=${v===null?"NULL":JSON.stringify(v)}`).join(", ")}async function upsertByNaturalKey(supabase,table,naturalKey,record,options={}){let query=supabase.from(table).select("id");for(const[key,value]of Object.entries(naturalKey)){query=value===null?query.is(key,null):query.eq(key,value)}const{data:existing,error:selectError}=await query.limit(2);if(selectError){throw new Error(`lookup failed on ${table} (${describeKey(naturalKey)}): ${selectError.message}`)}const rows=existing??[];if(rows.length>1){throw new Error(`${table} has at least ${rows.length} rows matching ${describeKey(naturalKey)} \u2014 ambiguous, refusing to write. Ids: ${rows.map(r=>r.id).join(", ")}. ${table==="client_data_type"?"This table has no unique constraint on its natural key, so duplicates are possible. ":""}Resolve the duplicate in the database first (see \`template/cli doctor\`).`)}const current=rows[0];if(current){if(options.dryRun)return{id:current.id,action:"updated"};const{data:data2,error:error2}=await supabase.from(table).update(record).eq("id",current.id).select("id").single();if(error2)throw new Error(`update failed on ${table} id=${current.id}: ${error2.message}`);return{id:data2.id,action:"updated"}}if(options.dryRun)return{id:"(new)",action:"created"};const{data,error}=await supabase.from(table).insert([record]).select("id").single();if(error){throw new Error(`insert failed on ${table} (${describeKey(naturalKey)}): ${error.message}`)}return{id:data.id,action:"created"}}var LayerManager=class{constructor(def,options,mappings){this.def=def;this.options=options;this.mappings=mappings}def;options;mappings;mappingEntities(){const primary=this.def.mappingEntity?[this.def.mappingEntity]:[];return[...primary,...this.def.mappingEntities??[]]}dir(){return layerDir(this.def.sourceDir)}duplicateDbKeys=new Set;bundleRefsVisited=0;files(){const files=listJsonFiles(this.dir());const filter=this.options.nameFilter;if(!filter)return files;return files.filter(f=>f===`${filter}.json`||f.replace(/\.json$/,"")===filter)}fileKeys(){const keys=[];for(const file of this.files()){try{keys.push(this.naturalKeyOf(this.loadFile(file)))}catch{}}return keys}loadFile(fileName){const dir=this.dir();if(!dir)throw new Error(`layer ${this.def.name} has no source directory`);const raw=readJsonFile(join2(dir,fileName));const result=validateShape(raw,this.shape);if(!result.ok||!result.value){throw new Error(result.errors.join("; "))}return result.value}extraIssues(_def){return{errors:[],warnings:[]}}validateAll(){this.bundleRefsVisited=0;const errors=[];const warnings=[];const files=this.files();const seen=new Map;for(const file of files){try{const def=this.loadFile(file);const refs=bundleRefErrors(def,this.options.orgSlug);this.bundleRefsVisited+=refs.visited;for(const message of refs.errors)errors.push({file,message});const extra=this.extraIssues(def);for(const message of extra.errors)errors.push({file,message});for(const message of extra.warnings)warnings.push({file,message});const key=this.naturalKeyOf(def);const previous=seen.get(key);if(previous){errors.push({file,message:`natural key "${key}" already declared by ${previous} \u2014 two files would fight over one row`})}else{seen.set(key,file)}}catch(error){errors.push({file,message:error instanceof Error?error.message:String(error)})}}if(files.length===0&&this.def.sourceDir&&!this.options.nameFilter){errors.push({file:this.dir()??"(layer)",message:`this layer declares sourceDir "${this.def.sourceDir}" and found NO files there. Every check in this layer runs per file, so zero files is a green run that verified nothing \u2014 a moved or unchecked-out directory, not a clean one.`})}return{layer:this.def.name,filesChecked:files.length,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];for(const file of this.files()){try{const def=this.loadFile(file);const key=this.naturalKeyOf(def);const outcome=await upsertByNaturalKey(this.options.supabase,this.def.table,this.naturalKeyFilter(def),this.toRow(def),{dryRun:this.options.dryRun});if(this.def.mappingEntity&&!this.options.dryRun){this.mappings.set(this.def.mappingEntity,key,outcome.id)}entities.push({entity:key,action:outcome.action,id:outcome.id})}catch(error){errors.push({entity:file,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}async status(){const files=this.files();const base={layer:this.def.name,kind:this.def.kind,implemented:this.def.implemented,files:files.length,dbRows:null,mappedIds:this.mappingEntities().reduce((sum,e)=>sum+this.mappings.count(e),0),missingInDb:[],missingInFiles:[],unmappedInDb:[]};if(!this.def.implemented)return base;const fileKeys=new Set(this.fileKeys());try{const dbKeys=await this.fetchDbKeys();base.dbRows=dbKeys.size;base.missingInDb=[...fileKeys].filter(k=>!dbKeys.has(k)).sort();base.missingInFiles=[...dbKeys.keys()].filter(k=>!fileKeys.has(k)).sort();if(this.def.mappingEntity){const mapped=new Set(this.mappings.keys(this.def.mappingEntity));base.unmappedInDb=[...dbKeys.keys()].filter(k=>fileKeys.has(k)&&!mapped.has(k)).sort()}}catch(error){base.error=error instanceof Error?error.message:String(error)}return base}async healthChecks(){return[]}async orphans(){return[]}async pull(){const started=Date.now();const entities=[];const errors=[];if(!this.def.implemented||!this.def.mappingEntity){return{layer:this.def.name,created:0,updated:0,skipped:0,entities,errors,duration_ms:Date.now()-started}}try{const dbKeys=await this.fetchDbKeys();for(const key of this.fileKeys()){const id=dbKeys.get(key);if(!id){entities.push({entity:key,action:"skipped",id:null,reason:"not in database"});continue}if(this.duplicateDbKeys.has(key)){entities.push({entity:key,action:"skipped",id:null,reason:"ambiguous \u2014 more than one row shares this natural key; resolve the duplicate before mapping it (see `doctor`)"});continue}const before=this.mappings.getId(this.def.mappingEntity,key);this.mappings.set(this.def.mappingEntity,key,id);entities.push({entity:key,action:before===id?"skipped":"updated",id})}}catch(error){errors.push({entity:this.def.name,error:error instanceof Error?error.message:String(error)})}return{layer:this.def.name,created:0,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};var CONTRACT=["src/types/host-contract.generated.ts","src/types/host-contract.generated.js"].map(f=>join3(REPO_ROOT,f)).find(f=>existsSync3(f));var AGENT_TOOL_NAMES=CONTRACT?(await import(pathToFileURL(CONTRACT).href)).AGENT_TOOL_NAMES??[]:[];var AgentTemplateManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},is_template:{type:"boolean",default:true},prompt:{type:"any",required:true},tools:{type:"any",required:true},model:{type:"any"},output_schema:{type:"any"},skills:{type:"any"},metadata:{type:"object"}}}}extraIssues(def){const errors=[];const warnings=[];const tools=def.tools;if(tools!==void 0){const shape=tools;if(typeof tools!=="object"||tools===null||Array.isArray(tools)){errors.push(`"tools" must be an object carrying a toolNames array \u2014 the host keeps platform-agent's ADVISOR tools for any other shape, silently`)}else if(!Array.isArray(shape?.toolNames)){errors.push(`"tools.toolNames" must be an array \u2014 without it the host keeps platform-agent's ADVISOR tools (createEvent, updateEvent, submitProcessJob), silently, and this row's own tools are never applied`)}else if(shape.toolNames.some(t=>typeof t!=="string"||t.trim()==="")){errors.push(`"tools.toolNames" must contain non-empty strings \u2014 the host matches them by exact name against its tool registry, so a blank entry silently selects nothing`)}else if(AGENT_TOOL_NAMES.length>0){const known=new Set(AGENT_TOOL_NAMES);const names=shape.toolNames;const unknown=names.filter(t=>!known.has(t));if(unknown.length>0&&unknown.length<names.length){errors.push(`"tools.toolNames" names ${unknown.length} tool(s) of ${names.length} that the host does not have: ${unknown.join(", ")}. The runtime matches by exact name and drops the rest silently, so this agent would run with fewer tools than it declares. Valid names are in AGENT_TOOL_NAMES (host contract 2.15.0+)`)}else if(unknown.length===names.length){warnings.push(`none of this agent's ${names.length} tool name(s) are in the host's chat-agent registry, which reads as a BEA or other non-host runtime rather than a typo \u2014 every name would have to be wrong for a typo to look like this. Not checkable here: only the host chat runtime publishes its tool names (AGENT_TOOL_NAMES, contract 2.15.0+)`)}}}const prompt=def.prompt;if(prompt!==void 0){const shape=prompt;if(typeof prompt!=="object"||prompt===null||Array.isArray(prompt)){errors.push(`"prompt" must be an object with a "system" string \u2014 the host keeps platform-agent's ADVISOR system prompt for any other shape`)}else if(typeof shape?.system!=="string"||shape.system.trim()===""){errors.push(`"prompt.system" must be a non-empty string \u2014 without it the host keeps platform-agent's ADVISOR system prompt ("assistant for financial advisors")`)}}return{errors,warnings}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name,is_template:true}}toRow(def){const row={org_id:this.options.orgId,name:def.name,is_template:true,archived:false,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.prompt!==void 0)row.prompt=def.prompt;if(def.tools!==void 0)row.tools=def.tools;if(def.model!==void 0)row.model=def.model;if(def.output_schema!==void 0)row.output_schema=def.output_schema;if(def.skills!==void 0)row.skills=def.skills;if(def.metadata!==void 0)row.metadata=def.metadata;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("agent").select("id, name").eq("org_id",this.options.orgId).eq("is_template",true).eq("archived",false);if(error)throw new Error(`agent lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){if(row.name)out.set(row.name,row.id)}return out}};var DataTypeManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},data_format:{type:"enum",values:["text","json","file"],default:"text"},schema:{type:"any"}},ignored:["org_id","org_name"]}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(def){const row={org_id:this.options.orgId,name:def.name,data_format:def.data_format,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.schema!==void 0)row.schema=def.schema;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("client_data_type").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`client_data_type lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){out.set(row.name,row.id)}return out}};import{existsSync as existsSync4,readdirSync as readdirSync2}from"node:fs";import{join as join4}from"node:path";var ORG_OVERRIDE_DIR=join4(DATA_DIR,"event-types");var DECLARATIONS=["kind","visible_to","creatable_by","data_schema","retention","requires_consent","hooks","components","calendar_export","access_logged"];var EventTypeManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},icon:{type:"string"},color:{type:"string"},category:{type:"string"},display_order:{type:"number"},metadata:{type:"object"},kind:{type:"enum",values:["scheduled","record"]},visible_to:{type:"array"},creatable_by:{type:"array"},data_schema:{type:"object"},retention:{type:"string"},requires_consent:{type:"boolean"},hooks:{type:"array"},components:{type:"object"},calendar_export:{type:"enum",values:["none","busy","details"]},access_logged:{type:"boolean"}}}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(def){const row={org_id:this.options.orgId,name:def.name,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.icon!==void 0)row.icon=def.icon;if(def.color!==void 0)row.color=def.color;if(def.metadata!==void 0)row.metadata=def.metadata;for(const key of DECLARATIONS)if(def[key]!==void 0)row[key]=def[key];return row}orgFiles(){if(!existsSync4(ORG_OVERRIDE_DIR))return[];return readdirSync2(ORG_OVERRIDE_DIR).filter(f=>f.endsWith(".json")).sort()}resolvePath(fileName){const orgPath=join4(ORG_OVERRIDE_DIR,fileName);return existsSync4(orgPath)?orgPath:join4(this.dir()??"",fileName)}files(){const merged=new Set([...super.files(),...this.orgFiles()]);const all=[...merged].sort();const filter=this.options.nameFilter;if(!filter)return all;return all.filter(f=>f===`${filter}.json`||f.replace(/\.json$/,"")===filter)}loadFile(fileName){const raw=readJsonFile(this.resolvePath(fileName));const result=validateShape(raw,this.shape);if(!result.ok||!result.value)throw new Error(result.errors.join("; "));return result.value}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("event_type").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`event_type: ${error.message}`);const keys=new Map;for(const row of data??[]){keys.set(row.name,row.id)}return keys}};var FeedProducerManager=class extends LayerManager{get shape(){return{fields:{key:{type:"string",required:true,minLength:1},name:{type:"string",required:true,minLength:1},description:{type:"string"},kind:{type:"enum",values:["process","system","manual","user"],default:"process"},is_default:{type:"boolean",default:false},config:{type:"object",default:{}},status:{type:"enum",values:["active","paused","retired"],default:"active"},metadata:{type:"object"},source_table:{type:"string"},source_id:{type:"string"}}}}naturalKeyOf(def){return def.key}naturalKeyFilter(def){return{org_id:this.options.orgId,key:def.key}}toRow(def){const row={org_id:this.options.orgId,key:def.key,name:def.name,kind:def.kind,is_default:def.is_default,config:def.config,status:def.status,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.metadata!==void 0)row.metadata=def.metadata;if(def.source_table!==void 0)row.source_table=def.source_table;if(def.source_id!==void 0)row.source_id=def.source_id;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("feed_producer").select("id, key").eq("org_id",this.options.orgId);if(error){throw new Error(`feed_producer lookup failed: ${error.message}. This layer needs the 20260804000000_feed_system migration.`)}const out=new Map;for(const row of data??[]){out.set(row.key,row.id)}return out}};import{join as join5}from"node:path";import{createRequire}from"node:module";import{pathToFileURL as pathToFileURL2}from"node:url";import Ajv2020 from"ajv/dist/2020.js";var question_schema_min_default={anyOf:[{$ref:"#/$defs/question"},{$ref:"#/$defs/questionGroup"}],$defs:{question:{type:"object",properties:{key:{type:"string",pattern:"^[A-Za-z][A-Za-z0-9_.-]{0,119}$"},type:{type:"string",enum:["text","textarea","number","date","select","radio","multiselect","file"]},label:{type:"string",minLength:1,maxLength:1e3},required:{type:"boolean"},options:{anyOf:[{minItems:1,maxItems:500,type:"array",items:{type:"object",properties:{value:{anyOf:[{type:"string",maxLength:200},{type:"number"}]},label:{type:"string",minLength:1,maxLength:500}},required:["value","label"],additionalProperties:false}},{type:"null"}]},validation:{anyOf:[{type:"object",properties:{min:{type:"number"},max:{type:"number"}},additionalProperties:false},{type:"null"}]},default:{},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]},description:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},helpText:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},placeholder:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},content_before:{anyOf:[{maxItems:50,type:"array",items:{$ref:"#/$defs/contentBlock"}},{type:"null"}]},content_after:{anyOf:[{maxItems:50,type:"array",items:{$ref:"#/$defs/contentBlock"}},{type:"null"}]},upload:{anyOf:[{type:"object",properties:{instructions:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},acceptedFileTypes:{maxItems:50,type:"array",items:{type:"string",minLength:1,maxLength:100}},maxFileSizeMB:{type:"number",exclusiveMinimum:0,maximum:100},allowCameraCapture:{type:"boolean"},requirePersonalVerification:{type:"boolean"},uploadZoneLabel:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},uploadZoneSubLabel:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},successMessage:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]}},additionalProperties:false},{type:"null"}]}},required:["key","type","label","required"],additionalProperties:false,allOf:[{if:{properties:{type:{enum:["select","radio","multiselect"]}}},then:{required:["options"],properties:{options:{type:"array",minItems:1}}},else:{properties:{options:{type:"null"}}}},{if:{properties:{type:{enum:["file"]}}},else:{properties:{upload:{type:"null"}}}},{if:{properties:{type:{enum:["number"]}}},else:{properties:{validation:{type:"null"}}}}]},condition:{type:"object",properties:{all:{maxItems:50,type:"array",items:{type:"object",properties:{q:{type:"string",minLength:1,maxLength:120},op:{type:"string",enum:["eq","ne","in","not_in","gt","lt","gte","lte","contains","not_contains","empty","not_empty"]},value:{}},required:["q","op"],additionalProperties:false}},not:{type:"boolean"}},required:["all"],additionalProperties:false},contentBlock:{type:"object",properties:{id:{type:"string",minLength:1,maxLength:120},type:{type:"string",minLength:1,maxLength:60},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]}},required:["id","type"],additionalProperties:{}},questionGroup:{type:"object",properties:{group:{type:"boolean",const:true},label:{anyOf:[{type:"string",maxLength:1e3},{type:"null"}]},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]},questions:{minItems:1,maxItems:100,type:"array",items:{$ref:"#/$defs/question"}}},required:["group","questions"],additionalProperties:false}}};var check;function questionProblems(state){check??=new Ajv2020({strict:false,allErrors:true}).compile(question_schema_min_default);const out=[];if(!check(state)){for(const e of check.errors??[])if(e.keyword!=="if"&&e.keyword!=="anyOf")out.push(`${e.instancePath||"/"} ${e.message}`);if(!out.length)out.push("doesn\u2019t match the form-definition contract")}const s=state;const v=s?.validation;if(typeof v?.min==="number"&&typeof v?.max==="number"&&v.min>v.max)out.push("QUESTION_VALIDATION: min is greater than max");if(s?.group&&Array.isArray(s.questions)){const keys=s.questions.map(q=>q?.key).filter(Boolean);const dup=keys.find((k,i)=>keys.indexOf(k)!==i);if(dup)out.push(`QUESTION_KEY_DUPLICATE: ${dup} appears twice in the group`)}return out}var byOrder=xs=>[...xs??[]].sort((a,b)=>Number(a.orderIndex??0)-Number(b.orderIndex??0));var ref=key=>`@q_${key}`;var and=(a,b)=>{if(!a)return b??void 0;if(!b)return a;if(!a.not&&!b.not)return{all:[...a.all,...b.all]};return void 0};function formSpecOf(label,file,deps){const problems=[];const key=String(file.template_id??"");if(!key)problems.push(`${label}: a form template needs a template_id (its template_key)`);const when=(raw,where)=>{const{condition,problems:p}=deps.normalize(typeof raw==="string"?raw:deps.decode(raw));for(const x of p)problems.push(`${label}: ${where}: ${x}`);return condition??void 0};const questions=[];const seenKeys=new Map;const question=(k,name,state,where)=>{const prior=seenKeys.get(k);if(prior)problems.push(`${label}: ${where}: question key "${k}" is also ${prior}'s`);seenKeys.set(k,where);questions.push({key:k,name,state:{key:k,...state}})};const cells=[];const steps=[];const chapters=[];const stepIds=new Set;for(const page of byOrder(file.pages)){chapters.push({id:page.pageId,title:page.title});const pageWhen=when(page.conditionalLogic,`page ${page.pageId}`);for(const section of byOrder(page.sections)){const sid=section.sectionId;if(stepIds.has(sid))problems.push(`${label}: section id "${sid}" is used twice; step ids are deep links`);stepIds.add(sid);const ids=[];const cell=c=>(cells.push(c),ids.push(c.id));const content=(suffix,value)=>{if(value!==void 0&&value!==null&&!(Array.isArray(value)&&!value.length))cell({id:`${sid}-${suffix}`,component:"SectionContent",props:{content:value}})};content("before",section.contentBefore);const type=section.sectionType??"standard";const fields=byOrder(section.fields);const upload=type==="upload"?section.uploadConfig??{}:null;const uploadKey=typeof upload?.fieldId==="string"?upload.fieldId:"";for(const f of fields){const where=`field ${f.fieldId}`;const isUpload=upload&&f.fieldId===uploadKey;question(f.fieldId,f.label,{type:isUpload?"file":f.fieldType,...isUpload?{upload:(({fieldId:_f,...rest})=>rest)(upload)}:{},label:f.label,required:f.required===true,...f.options!=null?{options:deps.decode(f.options)}:{},...f.validation!=null?{validation:deps.decode(f.validation)}:{},...f.defaultValue!=null?{default:f.defaultValue}:{},...f.conditionalLogic!=null?(()=>{const w2=when(f.conditionalLogic,where);return w2?{when:w2}:{}})():{},...f.description!=null?{description:f.description}:{},...f.helpText!=null?{helpText:f.helpText}:{},...f.placeholder!=null?{placeholder:f.placeholder}:{},...f.contentBefore!=null?{content_before:f.contentBefore}:{},...f.contentAfter!=null?{content_after:f.contentAfter}:{}},where)}const extras=[["reviewConfig","SectionReview"],["advisorReviewConfig","SectionAdvisorReview"],["statusConfig","SectionStatus"]];switch(type){case"standard":if(fields.length)cell({id:sid,component:"SectionQuestions",props:{questions:fields.map(f=>ref(f.fieldId))}});break;case"content":cell({id:sid,component:"SectionContent",props:{content:section.content??null}});break;case"review":case"advisor-review":case"status":{const[prop,component]=extras.find(([,c])=>c==={review:"SectionReview","advisor-review":"SectionAdvisorReview",status:"SectionStatus"}[type]);cell({id:sid,component,props:{config:section[prop]??null}});break}case"document":cell({id:sid,component:"SectionDocument",props:{config:section.documentConfig??null}});break;case"upload":{if(!uploadKey){problems.push(`${label}: section ${sid}: an upload needs uploadConfig.fieldId (its question key)`);break}if(!fields.some(f=>f.fieldId===uploadKey)){const{fieldId:_f,...rest}=upload;question(uploadKey,section.title,{type:"file",label:section.title,required:false,upload:rest},`upload ${sid}`)}cell({id:sid,component:"SectionQuestions",props:{questions:[...fields.map(f=>f.fieldId),...fields.some(f=>f.fieldId===uploadKey)?[]:[uploadKey]].map(ref)}});break}case"cell":{const c=section.cellConfig;if(!c?.component)problems.push(`${label}: section ${sid}: a cell section needs cellConfig.component`);else cell({...c,id:sid});let n=0;for(const[prop,component]of extras)if(section[prop]!=null)cell({id:`${sid}-${++n}`,component,props:{config:section[prop]}});break}default:problems.push(`${label}: section ${sid}: unknown sectionType "${type}"`)}content("after",section.contentAfter);const sectionWhen=when(section.conditionalLogic,`section ${sid}`);const w=and(pageWhen,sectionWhen);if(pageWhen&§ionWhen&&!w)problems.push(`${label}: section ${sid}: a negated page and section condition can't be combined`);steps.push({id:sid,title:section.title,chapter:page.pageId,cell_ids:ids,...w?{when:w}:{}})}}for(const q of questions)for(const p of questionProblems(q.state))problems.push(`${label}: question ${q.key}: ${p}`);const meta=k=>file[k]!==void 0?{[k]:file[k]}:{};return{spec:{label,templateKey:key,name:file.template_name,pageType:`form:${key}`,model:{...meta("description"),...meta("template_type"),...file.profile_block_id!==void 0?{profile_block:{id:file.profile_block_id,name:file.profile_block_name??null}}:{},...meta("metadata")},questions,document:{cells,layout:{steps,chapters}}},problems}}function writeSpecOf(spec){return{v:1,template_key:spec.templateKey,name:spec.name,...Object.keys(spec.model).length?{model:spec.model}:{},questions:spec.questions.map(q=>({key:q.key,name:q.name,state:q.state})),page:{content:{cells:spec.document.cells},layout:spec.document.layout}}}async function writeFormTemplates(api,processId,specs){const out=[];for(const spec of specs)out.push({label:spec.label,templateKey:spec.templateKey,result:await api.write(processId,writeSpecOf(spec))});return out}var BACKOFF_SECONDS=[2,4,8,16,30,30,30,30,30,30,30,30];var TemplateReleaseError=class extends Error{constructor(message,code){super(message);this.code=code}code;name="TemplateReleaseError"};var codeOf=e=>/^(?:DENIED|INVALID|CONFLICT|FAILED):\s*([A-Z][A-Z0-9_]+)/.exec(e.message)?.[1];var detailOf=e=>{try{const v=e.details?JSON.parse(e.details):void 0;return v&&typeof v==="object"?v:void 0}catch{return void 0}};async function releaseTemplate(scopeNodeId,deps){const now=deps.now??Date.now;const sleep=deps.sleep??(ms=>new Promise(r=>setTimeout(r,ms)));const log=deps.log??(()=>{});let jobId;let queued=false;for(let attempt=0;;attempt++){if(now()>=deps.deadline)throw new TemplateReleaseError(`the token's time ran out waiting for the page refresh${jobId?` (job ${jobId})`:""}; nothing was published. Run again.`,"DEADLINE");const{data,error}=await deps.rpc("version_release",{p_scope_node_id:scopeNodeId});if(!error){const out=data;if(out.status==="cut"&&out.version_id){log(`release ${out.number} cut`);const moved=await deps.rpc("version_publish",{p_scope_node_id:scopeNodeId,p_channel:"library",p_release_id:out.version_id});if(moved.error)throw new TemplateReleaseError(`release ${out.number} was cut, but moving library to it was refused: ${moved.error.message}`,codeOf(moved.error));log(`library \u2192 release ${out.number}`);return{releaseId:out.version_id,number:out.number??0}}if(out.status==="refresh_queued"){if(queued)throw new TemplateReleaseError(`the pages were refreshed (job ${jobId}) but are still out of date (job ${out.job_id} queued again); nothing was published.`,"REQUEUED");queued=true;jobId=out.job_id;log(`page refresh queued (job ${jobId})`)}else{throw new TemplateReleaseError(`version_release returned ${JSON.stringify(out.status)}; nothing was published.`)}}else if(codeOf(error)==="VERSION_PAGE_STALE"){const detail=detailOf(error);jobId=typeof detail?.job_id==="string"?detail.job_id:jobId;if(Array.isArray(detail?.pages)){const reasons=detail.pages.filter(p=>!p.published).map(p=>p.reason).filter(Boolean);throw new TemplateReleaseError(`the page refresh (job ${jobId}) ran but didn't publish the pages${reasons.length?` (${[...new Set(reasons)].join(", ")})`:""}; nothing was published.`,"REFRESH_FAILED")}}else if(codeOf(error)==="VERSION_BUSY"){log("the template is busy with another write; retrying")}else{throw new TemplateReleaseError(`the release was refused: ${error.message}`,codeOf(error))}const wait=BACKOFF_SECONDS[Math.min(attempt,BACKOFF_SECONDS.length-1)]*1e3;await sleep(Math.min(wait,Math.max(0,deps.deadline-now())))}}var releaseWith=(client,scopeNodeId,deps)=>releaseTemplate(scopeNodeId,{...deps,rpc:(fn,args)=>client.rpc(fn,args)});var TemplateCallError=class extends Error{constructor(fn,message){super(`${fn}: ${message}`);this.fn=fn}fn;name="TemplateCallError"};var must=(fn,out)=>{if(out.error)throw new TemplateCallError(fn,out.error.message);return out.data};var idOf=(fn,data)=>{if(typeof data==="string")return data;const d=data;const id=d?.id??d?.state_id??d?.graph_node_id;if(typeof id!=="string")throw new TemplateCallError(fn,`returned no id (${JSON.stringify(data)})`);return id};function templateApi(db){return{anchors:async processId=>{const graphs=must("graph",await db.from("graph").select("id").eq("process_id",processId).eq("graph_type","process-eng"));const ids=(graphs??[]).map(g=>g.id);const out=new Map;if(!ids.length)return out;const rows=must("graph_node",await db.from("graph_node").select("id, node!inner(name, node_type, content)").in("graph",ids).eq("archived",false).is("version",null).eq("node.node_type","project_page"));for(const r of rows??[]){const type=r.node?.content?.page_type;if(!type)continue;if(out.has(type))throw new TemplateCallError("graph_node",`two live anchors for page type "${type}" in process ${processId}`);out.set(type,{anchorId:r.id,name:r.node.name??""})}return out},pageCreate:async({processId,name,pageType})=>idOf("page_create",must("page_create",await db.rpc("page_create",{p_project_id:null,p_name:name,p_page_type:pageType,p_process_id:processId}))),pageUpdate:async({anchorId,name,model})=>{must("page_update",await db.rpc("page_update",{p_page_id:anchorId,...name?{p_name:name}:{},p_model:model}))},pageArtifact:async anchorId=>{const id=must("page_artifact",await db.rpc("page_artifact",{p_anchor:anchorId}));if(!id)throw new TemplateCallError("page_artifact",`anchor ${anchorId} has no artifact`);return id},writeDocument:async({artifactId,data,fixture})=>idOf("graph_append_state",must("graph_append_state",await db.rpc("graph_append_state",{p_graph_node_id:artifactId,p_system:"data",p_data:data,...fixture?{p_metadata:{fixture:true}}:{}}))),setUpdate:async(graphNodeId,update)=>{must("graph_edit",await db.rpc("graph_edit",{p_graph_node_id:graphNodeId,p_patch:{update:null}}));if(update)must("graph_edit",await db.rpc("graph_edit",{p_graph_node_id:graphNodeId,p_patch:{update}}))},setAudience:async(anchorId,audience)=>{must("page_set_client",await db.rpc("page_set_client",{p_page_id:anchorId,p_on:audience==="client"}));if(audience==="client")return;const placed=must("graph_node",await db.from("graph_node").select("node, node_row:node(audience)").eq("id",anchorId).maybeSingle());if(!placed)throw new TemplateCallError("graph_node",`anchor ${anchorId} isn't readable`);const current=placed.node_row?.audience??null;if(current==="subject"||current===audience)return;must("node",await db.from("node").update({audience}).eq("id",placed.node).select("id").single())},scopeRoot:async processId=>{const id=must("template_scope_root",await db.rpc("template_scope_root",{p_process_id:processId}));if(!id)throw new TemplateCallError("template_scope_root",`process ${processId} has no eng root scope yet`);return id}}}function formTemplateApi(db){return{write:async(processId,spec)=>{const{data,error}=await db.rpc("form_template_write",{p_process:processId,p_spec:spec});if(error)throw new TemplateCallError("form_template_write",`${spec.template_key}: ${error.message}${error.details?` (${error.details})`:""}`);return data}}}import{createClient as createClient2}from"@supabase/supabase-js";var TOKEN_ENV="PERIPHERIES_TEMPLATE_TOKEN";var PROCESS_ENV="PERIPHERIES_TEMPLATE_PROCESS_ID";var MIN_LIFE_MS=6e4;var TemplateTokenError=class extends Error{name="TemplateTokenError"};function decodeClaims(token){const part=token.split(".")[1];if(!part)throw new TemplateTokenError(`${TOKEN_ENV} isn't a token.`);try{return JSON.parse(Buffer.from(part.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8"))}catch{throw new TemplateTokenError(`${TOKEN_ENV} isn't a token.`)}}function templateToken(processId,env=process.env,now=Date.now()){const token=env[TOKEN_ENV];if(!token){throw new TemplateTokenError(`${TOKEN_ENV} isn't set. A template release runs under the agency's with-template-token wrapper for this template's process; it never uses the secret key.`)}const claims=decodeClaims(token);if(claims.grant_kind!=="mandate")throw new TemplateTokenError(`${TOKEN_ENV} isn't a mandate token (grant_kind ${JSON.stringify(claims.grant_kind??null)}).`);if(claims.obo!==void 0)throw new TemplateTokenError(`${TOKEN_ENV} speaks for a person; a template release runs as the template's own mandate.`);if(!claims.act?.job)throw new TemplateTokenError(`${TOKEN_ENV} names no template_release job.`);const want=processId.toLowerCase();if(claims.sub?.toLowerCase()!==want)throw new TemplateTokenError(`${TOKEN_ENV} is for process ${claims.sub??"none"}, not ${processId}, the template being released.`);const named=env[PROCESS_ENV];if(named&&named.toLowerCase()!==want)throw new TemplateTokenError(`${PROCESS_ENV} is ${named}, not ${processId}, the template being released.`);if(env.SUPABASE_URL){const host=u=>{try{return u?new URL(u).host.toLowerCase():null}catch{return null}};const issuer=host(claims.iss);if(!issuer)throw new TemplateTokenError(`${TOKEN_ENV} names no issuer, so it can't be matched to SUPABASE_URL.`);if(issuer!==host(env.SUPABASE_URL))throw new TemplateTokenError(`${TOKEN_ENV} was issued by ${issuer}, not SUPABASE_URL's host; check --env and the env file.`)}if(typeof claims.exp!=="number")throw new TemplateTokenError(`${TOKEN_ENV} has no expiry.`);const deadline=claims.exp*1e3-MIN_LIFE_MS;if(deadline<=now)throw new TemplateTokenError(`${TOKEN_ENV} expires in under a minute; run the wrapper again.`);return{token,claims,deadline}}function templateClient(url,publishableKey,token){return createClient2(url,publishableKey,{accessToken:async()=>token,auth:{persistSession:false}})}function decodeJsonbValue(value){let current=value;for(let depth=0;depth<4&&typeof current==="string";depth++){const trimmed=current.trim();if(!trimmed.startsWith("{")&&!trimmed.startsWith("[")&&!trimmed.startsWith('"'))break;try{const parsed=JSON.parse(trimmed);if(parsed===current)break;current=parsed}catch{break}}return current!==null&&typeof current==="object"?current:value}var RETIRED_SECTION_CONFIG_KEYS={nextButtonText:'authored 10x across 8 files and consumed by nothing \u2014 DocumentSectionRenderer and UploadSectionRenderer render no forward control; advancement is FormNavigationButtons at form scope. Removed 2026-08-27. The requirement it encoded is recorded as a rule ("the forward control names its destination"), not as literals, in the host plan 02 \xA711 \u2014 reviving the key re-creates the literals and loses the rule.'};function retiredSectionConfigKeyErrors(def){const out=[];let visited=0;const pages=Array.isArray(def.pages)?def.pages:[];pages.forEach((page,pi)=>{const sections=Array.isArray(page?.sections)?page.sections:[];sections.forEach((section,si)=>{for(const[configKey,config]of Object.entries(section??{})){if(!configKey.endsWith("Config")||!config||typeof config!=="object")continue;visited+=1;for(const key of Object.keys(config)){const reason=RETIRED_SECTION_CONFIG_KEYS[key];if(reason){out.push(`retired key "${key}" at pages[${pi}].sections[${si}].${configKey} \u2014 ${reason}`)}}}})});return{errors:out,visited}}var FormTemplateManager=class extends LayerManager{get shape(){return{fields:{template_name:{type:"string",required:true,minLength:1},name:{type:"string"},description:{type:"string"},template_type:{type:"enum",values:["form","workbook"],default:"form"},is_published:{type:"boolean",default:false},is_archived:{type:"boolean",default:false},metadata:{type:"object"},shell:{type:"object"},pages:{type:"array",required:true}},ignored:["template_id","org_id","version","profile_block_id","profile_block_name"]}}validateAll(){const result=super.validateAll();let visited=0;for(const file of this.files()){try{visited+=retiredSectionConfigKeyErrors(super.loadFile(file)).visited}catch{}}if(visited===0){result.errors.push({file:"(layer)",message:"the retired-key walker visited zero section config objects across every form template \u2014 its traversal assumptions no longer match the file shape, so the retired-key check is passing vacuously. Fix the walker before trusting a green run."})}return result}loadFile(fileName){const def=super.loadFile(fileName);const{errors}=retiredSectionConfigKeyErrors(def);if(errors.length)throw new Error(errors.join("; "));return def}naturalKeyOf(def){return String(def.template_id??def.name??def.template_name)}naturalKeyFilter(def){return{template_key:this.naturalKeyOf(def)}}toRow(){throw new Error("form templates are written by `engine template release` (form_template_write), never as rows")}async fetchDbKeys(){return new Map}normalize=null;normalizeProblem=null;async prepare(){if(this.normalize||this.normalizeProblem)return;try{const require2=createRequire(join5(REPO_ROOT,"package.json"));const mod=await import(pathToFileURL2(require2.resolve("@peripheries/renderer/forms")).href);if(typeof mod.normalizeCondition!=="function")throw new Error("no normalizeCondition");this.normalize=mod.normalizeCondition}catch(error){const why=error instanceof Error?error.message:String(error);this.normalizeProblem=`form templates need @peripheries/renderer 0.23 or later in this repo, whose \`forms\` entry converts their conditions; loading it failed: ${why}`}}specs(){const specs=[];const problems=[];if(!this.normalize)return{specs,problems:[this.normalizeProblem??"call prepare() first"]};for(const file of this.files()){const def=this.loadFile(file);if(def.is_published===false||def.is_archived===true)continue;const out=formSpecOf(file,def,{decode:decodeJsonbValue,normalize:this.normalize});specs.push(out.spec);problems.push(...out.problems)}return{specs,problems}}extraIssues(def){if(def.is_published===false||def.is_archived===true)return{errors:[],warnings:[`not published: not released (${def.template_name})`]};if(!this.normalize)return{errors:[this.normalizeProblem??"form templates: the renderer's condition converter isn't loaded, so the forms can't be checked"],warnings:[]};const{problems}=formSpecOf(def.template_name,def,{decode:decodeJsonbValue,normalize:this.normalize});return{errors:problems,warnings:[]}}async syncAll(){const started=Date.now();const entities=this.files().map(f=>({entity:f,action:"skipped",id:null,reason:"released only by `engine template release` under the with-template-token wrapper, for templates.json form_template_process"}));return{layer:this.def.name,created:0,updated:0,skipped:entities.length,entities,errors:[],duration_ms:Date.now()-started}}async orphans(){return[]}async healthChecks(){return[]}async release(){const started=Date.now();const entities=[];const errors=[];const finish=()=>({layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started});const processId=process.env[PROCESS_ENV]?.toLowerCase()??null;const configured=loadTemplatesConfig().form_template_process??null;if(!processId){errors.push({entity:"form-templates",error:`${PROCESS_ENV} isn't set; run under the with-template-token wrapper`});return finish()}if(configured!==processId){for(const f of this.files())entities.push({entity:f,action:"skipped",id:null,reason:configured?`another process (${configured}); released in its own wrapped run`:"templates.json names no form_template_process"});return finish()}await this.prepare();let specs;try{const out=this.specs();for(const p of out.problems)errors.push({entity:"form-templates",error:p});if(errors.length)return finish();specs=out.specs}catch(error){errors.push({entity:"form-templates",error:error instanceof Error?error.message:String(error)});return finish()}if(this.options.dryRun){for(const sp of specs)entities.push({entity:`${sp.label} \u2192 ${sp.pageType}`,action:"skipped",id:null,reason:"dry run"});return finish()}try{const{token,deadline}=templateToken(processId);const url=process.env.SUPABASE_URL;const key=process.env.SUPABASE_PUBLISHABLE_KEY;if(!url||!key)throw new Error("SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are needed to release templates (never the secret key).");const db=templateClient(url,key,token);const written=await writeFormTemplates(formTemplateApi(db),processId,specs);let changed=false;for(const w of written){changed||=w.result.changed;entities.push({entity:`${w.label} \u2192 form:${w.templateKey}`,action:w.result.changed?"updated":"skipped",id:w.result.form_id,...w.result.changed?{}:{reason:"unchanged"}})}if(!changed)return finish();const root=await templateApi(db).scopeRoot(processId);const release=await releaseWith(db,root,{deadline,log:line=>console.log(`[template] ${line}`)});entities.push({entity:`release ${release.number} on library`,action:"created",id:release.releaseId})}catch(error){errors.push({entity:"form-templates",error:error instanceof Error?error.message:String(error)})}return finish()}};var DEFAULT="Password123!";function devPassword(targetEnv){const set=process.env.DEV_USER_PASSWORD;if(targetEnv==="local")return set||DEFAULT;if(!set||set===DEFAULT){throw new Error(`dev users on ${targetEnv} need DEV_USER_PASSWORD set to a secret of your own; the built-in default is public and is for a local stack only`)}return set}async function findOrgBySlug(supabase,orgSlug){const{data,error}=await supabase.from("organization").select("id, name, advisor, metadata").eq("metadata->>org_slug",orgSlug).limit(2);if(error){throw new Error(`organization lookup failed for org_slug="${orgSlug}": ${error.message}`)}const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} organization rows have metadata->>org_slug="${orgSlug}" \u2014 ambiguous. Ids: ${rows.map(r=>r.id).join(", ")}. Fix the database before seeding.`)}const row=rows[0];if(!row)return null;const metadata=row.metadata??{};return{id:row.id,name:row.name,advisor:row.advisor===true,advisorOrgId:metadata["advisor_org_id"]??null,advisorOrgSlug:metadata["advisor_org_slug"]??null}}var DevUserManager=class extends LayerManager{get shape(){return{fields:{type:{type:"enum",values:["advisor","client"],required:true},email:{type:"string",required:true,minLength:3},name:{type:"string",required:true,minLength:1},first_name:{type:"string",required:true,minLength:1},last_name:{type:"string",required:true,minLength:1},user_metadata:{type:"object"},org_memberships:{type:"array",required:true},org_role:{type:"enum",values:["owner","editor"]},advisor_orgs:{type:"array"},client_record:{type:"object"}}}}naturalKeyOf(def){return def.email}naturalKeyFilter(def){return{email:def.email}}toRow(){throw new Error("DevUserManager writes via syncAll()")}devPassword(){return devPassword(this.options.targetEnv)}async findAuthUser(email){const perPage=200;for(let page=1;page<=50;page++){const{data,error}=await this.options.supabase.auth.admin.listUsers({page,perPage});if(error)throw new Error(`auth.admin.listUsers failed: ${error.message}`);const users=data?.users??[];const match=users.find(u=>u.email?.toLowerCase()===email.toLowerCase());if(match)return match.id;if(users.length<perPage)return null}return null}async ensureAuthUser(def){const existing=await this.findAuthUser(def.email);if(existing)return{id:existing,created:false};const{data,error}=await this.options.supabase.auth.admin.createUser({email:def.email,password:this.devPassword(),email_confirm:true,user_metadata:{name:def.name,role:def.type,...def.user_metadata??{}}});if(error)throw new Error(`auth.admin.createUser failed for ${def.email}: ${error.message}`);const id=data?.user?.id;if(!id)throw new Error(`no user id returned for ${def.email}`);return{id,created:true}}async orgIdFor(slug){if(slug===this.options.orgSlug)return this.options.orgId;const org=await findOrgBySlug(this.options.supabase,slug);return org?.id??null}async ensureOrgMember(userId,orgId,def){const{data,error}=await this.options.supabase.from("org_member").select("id").eq("user_id",userId).eq("org_id",orgId).limit(2);if(error)throw new Error(`org_member lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} org_member rows for this user+org \u2014 role resolution will throw. Run doctor.`)}if(rows.length===1){const{error:updateError}=await this.options.supabase.from("org_member").update({first_name:def.first_name,last_name:def.last_name,nick_name:def.first_name}).eq("id",rows[0].id);if(updateError)throw new Error(`org_member update failed: ${updateError.message}`);return}const{error:insertError}=await this.options.supabase.from("org_member").insert({user_id:userId,org_id:orgId,first_name:def.first_name,last_name:def.last_name,nick_name:def.first_name});if(insertError)throw new Error(`org_member insert failed: ${insertError.message}`)}async ensureOrgRole(userId,orgId,role){const{data,error}=await this.options.supabase.from("access_grant").select("id").eq("subject_kind","user").eq("subject_id",userId).eq("scope_kind","org").eq("scope_id",orgId).eq("role",role).is("revoked_at",null).limit(1);if(error)throw new Error(`access_grant lookup failed: ${error.message}`);if((data??[]).length)return;const{error:insertError}=await this.options.supabase.from("access_grant").insert({subject_kind:"user",subject_id:userId,role,scope_kind:"org",scope_id:orgId,purpose:"dev user (template seed)"});if(insertError)throw new Error(`access_grant insert (${role}) failed: ${insertError.message}`)}async ensureAdvisor(userId,def){const primarySlug=def.org_memberships[0];if(!primarySlug)throw new Error(`advisor ${def.name} has no org_memberships`);const primaryOrgId=await this.orgIdFor(primarySlug);if(!primaryOrgId)throw new Error(`cannot resolve primary org "${primarySlug}"`);const{data,error}=await this.options.supabase.from("advisor").select("id").eq("user_id",userId).eq("org_id",primaryOrgId).limit(2);if(error)throw new Error(`advisor lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} advisor rows for this user+org \u2014 role resolution will throw. Run doctor.`)}let advisorId;if(rows.length===1){advisorId=rows[0].id;await this.options.supabase.from("advisor").update({name:def.name,email:def.email}).eq("id",advisorId)}else{const{data:inserted,error:insertError}=await this.options.supabase.from("advisor").insert({user_id:userId,org_id:primaryOrgId,name:def.name,email:def.email}).select("id").single();if(insertError)throw new Error(`advisor insert failed: ${insertError.message}`);advisorId=inserted.id}for(const slug of def.advisor_orgs??[]){const linkedOrgId=await this.orgIdFor(slug);if(!linkedOrgId){console.warn(`[template] \u26A0 advisor_orgs: "${slug}" not found in ${this.options.targetEnv} \u2014 skipping the link. Seed that org, then re-run.`);continue}const{data:link}=await this.options.supabase.from("advisor_org").select("id").eq("advisor_id",advisorId).eq("org_id",linkedOrgId).maybeSingle();if(!link){const{error:linkError}=await this.options.supabase.from("advisor_org").insert({advisor_id:advisorId,org_id:linkedOrgId});if(linkError)throw new Error(`advisor_org insert failed: ${linkError.message}`)}}return advisorId}async ensureClient(userId,def){const record=def.client_record;if(!record)throw new Error(`client ${def.name} has no client_record`);const orgSlug=def.org_memberships[0]??this.options.orgSlug;const orgId=await this.orgIdFor(orgSlug);if(!orgId)throw new Error(`cannot resolve client org "${orgSlug}"`);const data={...record.metadata??{}};if(record.slug)data.slug=record.slug;const{data:found,error}=await this.options.supabase.from("client").select("id, data, user_id").eq("org_id",orgId).eq("name",record.name);if(error)throw new Error(`client lookup failed: ${error.message}`);let rows=found??[];if(rows.length>1){const mine=rows.filter(r=>r.user_id===userId);if(mine.length===1){console.warn(`[template] \u26A0 ${rows.length} client rows named "${record.name}" in this org; using the one already linked to ${def.email} (${mine[0].id}). Run doctor \u2014 the others are strays.`);rows=mine}else{throw new Error(`${rows.length} client rows named "${record.name}" in this org and ${mine.length} linked to ${def.email} \u2014 ambiguous, refusing to guess. Run doctor for the ids.`)}}if(rows[0]){const merged={...rows[0].data??{},...data};const{error:updateError}=await this.options.supabase.from("client").update({user_id:userId,data:merged,description:record.description??null}).eq("id",rows[0].id);if(updateError)throw new Error(`client update failed: ${updateError.message}`);return rows[0].id}const{data:inserted,error:insertError}=await this.options.supabase.from("client").insert({org_id:orgId,name:record.name,description:record.description??null,user_id:userId,data}).select("id").single();if(insertError)throw new Error(`client insert failed: ${insertError.message}`);return inserted.id}async fetchDbKeys(){const out=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const id=await this.findAuthUser(def.email);if(id)out.set(def.email,id)}return out}async healthChecks(){const{data,error}=await this.options.supabase.from("client").select("id, name, user_id").eq("org_id",this.options.orgId);if(error){return[{name:"dev-user-clients",severity:"warn",message:`lookup failed: ${error.message}`}]}const rows=data??[];const byName=new Map;for(const row of rows){byName.set(row.name,[...byName.get(row.name)??[],{id:row.id,user_id:row.user_id}])}const managed=new Set;for(const file of this.files()){try{const def=this.loadFile(file);if(def.client_record?.name)managed.add(def.client_record.name)}catch{}}const blocking=[];let unmanagedDuplicates=0;for(const[name,group]of byName){if(group.length<2)continue;if(!managed.has(name)){unmanagedDuplicates++;continue}const linked=group.filter(r=>r.user_id);if(linked.length===1)continue;blocking.push(`${name}: ${group.length} rows, ${linked.length} linked \u2014 ${group.map(r=>r.id).join(", ")}`)}const checks=[blocking.length===0?{name:"dev-user-clients",severity:"ok",message:`${managed.size} managed client(s), all resolvable`}:{name:"dev-user-clients",severity:"fail",message:`${blocking.length} managed client name(s) ambiguous \u2014 dev-users sync will refuse them`,detail:blocking}];if(unmanagedDuplicates>0){checks.push({name:"client-name-duplicates",severity:"warn",message:`${unmanagedDuplicates} client name(s) outside this layer appear more than once (bulk-imported member records; client has no unique constraint on (org_id, name)). Informational \u2014 a duplicate import or two members sharing a name.`})}return checks}async syncAll(){const started=Date.now();const entities=[];const errors=[];if(this.options.targetEnv==="prod"){return{layer:this.def.name,created:0,updated:0,skipped:1,entities:[{entity:"dev-users",action:"skipped",id:null,reason:"dev fixtures never run against prod"}],errors,duration_ms:Date.now()-started}}const files=this.files();const ordered=[...files.filter(f=>f.startsWith("advisor-")),...files.filter(f=>!f.startsWith("advisor-"))];for(const file of ordered){try{const def=this.loadFile(file);if(this.options.dryRun){const existing=await this.findAuthUser(def.email);entities.push({entity:`${def.type}:${def.email}`,action:existing?"updated":"created",id:existing??"(new)"});continue}const user=await this.ensureAuthUser(def);for(const slug of def.type==="client"?[]:def.org_memberships){const orgId=await this.orgIdFor(slug);if(!orgId){console.warn(`[template] \u26A0 org_memberships: "${slug}" not found in ${this.options.targetEnv} \u2014 skipping membership.`);continue}await this.ensureOrgMember(user.id,orgId,def)}if(def.org_role&&def.type!=="client"){const orgId=await this.orgIdFor(def.org_memberships[0]??"");if(!orgId)throw new Error(`org_role needs its org: "${def.org_memberships[0]}" not found`);await this.ensureOrgRole(user.id,orgId,def.org_role)}if(def.type==="advisor"){const advisorId=await this.ensureAdvisor(user.id,def);this.mappings.set("advisor",def.email,advisorId)}else{const clientId=await this.ensureClient(user.id,def);this.mappings.set("client",def.email,clientId);const slug=def.client_record?.slug;if(slug)this.mappings.set("client",slug,clientId)}entities.push({entity:`${def.type}:${def.email}`,action:user.created?"created":"updated",id:user.id})}catch(error){errors.push({entity:file,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};var DevClientDataManager=class extends LayerManager{get shape(){return{fields:{client:{type:"string",required:true,minLength:1},entries:{type:"array",required:true}},ignored:["_comment"]}}naturalKeyOf(def){return def.client}naturalKeyFilter(def){return{org_id:this.options.orgId,client:def.client}}toRow(){throw new Error("DevClientDataManager writes via syncAll()")}async fetchDbKeys(){const out=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const clientId=await this.resolveClientId(def.client);if(clientId)out.set(def.client,clientId)}return out}async resolveClientId(reference){const bySlug=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("data->>slug",reference).limit(2);if(bySlug.error)throw new Error(`client lookup failed: ${bySlug.error.message}`);const slugRows=bySlug.data??[];if(slugRows.length>1){throw new Error(`${slugRows.length} clients have data->>slug="${reference}" \u2014 ambiguous.`)}if(slugRows[0])return slugRows[0].id;const byName=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("name",reference).limit(2);if(byName.error)throw new Error(`client lookup failed: ${byName.error.message}`);const nameRows=byName.data??[];if(nameRows.length>1){throw new Error(`${nameRows.length} clients named "${reference}" \u2014 ambiguous.`)}return nameRows[0]?.id??null}async resolveDataTypeId(name){const{data,error}=await this.options.supabase.from("client_data_type").select("id, data_format").eq("org_id",this.options.orgId).eq("name",name).limit(2);if(error)throw new Error(`client_data_type lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} client_data_type rows named "${name}" in this org \u2014 ambiguous. That table has no unique constraint on (org_id, name); run doctor for the ids.`)}const row=rows[0];return row?{id:row.id,format:row.data_format}:null}async syncAll(){const started=Date.now();const entities=[];const errors=[];let created=0;let updated=0;let skipped=0;if(this.options.targetEnv==="prod"){return{layer:this.def.name,created:0,updated:0,skipped:1,entities:[{entity:"dev-client-data",action:"skipped",id:null,reason:"dev fixtures never run against prod"}],errors,duration_ms:Date.now()-started}}for(const file of this.files()){try{const def=this.loadFile(file);const clientId=await this.resolveClientId(def.client);if(!clientId){entities.push({entity:def.client,action:"skipped",id:null,reason:`no client matches "${def.client}" \u2014 seed dev-users first:
|
|
3
|
+
- ${result.errors.join("\n - ")}`)}if(!result.value.advisor&&!result.value.advisor_org_slug){console.warn("[template] config/org.json: client org has no advisor_org_slug \u2014 the org layer will not link an advisor org.")}cachedOrgConfig=result.value;return result.value}var cachedTemplatesConfig=null;function loadTemplatesConfig(){if(cachedTemplatesConfig)return cachedTemplatesConfig;const path=join(CONFIG_DIR,"templates.json");if(!existsSync2(path)){cachedTemplatesConfig={org_template:null,project_templates:[]};return cachedTemplatesConfig}const parsed=JSON.parse(readFileSync2(path,"utf8"));const outer=validateShape(parsed,{fields:{org_template:{type:"any"},project_templates:{type:"array",default:[]},form_template_process:{type:"string"},form_bundle_refs:{type:"array"}},ignored:["_comment"]});if(!outer.ok||!outer.value){throw new Error(`template/config/templates.json is invalid:
|
|
4
|
+
- ${outer.errors.join("\n - ")}`)}let orgTemplate=null;if(outer.value.org_template){const result=validateShape(outer.value.org_template,{fields:{slug:{type:"string",required:true,minLength:1},bundle_name:{type:"string",default:"main"},layout_bundles:{type:"object"}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`templates.json org_template is invalid: ${result.errors.join("; ")}`)}orgTemplate=result.value}const projectTemplates=[];(outer.value.project_templates??[]).forEach((raw,index)=>{const result=validateShape(raw,{fields:{slug:{type:"string",required:true,minLength:1},bundle_name:{type:"string",default:"main"},skip_pages:{type:"boolean",default:false},page_template_process:{type:"string",minLength:36}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`templates.json project_templates[${index}] is invalid: ${result.errors.join("; ")}`)}const pageProcess=result.value.page_template_process;if(pageProcess!==void 0&&!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(pageProcess)){throw new Error(`templates.json project_templates[${index}].page_template_process must be a process id (a uuid)`)}projectTemplates.push(pageProcess===void 0?result.value:{...result.value,page_template_process:pageProcess.toLowerCase()})});const formProcess=outer.value.form_template_process;if(formProcess!==void 0&&!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(formProcess)){throw new Error("templates.json form_template_process must be a process id (a uuid)")}const formRefs=outer.value.form_bundle_refs;cachedTemplatesConfig={org_template:orgTemplate,project_templates:projectTemplates,...formProcess?{form_template_process:formProcess.toLowerCase()}:{},...formRefs?{form_bundle_refs:formRefs}:{}};return cachedTemplatesConfig}function orgTemplatePath(slug){return join(REPO_ROOT,"src","org_templates",slug,"template.json")}function projectTemplateDir(slug){return join(REPO_ROOT,"src","project_templates",slug)}function projectTemplatePages(slug){const pagesDir=join(projectTemplateDir(slug),"pages");if(!existsSync2(pagesDir))return[];return readdirSync(pagesDir,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>({page:e.name,path:join(pagesDir,e.name,"page.json")})).filter(p=>existsSync2(p.path)).sort((a,b)=>a.page.localeCompare(b.page))}function layerDir(sourceDir){return sourceDir?join(TEMPLATE_ROOT,sourceDir):null}function listJsonFiles(dir){if(!dir||!existsSync2(dir))return[];return readdirSync(dir).filter(f=>f.endsWith(".json")).sort()}function readJsonFile(path){try{return JSON.parse(readFileSync2(path,"utf8"))}catch(error){throw new Error(`${path}: not valid JSON \u2014 ${error instanceof Error?error.message:String(error)}`)}}import{join as join2}from"node:path";var LIST_KEY="bundle_refs";var SINGLE_KEY="bundle_ref";function describe(value){if(value===null)return"null";if(Array.isArray(value))return"an array";return typeof value}function refError(ref2,path,orgSlug){if(typeof ref2==="string"){if(ref2===orgSlug)return null;return`${path}: bare-string bundle ref "${ref2}" is an ORG SLUG, and it is not this org's ("${orgSlug}") \u2014 did you mean { "bundle": "${ref2}" }? A bare string resolves to that org's main bundle, so a lineage name here silently falls through to main and the page renders with the wrong components. For a deliberate cross-org ref, name the lineage explicitly: { "bundle": "<lineage>", "org_slug": "${ref2}" }.`}if(typeof ref2!=="object"||ref2===null||Array.isArray(ref2)){return`${path}: a bundle ref must be a string or an object, got ${describe(ref2)}`}const obj=ref2;if("bundle"in obj){if(typeof obj.bundle!=="string"||obj.bundle.length===0){return`${path}: { "bundle": \u2026 } must name a lineage as a non-empty string, got ${describe(obj.bundle)}`}if("org_slug"in obj&&typeof obj.org_slug!=="string"){return`${path}: "org_slug" must be a string when present, got ${describe(obj.org_slug)}`}return null}if("bundle_id"in obj){if(typeof obj.bundle_id!=="string"||obj.bundle_id.length===0){return`${path}: { "bundle_id": \u2026 } must be a non-empty string`}return null}return`${path}: object bundle ref has neither "bundle" nor "bundle_id" (keys: ${Object.keys(obj).join(", ")||"none"}) \u2014 the union is string | { bundle_id } | { bundle, org_slug? }`}function bundleRefErrors(def,orgSlug){const errors=[];let visited=0;const walk=(node,path)=>{if(Array.isArray(node)){node.forEach((item,i)=>walk(item,`${path}[${i}]`));return}if(typeof node!=="object"||node===null)return;for(const[key,value]of Object.entries(node)){const here=path?`${path}.${key}`:key;if(key===LIST_KEY){if(!Array.isArray(value)){errors.push(`${here}: "${LIST_KEY}" must be an array, got ${describe(value)}`);continue}value.forEach((ref2,i)=>{visited+=1;const error=refError(ref2,`${here}[${i}]`,orgSlug);if(error)errors.push(error)});continue}if(key===SINGLE_KEY){if(value===void 0||value===null)continue;visited+=1;const error=refError(value,here,orgSlug);if(error)errors.push(error);continue}walk(value,here)}};walk(def,"");return{errors,visited}}function describeKey(naturalKey){return Object.entries(naturalKey).map(([k,v])=>`${k}=${v===null?"NULL":JSON.stringify(v)}`).join(", ")}async function upsertByNaturalKey(supabase,table,naturalKey,record,options={}){let query=supabase.from(table).select("id");for(const[key,value]of Object.entries(naturalKey)){query=value===null?query.is(key,null):query.eq(key,value)}const{data:existing,error:selectError}=await query.limit(2);if(selectError){throw new Error(`lookup failed on ${table} (${describeKey(naturalKey)}): ${selectError.message}`)}const rows=existing??[];if(rows.length>1){throw new Error(`${table} has at least ${rows.length} rows matching ${describeKey(naturalKey)} \u2014 ambiguous, refusing to write. Ids: ${rows.map(r=>r.id).join(", ")}. ${table==="client_data_type"?"This table has no unique constraint on its natural key, so duplicates are possible. ":""}Resolve the duplicate in the database first (see \`template/cli doctor\`).`)}const current=rows[0];if(current){if(options.dryRun)return{id:current.id,action:"updated"};const{data:data2,error:error2}=await supabase.from(table).update(record).eq("id",current.id).select("id").single();if(error2)throw new Error(`update failed on ${table} id=${current.id}: ${error2.message}`);return{id:data2.id,action:"updated"}}if(options.dryRun)return{id:"(new)",action:"created"};const{data,error}=await supabase.from(table).insert([record]).select("id").single();if(error){throw new Error(`insert failed on ${table} (${describeKey(naturalKey)}): ${error.message}`)}return{id:data.id,action:"created"}}var LayerManager=class{constructor(def,options,mappings){this.def=def;this.options=options;this.mappings=mappings}def;options;mappings;mappingEntities(){const primary=this.def.mappingEntity?[this.def.mappingEntity]:[];return[...primary,...this.def.mappingEntities??[]]}dir(){return layerDir(this.def.sourceDir)}duplicateDbKeys=new Set;bundleRefsVisited=0;files(){const files=listJsonFiles(this.dir());const filter=this.options.nameFilter;if(!filter)return files;return files.filter(f=>f===`${filter}.json`||f.replace(/\.json$/,"")===filter)}fileKeys(){const keys=[];for(const file of this.files()){try{keys.push(this.naturalKeyOf(this.loadFile(file)))}catch{}}return keys}loadFile(fileName){const dir=this.dir();if(!dir)throw new Error(`layer ${this.def.name} has no source directory`);const raw=readJsonFile(join2(dir,fileName));const result=validateShape(raw,this.shape);if(!result.ok||!result.value){throw new Error(result.errors.join("; "))}return result.value}extraIssues(_def){return{errors:[],warnings:[]}}validateAll(){this.bundleRefsVisited=0;const errors=[];const warnings=[];const files=this.files();const seen=new Map;for(const file of files){try{const def=this.loadFile(file);const refs=bundleRefErrors(def,this.options.orgSlug);this.bundleRefsVisited+=refs.visited;for(const message of refs.errors)errors.push({file,message});const extra=this.extraIssues(def);for(const message of extra.errors)errors.push({file,message});for(const message of extra.warnings)warnings.push({file,message});const key=this.naturalKeyOf(def);const previous=seen.get(key);if(previous){errors.push({file,message:`natural key "${key}" already declared by ${previous} \u2014 two files would fight over one row`})}else{seen.set(key,file)}}catch(error){errors.push({file,message:error instanceof Error?error.message:String(error)})}}if(files.length===0&&this.def.sourceDir&&!this.options.nameFilter){errors.push({file:this.dir()??"(layer)",message:`this layer declares sourceDir "${this.def.sourceDir}" and found NO files there. Every check in this layer runs per file, so zero files is a green run that verified nothing \u2014 a moved or unchecked-out directory, not a clean one.`})}return{layer:this.def.name,filesChecked:files.length,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];for(const file of this.files()){try{const def=this.loadFile(file);const key=this.naturalKeyOf(def);const outcome=await upsertByNaturalKey(this.options.supabase,this.def.table,this.naturalKeyFilter(def),this.toRow(def),{dryRun:this.options.dryRun});if(this.def.mappingEntity&&!this.options.dryRun){this.mappings.set(this.def.mappingEntity,key,outcome.id)}entities.push({entity:key,action:outcome.action,id:outcome.id})}catch(error){errors.push({entity:file,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}async status(){const files=this.files();const base={layer:this.def.name,kind:this.def.kind,implemented:this.def.implemented,files:files.length,dbRows:null,mappedIds:this.mappingEntities().reduce((sum,e)=>sum+this.mappings.count(e),0),missingInDb:[],missingInFiles:[],unmappedInDb:[]};if(!this.def.implemented)return base;const fileKeys=new Set(this.fileKeys());try{const dbKeys=await this.fetchDbKeys();base.dbRows=dbKeys.size;base.missingInDb=[...fileKeys].filter(k=>!dbKeys.has(k)).sort();base.missingInFiles=[...dbKeys.keys()].filter(k=>!fileKeys.has(k)).sort();if(this.def.mappingEntity){const mapped=new Set(this.mappings.keys(this.def.mappingEntity));base.unmappedInDb=[...dbKeys.keys()].filter(k=>fileKeys.has(k)&&!mapped.has(k)).sort()}}catch(error){base.error=error instanceof Error?error.message:String(error)}return base}async healthChecks(){return[]}async orphans(){return[]}async pull(){const started=Date.now();const entities=[];const errors=[];if(!this.def.implemented||!this.def.mappingEntity){return{layer:this.def.name,created:0,updated:0,skipped:0,entities,errors,duration_ms:Date.now()-started}}try{const dbKeys=await this.fetchDbKeys();for(const key of this.fileKeys()){const id=dbKeys.get(key);if(!id){entities.push({entity:key,action:"skipped",id:null,reason:"not in database"});continue}if(this.duplicateDbKeys.has(key)){entities.push({entity:key,action:"skipped",id:null,reason:"ambiguous \u2014 more than one row shares this natural key; resolve the duplicate before mapping it (see `doctor`)"});continue}const before=this.mappings.getId(this.def.mappingEntity,key);this.mappings.set(this.def.mappingEntity,key,id);entities.push({entity:key,action:before===id?"skipped":"updated",id})}}catch(error){errors.push({entity:this.def.name,error:error instanceof Error?error.message:String(error)})}return{layer:this.def.name,created:0,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};var CONTRACT=["src/types/host-contract.generated.ts","src/types/host-contract.generated.js"].map(f=>join3(REPO_ROOT,f)).find(f=>existsSync3(f));var AGENT_TOOL_NAMES=CONTRACT?(await import(pathToFileURL(CONTRACT).href)).AGENT_TOOL_NAMES??[]:[];var AgentTemplateManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},is_template:{type:"boolean",default:true},prompt:{type:"any",required:true},tools:{type:"any",required:true},model:{type:"any"},output_schema:{type:"any"},skills:{type:"any"},metadata:{type:"object"}}}}extraIssues(def){const errors=[];const warnings=[];const tools=def.tools;if(tools!==void 0){const shape=tools;if(typeof tools!=="object"||tools===null||Array.isArray(tools)){errors.push(`"tools" must be an object carrying a toolNames array \u2014 the host keeps platform-agent's ADVISOR tools for any other shape, silently`)}else if(!Array.isArray(shape?.toolNames)){errors.push(`"tools.toolNames" must be an array \u2014 without it the host keeps platform-agent's ADVISOR tools (createEvent, updateEvent, submitProcessJob), silently, and this row's own tools are never applied`)}else if(shape.toolNames.some(t=>typeof t!=="string"||t.trim()==="")){errors.push(`"tools.toolNames" must contain non-empty strings \u2014 the host matches them by exact name against its tool registry, so a blank entry silently selects nothing`)}else if(AGENT_TOOL_NAMES.length>0){const known=new Set(AGENT_TOOL_NAMES);const names=shape.toolNames;const unknown=names.filter(t=>!known.has(t));if(unknown.length>0&&unknown.length<names.length){errors.push(`"tools.toolNames" names ${unknown.length} tool(s) of ${names.length} that the host does not have: ${unknown.join(", ")}. The runtime matches by exact name and drops the rest silently, so this agent would run with fewer tools than it declares. Valid names are in AGENT_TOOL_NAMES (host contract 2.15.0+)`)}else if(unknown.length===names.length){warnings.push(`none of this agent's ${names.length} tool name(s) are in the host's chat-agent registry, which reads as a BEA or other non-host runtime rather than a typo \u2014 every name would have to be wrong for a typo to look like this. Not checkable here: only the host chat runtime publishes its tool names (AGENT_TOOL_NAMES, contract 2.15.0+)`)}}}const prompt=def.prompt;if(prompt!==void 0){const shape=prompt;if(typeof prompt!=="object"||prompt===null||Array.isArray(prompt)){errors.push(`"prompt" must be an object with a "system" string \u2014 the host keeps platform-agent's ADVISOR system prompt for any other shape`)}else if(typeof shape?.system!=="string"||shape.system.trim()===""){errors.push(`"prompt.system" must be a non-empty string \u2014 without it the host keeps platform-agent's ADVISOR system prompt ("assistant for financial advisors")`)}}return{errors,warnings}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name,is_template:true}}toRow(def){const row={org_id:this.options.orgId,name:def.name,is_template:true,archived:false,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.prompt!==void 0)row.prompt=def.prompt;if(def.tools!==void 0)row.tools=def.tools;if(def.model!==void 0)row.model=def.model;if(def.output_schema!==void 0)row.output_schema=def.output_schema;if(def.skills!==void 0)row.skills=def.skills;if(def.metadata!==void 0)row.metadata=def.metadata;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("agent").select("id, name").eq("org_id",this.options.orgId).eq("is_template",true).eq("archived",false);if(error)throw new Error(`agent lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){if(row.name)out.set(row.name,row.id)}return out}};var DataTypeManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},data_format:{type:"enum",values:["text","json","file"],default:"text"},schema:{type:"any"}},ignored:["org_id","org_name"]}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(def){const row={org_id:this.options.orgId,name:def.name,data_format:def.data_format,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.schema!==void 0)row.schema=def.schema;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("client_data_type").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`client_data_type lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){out.set(row.name,row.id)}return out}};import{existsSync as existsSync4,readdirSync as readdirSync2}from"node:fs";import{join as join4}from"node:path";var ORG_OVERRIDE_DIR=join4(DATA_DIR,"event-types");var DECLARATIONS=["kind","visible_to","creatable_by","data_schema","retention","requires_consent","hooks","components","calendar_export","access_logged"];var EventTypeManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},icon:{type:"string"},color:{type:"string"},category:{type:"string"},display_order:{type:"number"},metadata:{type:"object"},kind:{type:"enum",values:["scheduled","record"]},visible_to:{type:"array"},creatable_by:{type:"array"},data_schema:{type:"object"},retention:{type:"string"},requires_consent:{type:"boolean"},hooks:{type:"array"},components:{type:"object"},calendar_export:{type:"enum",values:["none","busy","details"]},access_logged:{type:"boolean"}}}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(def){const row={org_id:this.options.orgId,name:def.name,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.icon!==void 0)row.icon=def.icon;if(def.color!==void 0)row.color=def.color;if(def.metadata!==void 0)row.metadata=def.metadata;for(const key of DECLARATIONS)if(def[key]!==void 0)row[key]=def[key];return row}orgFiles(){if(!existsSync4(ORG_OVERRIDE_DIR))return[];return readdirSync2(ORG_OVERRIDE_DIR).filter(f=>f.endsWith(".json")).sort()}resolvePath(fileName){const orgPath=join4(ORG_OVERRIDE_DIR,fileName);return existsSync4(orgPath)?orgPath:join4(this.dir()??"",fileName)}files(){const merged=new Set([...super.files(),...this.orgFiles()]);const all=[...merged].sort();const filter=this.options.nameFilter;if(!filter)return all;return all.filter(f=>f===`${filter}.json`||f.replace(/\.json$/,"")===filter)}loadFile(fileName){const raw=readJsonFile(this.resolvePath(fileName));const result=validateShape(raw,this.shape);if(!result.ok||!result.value)throw new Error(result.errors.join("; "));return result.value}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("event_type").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`event_type: ${error.message}`);const keys=new Map;for(const row of data??[]){keys.set(row.name,row.id)}return keys}};var FeedProducerManager=class extends LayerManager{get shape(){return{fields:{key:{type:"string",required:true,minLength:1},name:{type:"string",required:true,minLength:1},description:{type:"string"},kind:{type:"enum",values:["process","system","manual","user"],default:"process"},is_default:{type:"boolean",default:false},config:{type:"object",default:{}},status:{type:"enum",values:["active","paused","retired"],default:"active"},metadata:{type:"object"},source_table:{type:"string"},source_id:{type:"string"}}}}naturalKeyOf(def){return def.key}naturalKeyFilter(def){return{org_id:this.options.orgId,key:def.key}}toRow(def){const row={org_id:this.options.orgId,key:def.key,name:def.name,kind:def.kind,is_default:def.is_default,config:def.config,status:def.status,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.metadata!==void 0)row.metadata=def.metadata;if(def.source_table!==void 0)row.source_table=def.source_table;if(def.source_id!==void 0)row.source_id=def.source_id;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("feed_producer").select("id, key").eq("org_id",this.options.orgId);if(error){throw new Error(`feed_producer lookup failed: ${error.message}. This layer needs the 20260804000000_feed_system migration.`)}const out=new Map;for(const row of data??[]){out.set(row.key,row.id)}return out}};import{join as join5}from"node:path";import{createRequire}from"node:module";import{pathToFileURL as pathToFileURL2}from"node:url";import Ajv2020 from"ajv/dist/2020.js";var question_schema_min_default={anyOf:[{$ref:"#/$defs/question"},{$ref:"#/$defs/questionGroup"}],$defs:{question:{type:"object",properties:{key:{type:"string",pattern:"^[A-Za-z][A-Za-z0-9_.-]{0,119}$"},type:{type:"string",enum:["text","textarea","number","date","select","radio","multiselect","file"]},label:{type:"string",minLength:1,maxLength:1e3},required:{type:"boolean"},options:{anyOf:[{minItems:1,maxItems:500,type:"array",items:{type:"object",properties:{value:{anyOf:[{type:"string",maxLength:200},{type:"number"}]},label:{type:"string",minLength:1,maxLength:500}},required:["value","label"],additionalProperties:false}},{type:"null"}]},validation:{anyOf:[{type:"object",properties:{min:{type:"number"},max:{type:"number"}},additionalProperties:false},{type:"null"}]},default:{},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]},description:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},helpText:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},placeholder:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},content_before:{anyOf:[{maxItems:50,type:"array",items:{$ref:"#/$defs/contentBlock"}},{type:"null"}]},content_after:{anyOf:[{maxItems:50,type:"array",items:{$ref:"#/$defs/contentBlock"}},{type:"null"}]},upload:{anyOf:[{type:"object",properties:{instructions:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},acceptedFileTypes:{maxItems:50,type:"array",items:{type:"string",minLength:1,maxLength:100}},maxFileSizeMB:{type:"number",exclusiveMinimum:0,maximum:100},allowCameraCapture:{type:"boolean"},requirePersonalVerification:{type:"boolean"},uploadZoneLabel:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},uploadZoneSubLabel:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},successMessage:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]}},additionalProperties:false},{type:"null"}]}},required:["key","type","label","required"],additionalProperties:false,allOf:[{if:{properties:{type:{enum:["select","radio","multiselect"]}}},then:{required:["options"],properties:{options:{type:"array",minItems:1}}},else:{properties:{options:{type:"null"}}}},{if:{properties:{type:{enum:["file"]}}},else:{properties:{upload:{type:"null"}}}},{if:{properties:{type:{enum:["number"]}}},else:{properties:{validation:{type:"null"}}}}]},condition:{type:"object",properties:{all:{maxItems:50,type:"array",items:{type:"object",properties:{q:{type:"string",minLength:1,maxLength:120},op:{type:"string",enum:["eq","ne","in","not_in","gt","lt","gte","lte","contains","not_contains","empty","not_empty"]},value:{}},required:["q","op"],additionalProperties:false}},not:{type:"boolean"}},required:["all"],additionalProperties:false},contentBlock:{type:"object",properties:{id:{type:"string",minLength:1,maxLength:120},type:{type:"string",minLength:1,maxLength:60},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]}},required:["id","type"],additionalProperties:{}},questionGroup:{type:"object",properties:{group:{type:"boolean",const:true},label:{anyOf:[{type:"string",maxLength:1e3},{type:"null"}]},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]},questions:{minItems:1,maxItems:100,type:"array",items:{$ref:"#/$defs/question"}}},required:["group","questions"],additionalProperties:false}}};var check;function questionProblems(state){check??=new Ajv2020({strict:false,allErrors:true}).compile(question_schema_min_default);const out=[];if(!check(state)){for(const e of check.errors??[])if(e.keyword!=="if"&&e.keyword!=="anyOf")out.push(`${e.instancePath||"/"} ${e.message}`);if(!out.length)out.push("doesn\u2019t match the form-definition contract")}const s=state;const v=s?.validation;if(typeof v?.min==="number"&&typeof v?.max==="number"&&v.min>v.max)out.push("QUESTION_VALIDATION: min is greater than max");if(s?.group&&Array.isArray(s.questions)){const keys=s.questions.map(q=>q?.key).filter(Boolean);const dup=keys.find((k,i)=>keys.indexOf(k)!==i);if(dup)out.push(`QUESTION_KEY_DUPLICATE: ${dup} appears twice in the group`)}return out}var byOrder=xs=>[...xs??[]].sort((a,b)=>Number(a.orderIndex??0)-Number(b.orderIndex??0));var ref=key=>`@q_${key}`;var and=(a,b)=>{if(!a)return b??void 0;if(!b)return a;if(!a.not&&!b.not)return{all:[...a.all,...b.all]};return void 0};function formSpecOf(label,file,deps){const problems=[];const key=String(file.template_id??"");if(!key)problems.push(`${label}: a form template needs a template_id (its template_key)`);const when=(raw,where)=>{const{condition,problems:p}=deps.normalize(typeof raw==="string"?raw:deps.decode(raw));for(const x of p)problems.push(`${label}: ${where}: ${x}`);return condition??void 0};const questions=[];const seenKeys=new Map;const question=(k,name,state,where)=>{const prior=seenKeys.get(k);if(prior)problems.push(`${label}: ${where}: question key "${k}" is also ${prior}'s`);seenKeys.set(k,where);questions.push({key:k,name,state:{key:k,...state}})};const cells=[];const steps=[];const chapters=[];const stepIds=new Set;for(const page of byOrder(file.pages)){chapters.push({id:page.pageId,title:page.title});const pageWhen=when(page.conditionalLogic,`page ${page.pageId}`);for(const section of byOrder(page.sections)){const sid=section.sectionId;if(stepIds.has(sid))problems.push(`${label}: section id "${sid}" is used twice; step ids are deep links`);stepIds.add(sid);const ids=[];const cell=c=>(cells.push(c),ids.push(c.id));const content=(suffix,value)=>{if(value!==void 0&&value!==null&&!(Array.isArray(value)&&!value.length))cell({id:`${sid}-${suffix}`,component:"SectionContent",props:{content:value}})};content("before",section.contentBefore);const type=section.sectionType??"standard";const fields=byOrder(section.fields);const upload=type==="upload"?section.uploadConfig??{}:null;const uploadKey=typeof upload?.fieldId==="string"?upload.fieldId:"";for(const f of fields){const where=`field ${f.fieldId}`;const isUpload=upload&&f.fieldId===uploadKey;question(f.fieldId,f.label,{type:isUpload?"file":f.fieldType,...isUpload?{upload:(({fieldId:_f,...rest})=>rest)(upload)}:{},label:f.label,required:f.required===true,...f.options!=null?{options:deps.decode(f.options)}:{},...f.validation!=null?{validation:deps.decode(f.validation)}:{},...f.defaultValue!=null?{default:f.defaultValue}:{},...f.conditionalLogic!=null?(()=>{const w2=when(f.conditionalLogic,where);return w2?{when:w2}:{}})():{},...f.description!=null?{description:f.description}:{},...f.helpText!=null?{helpText:f.helpText}:{},...f.placeholder!=null?{placeholder:f.placeholder}:{},...f.contentBefore!=null?{content_before:f.contentBefore}:{},...f.contentAfter!=null?{content_after:f.contentAfter}:{}},where)}const extras=[["reviewConfig","SectionReview"],["advisorReviewConfig","SectionAdvisorReview"],["statusConfig","SectionStatus"]];switch(type){case"standard":if(fields.length)cell({id:sid,component:"SectionQuestions",props:{questions:fields.map(f=>ref(f.fieldId))}});break;case"content":cell({id:sid,component:"SectionContent",props:{content:section.content??null}});break;case"review":case"advisor-review":case"status":{const[prop,component]=extras.find(([,c])=>c==={review:"SectionReview","advisor-review":"SectionAdvisorReview",status:"SectionStatus"}[type]);cell({id:sid,component,props:{config:section[prop]??null}});break}case"document":cell({id:sid,component:"SectionDocument",props:{config:section.documentConfig??null}});break;case"upload":{if(!uploadKey){problems.push(`${label}: section ${sid}: an upload needs uploadConfig.fieldId (its question key)`);break}if(!fields.some(f=>f.fieldId===uploadKey)){const{fieldId:_f,...rest}=upload;question(uploadKey,section.title,{type:"file",label:section.title,required:false,upload:rest},`upload ${sid}`)}cell({id:sid,component:"SectionQuestions",props:{questions:[...fields.map(f=>f.fieldId),...fields.some(f=>f.fieldId===uploadKey)?[]:[uploadKey]].map(ref)}});break}case"cell":{const c=section.cellConfig;if(!c?.component)problems.push(`${label}: section ${sid}: a cell section needs cellConfig.component`);else cell({...c,id:sid});let n=0;for(const[prop,component]of extras)if(section[prop]!=null)cell({id:`${sid}-${++n}`,component,props:{config:section[prop]}});break}default:problems.push(`${label}: section ${sid}: unknown sectionType "${type}"`)}content("after",section.contentAfter);const sectionWhen=when(section.conditionalLogic,`section ${sid}`);const w=and(pageWhen,sectionWhen);if(pageWhen&§ionWhen&&!w)problems.push(`${label}: section ${sid}: a negated page and section condition can't be combined`);steps.push({id:sid,title:section.title,chapter:page.pageId,cell_ids:ids,...w?{when:w}:{}})}}for(const q of questions)for(const p of questionProblems(q.state))problems.push(`${label}: question ${q.key}: ${p}`);const meta=k=>file[k]!==void 0?{[k]:file[k]}:{};const metadata=file.metadata??{};const hasOwn=Object.prototype.hasOwnProperty.call(metadata,"bundle_refs");if(hasOwn&&!Array.isArray(metadata.bundle_refs))problems.push(`${label}: metadata.bundle_refs must be a list`);const chosen=hasOwn?Array.isArray(metadata.bundle_refs)?metadata.bundle_refs:[]:deps.bundleRefs??[];const bundleRefs=chosen.length?chosen:void 0;return{spec:{label,templateKey:key,name:file.template_name,pageType:`form:${key}`,model:{...meta("description"),...meta("template_type"),...file.profile_block_id!==void 0?{profile_block:{id:file.profile_block_id,name:file.profile_block_name??null}}:{},...meta("metadata")},...bundleRefs?{anchorModel:{bundle_refs:bundleRefs}}:{},questions,document:{cells,layout:{steps,chapters}}},problems}}var archiveSpecOf=templateKey=>({v:1,template_key:templateKey,archived:true});function writeSpecOf(spec){return{v:1,template_key:spec.templateKey,name:spec.name,...Object.keys(spec.model).length?{model:spec.model}:{},...spec.anchorModel?{anchor_model:spec.anchorModel}:{},questions:spec.questions.map(q=>({key:q.key,name:q.name,state:q.state})),page:{content:{cells:spec.document.cells},layout:spec.document.layout}}}async function writeFormTemplates(api,processId,specs){const out=[];for(const spec of specs)out.push({label:spec.label,templateKey:spec.templateKey,result:await api.write(processId,writeSpecOf(spec))});return out}async function archiveFormTemplates(api,processId,keys){const out=[];for(const k of keys){const result=await api.archive(processId,archiveSpecOf(k.templateKey));out.push({...k,result,changed:result.found!==false&&result.changed===true})}return out}var BACKOFF_SECONDS=[2,4,8,16,30,30,30,30,30,30,30,30];var TemplateReleaseError=class extends Error{constructor(message,code){super(message);this.code=code}code;name="TemplateReleaseError"};var codeOf=e=>/^(?:DENIED|INVALID|CONFLICT|FAILED):\s*([A-Z][A-Z0-9_]+)/.exec(e.message)?.[1];var detailOf=e=>{try{const v=e.details?JSON.parse(e.details):void 0;return v&&typeof v==="object"?v:void 0}catch{return void 0}};async function releaseTemplate(scopeNodeId,deps){const now=deps.now??Date.now;const sleep=deps.sleep??(ms=>new Promise(r=>setTimeout(r,ms)));const log=deps.log??(()=>{});let jobId;let queued=false;for(let attempt=0;;attempt++){if(now()>=deps.deadline)throw new TemplateReleaseError(`the token's time ran out waiting for the page refresh${jobId?` (job ${jobId})`:""}; nothing was published. Run again.`,"DEADLINE");const{data,error}=await deps.rpc("version_release",{p_scope_node_id:scopeNodeId});if(!error){const out=data;if(out.status==="cut"&&out.version_id){log(`release ${out.number} cut`);const moved=await deps.rpc("version_publish",{p_scope_node_id:scopeNodeId,p_channel:"library",p_release_id:out.version_id});if(moved.error)throw new TemplateReleaseError(`release ${out.number} was cut, but moving library to it was refused: ${moved.error.message}`,codeOf(moved.error));log(`library \u2192 release ${out.number}`);return{releaseId:out.version_id,number:out.number??0}}if(out.status==="refresh_queued"){if(queued)throw new TemplateReleaseError(`the pages were refreshed (job ${jobId}) but are still out of date (job ${out.job_id} queued again); nothing was published.`,"REQUEUED");queued=true;jobId=out.job_id;log(`page refresh queued (job ${jobId})`)}else{throw new TemplateReleaseError(`version_release returned ${JSON.stringify(out.status)}; nothing was published.`)}}else if(codeOf(error)==="VERSION_PAGE_STALE"){const detail=detailOf(error);jobId=typeof detail?.job_id==="string"?detail.job_id:jobId;if(Array.isArray(detail?.pages)){const reasons=detail.pages.filter(p=>!p.published).map(p=>p.reason).filter(Boolean);throw new TemplateReleaseError(`the page refresh (job ${jobId}) ran but didn't publish the pages${reasons.length?` (${[...new Set(reasons)].join(", ")})`:""}; nothing was published.`,"REFRESH_FAILED")}}else if(codeOf(error)==="VERSION_BUSY"){log("the template is busy with another write; retrying")}else{throw new TemplateReleaseError(`the release was refused: ${error.message}`,codeOf(error))}const wait=BACKOFF_SECONDS[Math.min(attempt,BACKOFF_SECONDS.length-1)]*1e3;await sleep(Math.min(wait,Math.max(0,deps.deadline-now())))}}var releaseWith=(client,scopeNodeId,deps)=>releaseTemplate(scopeNodeId,{...deps,rpc:(fn,args)=>client.rpc(fn,args)});var TemplateCallError=class extends Error{constructor(fn,message){super(`${fn}: ${message}`);this.fn=fn}fn;name="TemplateCallError"};var must=(fn,out)=>{if(out.error)throw new TemplateCallError(fn,out.error.message);return out.data};var idOf=(fn,data)=>{if(typeof data==="string")return data;const d=data;const id=d?.id??d?.state_id??d?.graph_node_id;if(typeof id!=="string")throw new TemplateCallError(fn,`returned no id (${JSON.stringify(data)})`);return id};function templateApi(db){return{anchors:async processId=>{const graphs=must("graph",await db.from("graph").select("id").eq("process_id",processId).eq("graph_type","process-eng"));const ids=(graphs??[]).map(g=>g.id);const out=new Map;if(!ids.length)return out;const rows=must("graph_node",await db.from("graph_node").select("id, node!inner(name, node_type, content)").in("graph",ids).eq("archived",false).is("version",null).eq("node.node_type","project_page"));for(const r of rows??[]){const type=r.node?.content?.page_type;if(!type)continue;if(out.has(type))throw new TemplateCallError("graph_node",`two live anchors for page type "${type}" in process ${processId}`);out.set(type,{anchorId:r.id,name:r.node.name??""})}return out},pageCreate:async({processId,name,pageType})=>idOf("page_create",must("page_create",await db.rpc("page_create",{p_project_id:null,p_name:name,p_page_type:pageType,p_process_id:processId}))),pageUpdate:async({anchorId,name,model})=>{must("page_update",await db.rpc("page_update",{p_page_id:anchorId,...name?{p_name:name}:{},p_model:model}))},pageArtifact:async anchorId=>{const id=must("page_artifact",await db.rpc("page_artifact",{p_anchor:anchorId}));if(!id)throw new TemplateCallError("page_artifact",`anchor ${anchorId} has no artifact`);return id},writeDocument:async({artifactId,data,fixture})=>idOf("graph_append_state",must("graph_append_state",await db.rpc("graph_append_state",{p_graph_node_id:artifactId,p_system:"data",p_data:data,...fixture?{p_metadata:{fixture:true}}:{}}))),setUpdate:async(graphNodeId,update)=>{must("graph_edit",await db.rpc("graph_edit",{p_graph_node_id:graphNodeId,p_patch:{update:null}}));if(update)must("graph_edit",await db.rpc("graph_edit",{p_graph_node_id:graphNodeId,p_patch:{update}}))},setAudience:async(anchorId,audience)=>{must("page_set_client",await db.rpc("page_set_client",{p_page_id:anchorId,p_on:audience==="client"}));if(audience==="client")return;const placed=must("graph_node",await db.from("graph_node").select("node, node_row:node(audience)").eq("id",anchorId).maybeSingle());if(!placed)throw new TemplateCallError("graph_node",`anchor ${anchorId} isn't readable`);const current=placed.node_row?.audience??null;if(current==="subject"||current===audience)return;must("node",await db.from("node").update({audience}).eq("id",placed.node).select("id").single())},scopeRoot:async processId=>{const id=must("template_scope_root",await db.rpc("template_scope_root",{p_process_id:processId}));if(!id)throw new TemplateCallError("template_scope_root",`process ${processId} has no eng root scope yet`);return id}}}function formTemplateApi(db){return{write:async(processId,spec)=>{const{data,error}=await db.rpc("form_template_write",{p_process:processId,p_spec:spec});if(error)throw new TemplateCallError("form_template_write",`${spec.template_key}: ${error.message}${error.details?` (${error.details})`:""}`);return data},archive:async(processId,spec)=>{const{data,error}=await db.rpc("form_template_write",{p_process:processId,p_spec:spec});if(error)throw new TemplateCallError("form_template_write",`${spec.template_key} (archive): ${error.message}${error.details?` (${error.details})`:""}`);return data}}}import{createClient as createClient2}from"@supabase/supabase-js";var TOKEN_ENV="PERIPHERIES_TEMPLATE_TOKEN";var PROCESS_ENV="PERIPHERIES_TEMPLATE_PROCESS_ID";var MIN_LIFE_MS=6e4;var TemplateTokenError=class extends Error{name="TemplateTokenError"};function decodeClaims(token){const part=token.split(".")[1];if(!part)throw new TemplateTokenError(`${TOKEN_ENV} isn't a token.`);try{return JSON.parse(Buffer.from(part.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8"))}catch{throw new TemplateTokenError(`${TOKEN_ENV} isn't a token.`)}}function templateToken(processId,env=process.env,now=Date.now()){const token=env[TOKEN_ENV];if(!token){throw new TemplateTokenError(`${TOKEN_ENV} isn't set. A template release runs under the agency's with-template-token wrapper for this template's process; it never uses the secret key.`)}const claims=decodeClaims(token);if(claims.grant_kind!=="mandate")throw new TemplateTokenError(`${TOKEN_ENV} isn't a mandate token (grant_kind ${JSON.stringify(claims.grant_kind??null)}).`);if(claims.obo!==void 0)throw new TemplateTokenError(`${TOKEN_ENV} speaks for a person; a template release runs as the template's own mandate.`);if(!claims.act?.job)throw new TemplateTokenError(`${TOKEN_ENV} names no template_release job.`);const want=processId.toLowerCase();if(claims.sub?.toLowerCase()!==want)throw new TemplateTokenError(`${TOKEN_ENV} is for process ${claims.sub??"none"}, not ${processId}, the template being released.`);const named=env[PROCESS_ENV];if(named&&named.toLowerCase()!==want)throw new TemplateTokenError(`${PROCESS_ENV} is ${named}, not ${processId}, the template being released.`);if(env.SUPABASE_URL){const host=u=>{try{return u?new URL(u).host.toLowerCase():null}catch{return null}};const issuer=host(claims.iss);if(!issuer)throw new TemplateTokenError(`${TOKEN_ENV} names no issuer, so it can't be matched to SUPABASE_URL.`);if(issuer!==host(env.SUPABASE_URL))throw new TemplateTokenError(`${TOKEN_ENV} was issued by ${issuer}, not SUPABASE_URL's host; check --env and the env file.`)}if(typeof claims.exp!=="number")throw new TemplateTokenError(`${TOKEN_ENV} has no expiry.`);const deadline=claims.exp*1e3-MIN_LIFE_MS;if(deadline<=now)throw new TemplateTokenError(`${TOKEN_ENV} expires in under a minute; run the wrapper again.`);return{token,claims,deadline}}function templateClient(url,publishableKey,token){return createClient2(url,publishableKey,{accessToken:async()=>token,auth:{persistSession:false}})}function decodeJsonbValue(value){let current=value;for(let depth=0;depth<4&&typeof current==="string";depth++){const trimmed=current.trim();if(!trimmed.startsWith("{")&&!trimmed.startsWith("[")&&!trimmed.startsWith('"'))break;try{const parsed=JSON.parse(trimmed);if(parsed===current)break;current=parsed}catch{break}}return current!==null&&typeof current==="object"?current:value}var RETIRED_SECTION_CONFIG_KEYS={nextButtonText:'authored 10x across 8 files and consumed by nothing \u2014 DocumentSectionRenderer and UploadSectionRenderer render no forward control; advancement is FormNavigationButtons at form scope. Removed 2026-08-27. The requirement it encoded is recorded as a rule ("the forward control names its destination"), not as literals, in the host plan 02 \xA711 \u2014 reviving the key re-creates the literals and loses the rule.'};function retiredSectionConfigKeyErrors(def){const out=[];let visited=0;const pages=Array.isArray(def.pages)?def.pages:[];pages.forEach((page,pi)=>{const sections=Array.isArray(page?.sections)?page.sections:[];sections.forEach((section,si)=>{for(const[configKey,config]of Object.entries(section??{})){if(!configKey.endsWith("Config")||!config||typeof config!=="object")continue;visited+=1;for(const key of Object.keys(config)){const reason=RETIRED_SECTION_CONFIG_KEYS[key];if(reason){out.push(`retired key "${key}" at pages[${pi}].sections[${si}].${configKey} \u2014 ${reason}`)}}}})});return{errors:out,visited}}var FormTemplateManager=class extends LayerManager{get shape(){return{fields:{template_name:{type:"string",required:true,minLength:1},template_id:{type:"string",required:true,minLength:1},profile_block_id:{type:"string"},profile_block_name:{type:"string"},name:{type:"string"},description:{type:"string"},template_type:{type:"enum",values:["form","workbook"],default:"form"},is_published:{type:"boolean",default:false},is_archived:{type:"boolean",default:false},metadata:{type:"object"},shell:{type:"object"},pages:{type:"array",required:true}},ignored:["org_id","version"]}}validateAll(){const result=super.validateAll();let visited=0;for(const file of this.files()){try{visited+=retiredSectionConfigKeyErrors(super.loadFile(file)).visited}catch{}}if(visited===0){result.errors.push({file:"(layer)",message:"the retired-key walker visited zero section config objects across every form template \u2014 its traversal assumptions no longer match the file shape, so the retired-key check is passing vacuously. Fix the walker before trusting a green run."})}for(const message of this.defaultRefErrors())result.errors.push({file:"template/config/templates.json",message});return result}loadFile(fileName){const def=super.loadFile(fileName);const{errors}=retiredSectionConfigKeyErrors(def);if(errors.length)throw new Error(errors.join("; "));return def}naturalKeyOf(def){return String(def.template_id)}naturalKeyFilter(def){return{template_key:this.naturalKeyOf(def)}}toRow(){throw new Error("form templates are written by `engine template release` (form_template_write), never as rows")}async fetchDbKeys(){return new Map}normalize=null;normalizeProblem=null;async prepare(){if(this.normalize||this.normalizeProblem)return;try{const require2=createRequire(join5(REPO_ROOT,"package.json"));const mod=await import(pathToFileURL2(require2.resolve("@peripheries/renderer/forms")).href);if(typeof mod.normalizeCondition!=="function")throw new Error("no normalizeCondition");this.normalize=mod.normalizeCondition}catch(error){const why=error instanceof Error?error.message:String(error);this.normalizeProblem=`form templates need @peripheries/renderer 0.23 or later in this repo, whose \`forms\` entry converts their conditions; loading it failed: ${why}`}}retiredExplicitly(file){const dir=this.dir();if(!dir)return false;const raw=readJsonFile(join5(dir,file));return raw?.is_published===false||raw?.is_archived===true}defaultRefErrors(){const defaults=this.bundleRefs();return defaults?bundleRefErrors({bundle_refs:defaults},this.options.orgSlug).errors.map(e=>`templates.json form_bundle_refs: ${e}`):[]}bundleRefs(){return loadTemplatesConfig().form_bundle_refs}specs(){const specs=[];const problems=[];const retired=[];if(!this.normalize)return{specs,problems:[this.normalizeProblem??"call prepare() first"],retired};for(const file of this.files()){const def=this.loadFile(file);if(def.is_published===false||def.is_archived===true){if(def.template_id&&this.retiredExplicitly(file))retired.push({label:file,templateKey:String(def.template_id)});continue}const out=formSpecOf(file,def,{decode:decodeJsonbValue,normalize:this.normalize,bundleRefs:this.bundleRefs()});specs.push(out.spec);problems.push(...out.problems)}return{specs,problems,retired}}extraIssues(def){if(def.is_published===false||def.is_archived===true)return{errors:[],warnings:[`not published: archived in the template at the next release (${def.template_name})`]};const defaults=this.bundleRefs();if(!this.normalize)return{errors:[this.normalizeProblem??"form templates: the renderer's condition converter isn't loaded, so the forms can't be checked"],warnings:[]};const{problems}=formSpecOf(def.template_name,def,{decode:decodeJsonbValue,normalize:this.normalize,bundleRefs:defaults});return{errors:problems,warnings:[]}}async syncAll(){const started=Date.now();const entities=this.files().map(f=>({entity:f,action:"skipped",id:null,reason:"released only by `engine template release` under the with-template-token wrapper, for templates.json form_template_process"}));return{layer:this.def.name,created:0,updated:0,skipped:entities.length,entities,errors:[],duration_ms:Date.now()-started}}async orphans(){return[]}async healthChecks(){return[]}async release(){const started=Date.now();const entities=[];const errors=[];const finish=()=>({layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started});const processId=process.env[PROCESS_ENV]?.toLowerCase()??null;const configured=loadTemplatesConfig().form_template_process??null;if(!processId){errors.push({entity:"form-templates",error:`${PROCESS_ENV} isn't set; run under the with-template-token wrapper`});return finish()}if(configured!==processId){for(const f of this.files())entities.push({entity:f,action:"skipped",id:null,reason:configured?`another process (${configured}); released in its own wrapped run`:"templates.json names no form_template_process"});return finish()}await this.prepare();const checked=this.validateAll();for(const e of checked.errors)errors.push({entity:e.file,error:e.message});if(errors.length)return finish();let specs;let retired;try{const out=this.specs();for(const p of out.problems)errors.push({entity:"form-templates",error:p});if(errors.length)return finish();specs=out.specs;retired=out.retired}catch(error){errors.push({entity:"form-templates",error:error instanceof Error?error.message:String(error)});return finish()}if(this.options.dryRun){for(const sp of specs)entities.push({entity:`${sp.label} \u2192 ${sp.pageType}`,action:"skipped",id:null,reason:"dry run"});for(const r of retired)entities.push({entity:`${r.label} \u2192 form:${r.templateKey}`,action:"skipped",id:null,reason:"dry run (would archive: not published)"});return finish()}try{const{token,deadline}=templateToken(processId);const url=process.env.SUPABASE_URL;const key=process.env.SUPABASE_PUBLISHABLE_KEY;if(!url||!key)throw new Error("SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are needed to release templates (never the secret key).");const db=templateClient(url,key,token);const api=formTemplateApi(db);const written=await writeFormTemplates(api,processId,specs);let changed=false;for(const w of written){changed||=w.result.changed;entities.push({entity:`${w.label} \u2192 form:${w.templateKey}`,action:w.result.changed?"updated":"skipped",id:w.result.form_id,...w.result.changed?{}:{reason:"unchanged"}})}for(const a of await archiveFormTemplates(api,processId,retired)){changed||=a.changed;entities.push({entity:`${a.label} \u2192 form:${a.templateKey}`,action:a.changed?"updated":"skipped",id:a.result.form_id??null,reason:a.changed?"archived: not published":a.result.found===false?"not published, never written":"not published, already archived"})}if(!changed)return finish();const root=await templateApi(db).scopeRoot(processId);const release=await releaseWith(db,root,{deadline,log:line=>console.log(`[template] ${line}`)});entities.push({entity:`release ${release.number} on library`,action:"created",id:release.releaseId})}catch(error){errors.push({entity:"form-templates",error:error instanceof Error?error.message:String(error)})}return finish()}};var DEFAULT="Password123!";function devPassword(targetEnv){const set=process.env.DEV_USER_PASSWORD;if(targetEnv==="local")return set||DEFAULT;if(!set||set===DEFAULT){throw new Error(`dev users on ${targetEnv} need DEV_USER_PASSWORD set to a secret of your own; the built-in default is public and is for a local stack only`)}return set}async function findOrgBySlug(supabase,orgSlug){const{data,error}=await supabase.from("organization").select("id, name, advisor, metadata").eq("metadata->>org_slug",orgSlug).limit(2);if(error){throw new Error(`organization lookup failed for org_slug="${orgSlug}": ${error.message}`)}const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} organization rows have metadata->>org_slug="${orgSlug}" \u2014 ambiguous. Ids: ${rows.map(r=>r.id).join(", ")}. Fix the database before seeding.`)}const row=rows[0];if(!row)return null;const metadata=row.metadata??{};return{id:row.id,name:row.name,advisor:row.advisor===true,advisorOrgId:metadata["advisor_org_id"]??null,advisorOrgSlug:metadata["advisor_org_slug"]??null}}var DevUserManager=class extends LayerManager{get shape(){return{fields:{type:{type:"enum",values:["advisor","client"],required:true},email:{type:"string",required:true,minLength:3},name:{type:"string",required:true,minLength:1},first_name:{type:"string",required:true,minLength:1},last_name:{type:"string",required:true,minLength:1},user_metadata:{type:"object"},org_memberships:{type:"array",required:true},org_role:{type:"enum",values:["owner","editor"]},advisor_orgs:{type:"array"},client_record:{type:"object"}}}}naturalKeyOf(def){return def.email}naturalKeyFilter(def){return{email:def.email}}toRow(){throw new Error("DevUserManager writes via syncAll()")}devPassword(){return devPassword(this.options.targetEnv)}async findAuthUser(email){const perPage=200;for(let page=1;page<=50;page++){const{data,error}=await this.options.supabase.auth.admin.listUsers({page,perPage});if(error)throw new Error(`auth.admin.listUsers failed: ${error.message}`);const users=data?.users??[];const match=users.find(u=>u.email?.toLowerCase()===email.toLowerCase());if(match)return match.id;if(users.length<perPage)return null}return null}async ensureAuthUser(def){const existing=await this.findAuthUser(def.email);if(existing)return{id:existing,created:false};const{data,error}=await this.options.supabase.auth.admin.createUser({email:def.email,password:this.devPassword(),email_confirm:true,user_metadata:{name:def.name,role:def.type,...def.user_metadata??{}}});if(error)throw new Error(`auth.admin.createUser failed for ${def.email}: ${error.message}`);const id=data?.user?.id;if(!id)throw new Error(`no user id returned for ${def.email}`);return{id,created:true}}async orgIdFor(slug){if(slug===this.options.orgSlug)return this.options.orgId;const org=await findOrgBySlug(this.options.supabase,slug);return org?.id??null}async ensureOrgMember(userId,orgId,def){const{data,error}=await this.options.supabase.from("org_member").select("id").eq("user_id",userId).eq("org_id",orgId).limit(2);if(error)throw new Error(`org_member lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} org_member rows for this user+org \u2014 role resolution will throw. Run doctor.`)}if(rows.length===1){const{error:updateError}=await this.options.supabase.from("org_member").update({first_name:def.first_name,last_name:def.last_name,nick_name:def.first_name}).eq("id",rows[0].id);if(updateError)throw new Error(`org_member update failed: ${updateError.message}`);return}const{error:insertError}=await this.options.supabase.from("org_member").insert({user_id:userId,org_id:orgId,first_name:def.first_name,last_name:def.last_name,nick_name:def.first_name});if(insertError)throw new Error(`org_member insert failed: ${insertError.message}`)}async ensureOrgRole(userId,orgId,role){const{data,error}=await this.options.supabase.from("access_grant").select("id").eq("subject_kind","user").eq("subject_id",userId).eq("scope_kind","org").eq("scope_id",orgId).eq("role",role).is("revoked_at",null).limit(1);if(error)throw new Error(`access_grant lookup failed: ${error.message}`);if((data??[]).length)return;const{error:insertError}=await this.options.supabase.from("access_grant").insert({subject_kind:"user",subject_id:userId,role,scope_kind:"org",scope_id:orgId,purpose:"dev user (template seed)"});if(insertError)throw new Error(`access_grant insert (${role}) failed: ${insertError.message}`)}async ensureAdvisor(userId,def){const primarySlug=def.org_memberships[0];if(!primarySlug)throw new Error(`advisor ${def.name} has no org_memberships`);const primaryOrgId=await this.orgIdFor(primarySlug);if(!primaryOrgId)throw new Error(`cannot resolve primary org "${primarySlug}"`);const{data,error}=await this.options.supabase.from("advisor").select("id").eq("user_id",userId).eq("org_id",primaryOrgId).limit(2);if(error)throw new Error(`advisor lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} advisor rows for this user+org \u2014 role resolution will throw. Run doctor.`)}let advisorId;if(rows.length===1){advisorId=rows[0].id;await this.options.supabase.from("advisor").update({name:def.name,email:def.email}).eq("id",advisorId)}else{const{data:inserted,error:insertError}=await this.options.supabase.from("advisor").insert({user_id:userId,org_id:primaryOrgId,name:def.name,email:def.email}).select("id").single();if(insertError)throw new Error(`advisor insert failed: ${insertError.message}`);advisorId=inserted.id}for(const slug of def.advisor_orgs??[]){const linkedOrgId=await this.orgIdFor(slug);if(!linkedOrgId){console.warn(`[template] \u26A0 advisor_orgs: "${slug}" not found in ${this.options.targetEnv} \u2014 skipping the link. Seed that org, then re-run.`);continue}const{data:link}=await this.options.supabase.from("advisor_org").select("id").eq("advisor_id",advisorId).eq("org_id",linkedOrgId).maybeSingle();if(!link){const{error:linkError}=await this.options.supabase.from("advisor_org").insert({advisor_id:advisorId,org_id:linkedOrgId});if(linkError)throw new Error(`advisor_org insert failed: ${linkError.message}`)}}return advisorId}async ensureClient(userId,def){const record=def.client_record;if(!record)throw new Error(`client ${def.name} has no client_record`);const orgSlug=def.org_memberships[0]??this.options.orgSlug;const orgId=await this.orgIdFor(orgSlug);if(!orgId)throw new Error(`cannot resolve client org "${orgSlug}"`);const data={...record.metadata??{}};if(record.slug)data.slug=record.slug;const{data:found,error}=await this.options.supabase.from("client").select("id, data, user_id").eq("org_id",orgId).eq("name",record.name);if(error)throw new Error(`client lookup failed: ${error.message}`);let rows=found??[];if(rows.length>1){const mine=rows.filter(r=>r.user_id===userId);if(mine.length===1){console.warn(`[template] \u26A0 ${rows.length} client rows named "${record.name}" in this org; using the one already linked to ${def.email} (${mine[0].id}). Run doctor \u2014 the others are strays.`);rows=mine}else{throw new Error(`${rows.length} client rows named "${record.name}" in this org and ${mine.length} linked to ${def.email} \u2014 ambiguous, refusing to guess. Run doctor for the ids.`)}}if(rows[0]){const merged={...rows[0].data??{},...data};const{error:updateError}=await this.options.supabase.from("client").update({user_id:userId,data:merged,description:record.description??null}).eq("id",rows[0].id);if(updateError)throw new Error(`client update failed: ${updateError.message}`);return rows[0].id}const{data:inserted,error:insertError}=await this.options.supabase.from("client").insert({org_id:orgId,name:record.name,description:record.description??null,user_id:userId,data}).select("id").single();if(insertError)throw new Error(`client insert failed: ${insertError.message}`);return inserted.id}async fetchDbKeys(){const out=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const id=await this.findAuthUser(def.email);if(id)out.set(def.email,id)}return out}async healthChecks(){const{data,error}=await this.options.supabase.from("client").select("id, name, user_id").eq("org_id",this.options.orgId);if(error){return[{name:"dev-user-clients",severity:"warn",message:`lookup failed: ${error.message}`}]}const rows=data??[];const byName=new Map;for(const row of rows){byName.set(row.name,[...byName.get(row.name)??[],{id:row.id,user_id:row.user_id}])}const managed=new Set;for(const file of this.files()){try{const def=this.loadFile(file);if(def.client_record?.name)managed.add(def.client_record.name)}catch{}}const blocking=[];let unmanagedDuplicates=0;for(const[name,group]of byName){if(group.length<2)continue;if(!managed.has(name)){unmanagedDuplicates++;continue}const linked=group.filter(r=>r.user_id);if(linked.length===1)continue;blocking.push(`${name}: ${group.length} rows, ${linked.length} linked \u2014 ${group.map(r=>r.id).join(", ")}`)}const checks=[blocking.length===0?{name:"dev-user-clients",severity:"ok",message:`${managed.size} managed client(s), all resolvable`}:{name:"dev-user-clients",severity:"fail",message:`${blocking.length} managed client name(s) ambiguous \u2014 dev-users sync will refuse them`,detail:blocking}];if(unmanagedDuplicates>0){checks.push({name:"client-name-duplicates",severity:"warn",message:`${unmanagedDuplicates} client name(s) outside this layer appear more than once (bulk-imported member records; client has no unique constraint on (org_id, name)). Informational \u2014 a duplicate import or two members sharing a name.`})}return checks}async syncAll(){const started=Date.now();const entities=[];const errors=[];if(this.options.targetEnv==="prod"){return{layer:this.def.name,created:0,updated:0,skipped:1,entities:[{entity:"dev-users",action:"skipped",id:null,reason:"dev fixtures never run against prod"}],errors,duration_ms:Date.now()-started}}const files=this.files();const ordered=[...files.filter(f=>f.startsWith("advisor-")),...files.filter(f=>!f.startsWith("advisor-"))];for(const file of ordered){try{const def=this.loadFile(file);if(this.options.dryRun){const existing=await this.findAuthUser(def.email);entities.push({entity:`${def.type}:${def.email}`,action:existing?"updated":"created",id:existing??"(new)"});continue}const user=await this.ensureAuthUser(def);for(const slug of def.type==="client"?[]:def.org_memberships){const orgId=await this.orgIdFor(slug);if(!orgId){console.warn(`[template] \u26A0 org_memberships: "${slug}" not found in ${this.options.targetEnv} \u2014 skipping membership.`);continue}await this.ensureOrgMember(user.id,orgId,def)}if(def.org_role&&def.type!=="client"){const orgId=await this.orgIdFor(def.org_memberships[0]??"");if(!orgId)throw new Error(`org_role needs its org: "${def.org_memberships[0]}" not found`);await this.ensureOrgRole(user.id,orgId,def.org_role)}if(def.type==="advisor"){const advisorId=await this.ensureAdvisor(user.id,def);this.mappings.set("advisor",def.email,advisorId)}else{const clientId=await this.ensureClient(user.id,def);this.mappings.set("client",def.email,clientId);const slug=def.client_record?.slug;if(slug)this.mappings.set("client",slug,clientId)}entities.push({entity:`${def.type}:${def.email}`,action:user.created?"created":"updated",id:user.id})}catch(error){errors.push({entity:file,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};var DevClientDataManager=class extends LayerManager{get shape(){return{fields:{client:{type:"string",required:true,minLength:1},entries:{type:"array",required:true}},ignored:["_comment"]}}naturalKeyOf(def){return def.client}naturalKeyFilter(def){return{org_id:this.options.orgId,client:def.client}}toRow(){throw new Error("DevClientDataManager writes via syncAll()")}async fetchDbKeys(){const out=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const clientId=await this.resolveClientId(def.client);if(clientId)out.set(def.client,clientId)}return out}async resolveClientId(reference){const bySlug=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("data->>slug",reference).limit(2);if(bySlug.error)throw new Error(`client lookup failed: ${bySlug.error.message}`);const slugRows=bySlug.data??[];if(slugRows.length>1){throw new Error(`${slugRows.length} clients have data->>slug="${reference}" \u2014 ambiguous.`)}if(slugRows[0])return slugRows[0].id;const byName=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("name",reference).limit(2);if(byName.error)throw new Error(`client lookup failed: ${byName.error.message}`);const nameRows=byName.data??[];if(nameRows.length>1){throw new Error(`${nameRows.length} clients named "${reference}" \u2014 ambiguous.`)}return nameRows[0]?.id??null}async resolveDataTypeId(name){const{data,error}=await this.options.supabase.from("client_data_type").select("id, data_format").eq("org_id",this.options.orgId).eq("name",name).limit(2);if(error)throw new Error(`client_data_type lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} client_data_type rows named "${name}" in this org \u2014 ambiguous. That table has no unique constraint on (org_id, name); run doctor for the ids.`)}const row=rows[0];return row?{id:row.id,format:row.data_format}:null}async syncAll(){const started=Date.now();const entities=[];const errors=[];let created=0;let updated=0;let skipped=0;if(this.options.targetEnv==="prod"){return{layer:this.def.name,created:0,updated:0,skipped:1,entities:[{entity:"dev-client-data",action:"skipped",id:null,reason:"dev fixtures never run against prod"}],errors,duration_ms:Date.now()-started}}for(const file of this.files()){try{const def=this.loadFile(file);const clientId=await this.resolveClientId(def.client);if(!clientId){entities.push({entity:def.client,action:"skipped",id:null,reason:`no client matches "${def.client}" \u2014 seed dev-users first:
|
|
5
5
|
npm run template -- sync --layer dev-users --env ${this.options.targetEnv}`});skipped++;continue}const purged=new Set;if(this.options.replaceExisting){const typeIds=new Set;for(const entry of def.entries){const t=await this.resolveDataTypeId(entry.data_type);if(t)typeIds.add(t.id)}if(typeIds.size>0){const{data:stale,error:staleError}=await this.options.supabase.from("client_data").select("id, name").eq("client_id",clientId).in("data_type_id",[...typeIds]);if(staleError)throw new Error(`client_data lookup failed: ${staleError.message}`);for(const row of stale??[]){if(!this.options.dryRun){const{error:deleteError}=await this.options.supabase.from("client_data").delete().eq("id",row.id);if(deleteError)throw new Error(`client_data delete failed: ${deleteError.message}`)}purged.add(row.id);updated++;entities.push({entity:row.name,action:"updated",id:row.id,reason:this.options.dryRun?"dry run \u2014 would DELETE (replace-existing)":"deleted (replace-existing)"})}}}for(const entry of def.entries){const dataType=await this.resolveDataTypeId(entry.data_type);if(!dataType){entities.push({entity:`${entry.data_type} / ${entry.name}`,action:"skipped",id:null,reason:`no client_data_type named "${entry.data_type}". That row belongs to the data-types LAYER, not this one, and this layer deliberately does not create it:
|
|
6
6
|
npm run template -- sync --layer data-types --env ${this.options.targetEnv}`});skipped++;continue}const hasJson=entry.json_content!==void 0;const hasText=entry.content!==void 0;if(hasJson===hasText){throw new Error(`${entry.data_type} / ${entry.name}: supply exactly one of json_content or content (got ${hasJson?"both":"neither"}).`)}if(dataType.format==="json"&&!hasJson){throw new Error(`${entry.data_type} / ${entry.name}: data type declares data_format "json" but the fixture supplies \`content\`. A JSON reader looks at json_content and would find null.`)}if(dataType.format&&dataType.format!=="json"&&!hasText){throw new Error(`${entry.data_type} / ${entry.name}: data type declares data_format "${dataType.format}" but the fixture supplies \`json_content\`.`)}const{data:existingRows,error:existingError}=await this.options.supabase.from("client_data").select("id").eq("client_id",clientId).eq("data_type_id",dataType.id).eq("name",entry.name).limit(1);if(existingError)throw new Error(`client_data lookup failed: ${existingError.message}`);const found=(existingRows??[])[0];const existing=found&&!purged.has(found.id)?found:void 0;if(existing){entities.push({entity:`${entry.data_type} / ${entry.name}`,action:"skipped",id:existing.id,reason:"artefact already exists \u2014 client_data is append-only history, never rewritten"});skipped++;continue}if(this.options.dryRun){entities.push({entity:`${entry.data_type} / ${entry.name}`,action:"created",id:null,reason:"dry run"});created++;continue}const{data:inserted,error:insertError}=await this.options.supabase.from("client_data").insert({client_id:clientId,data_type_id:dataType.id,org_id:this.options.orgId,name:entry.name,description:entry.description??null,json_content:entry.json_content??null,content:entry.content??null}).select("id").single();if(insertError)throw new Error(`client_data insert failed: ${insertError.message}`);created++;entities.push({entity:`${entry.data_type} / ${entry.name}`,action:"created",id:inserted.id})}}catch(error){errors.push({entity:file,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created,updated,skipped,entities,errors,duration_ms:Date.now()-started}}};import{existsSync as existsSync5,readdirSync as readdirSync3,readFileSync as readFileSync3}from"node:fs";import{join as join6}from"node:path";import{createClient as createClient3}from"@supabase/supabase-js";function answerValue(type,raw){if(type==="number"){const n=typeof raw==="number"?raw:Number(raw);if(Number.isNaN(n))throw new Error(`a number question, but "${raw}" isn't a number`);return{v:1,value:n}}if(type==="multiselect")return{v:1,value:String(raw).split(",").map(x=>x.trim()).filter(Boolean)};return{v:1,value:String(raw)}}async function seedWorkbooks(port,member,templates){const out=[];for(const wb of member.workbooks){const entity=`${member.client} / ${wb.template_name}`;const t=templates.get(wb.template_name);if(!t)throw new Error(`${entity}: no released form template is named "${wb.template_name}" (names are exact)`);const existing=await port.existing(member.client,t.templateKey);if(existing){out.push({entity,action:"skipped",id:existing,reason:"the client already has this workbook; fixtures never overwrite a member\u2019s answers"});continue}const form=await port.instantiate(member.client,member.project,t.templateKey);for(const a of wb.answers??[]){const node=form.questions[a.field_name];const type=t.types.get(a.field_name);if(!node||!type)throw new Error(`${entity}: the form has no question "${a.field_name}"`);await port.answer(member.client,node,a.field_name,answerValue(type,a.value))}const submitted=wb.completed===true||wb.status==="submitted"||wb.status==="completed";if(submitted)await port.submit(member.client,form.structureNodeId);if(wb.completed===true||wb.status==="completed")await port.complete(form.structureNodeId);out.push({entity,action:"created",id:form.structureNodeId,reason:`${wb.answers?.length??0} answer(s)${submitted?", submitted":""}${wb.completed?", completed":""}`})}return out}var DevWorkbookManager=class extends LayerManager{get shape(){return{fields:{client:{type:"string",required:true,minLength:1},project:{type:"string"},workbooks:{type:"array",required:true}},ignored:["_comment"]}}naturalKeyOf(def){return def.client}naturalKeyFilter(def){return{org_id:this.options.orgId,client:def.client}}toRow(){throw new Error("DevWorkbookManager writes via syncAll()")}async fetchDbKeys(){const out=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const clientId=await this.resolveClientId(def.client);if(clientId)out.set(def.client,clientId)}return out}async resolveClientId(reference){const bySlug=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("data->>slug",reference).limit(2);if(bySlug.error)throw new Error(`client lookup failed: ${bySlug.error.message}`);const slugRows=bySlug.data??[];if(slugRows.length>1){throw new Error(`${slugRows.length} clients have data->>slug="${reference}" \u2014 ambiguous.`)}if(slugRows[0])return slugRows[0].id;const byName=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("name",reference).limit(2);if(byName.error)throw new Error(`client lookup failed: ${byName.error.message}`);const nameRows=byName.data??[];if(nameRows.length>1){throw new Error(`${nameRows.length} clients named "${reference}" \u2014 ambiguous.`)}return nameRows[0]?.id??null}async resolveProjectId(name){const{data,error}=await this.options.supabase.from("project").select("id").eq("org_id",this.options.orgId).eq("name",name).limit(2);if(error)throw new Error(`project lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1)throw new Error(`${rows.length} projects named "${name}" \u2014 ambiguous.`);return rows[0]?.id??null}async syncAll(){const started=Date.now();const entities=[];const errors=[];const finish=()=>({layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:0,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started});if(this.options.targetEnv==="prod"){entities.push({entity:"dev-workbooks",action:"skipped",id:null,reason:"dev fixtures never run against prod"});return finish()}if(this.options.replaceExisting){errors.push({entity:"dev-workbooks",error:"--replace-existing is retired: forms are pages now, and a member\u2019s form is never deleted by a fixture"});return finish()}try{const formProcess=loadTemplatesConfig().form_template_process;if(!formProcess)throw new Error("templates.json names no form_template_process: dev workbooks are instantiated from its library release");const users=devUsers();const staffEmail=users.find(u=>u.type==="advisor"&&u.org_memberships?.[0]===this.options.orgSlug&&(u.org_role==="editor"||u.org_role==="owner"))?.email;if(!staffEmail)throw new Error(`seed/dev-users has no advisor of ${this.options.orgSlug} with an org_role (editor or owner): dev staff instantiates the workbooks, which takes edit forms`);const password=devPassword(this.options.targetEnv);if(this.options.dryRun){for(const file of this.files())entities.push({entity:this.loadFile(file).client,action:"skipped",id:null,reason:"dry run"});return finish()}const staff=await devSession(staffEmail,password);const members=new Map;const memberSession=async client=>{const email=users.find(u=>u.type==="client"&&u.client_record?.slug===client)?.email;if(!email)throw new Error(`seed/dev-users has no client "${client}" with a login: its answers are saved in the member's own session`);if(!members.has(client))members.set(client,await devSession(email,password));return members.get(client)};const port=realWorkbookPort({staff,memberSession,orgId:this.options.orgId,formProcess,resolveClient:ref2=>this.resolveClientId(ref2)});const templates=templateIndex();for(const file of this.files()){const def=this.loadFile(file);for(const r of await seedWorkbooks(port,def,templates))entities.push(r)}}catch(error){errors.push({entity:"dev-workbooks",error:error instanceof Error?error.message:String(error)})}return finish()}};function devUsers(){const dir=join6(SEED_DIR,"dev-users");if(!existsSync5(dir))return[];return readdirSync3(dir).filter(f=>f.endsWith(".json")).map(f=>JSON.parse(readFileSync3(join6(dir,f),"utf8")))}async function devSession(email,password){const url=process.env.SUPABASE_URL;const key=process.env.SUPABASE_PUBLISHABLE_KEY;if(!url||!key)throw new Error("SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are needed: dev workbooks are written as dev users, never with the secret key");const db=createClient3(url,key,{auth:{persistSession:false,autoRefreshToken:false}});const{error}=await db.auth.signInWithPassword({email,password});if(error)throw new Error(`signing in as ${email} failed: ${error.message} (seed dev-users first)`);return db}function templateIndex(){const out=new Map;const dir=join6(DATA_DIR,"form-templates");if(!existsSync5(dir))return out;for(const f of readdirSync3(dir).filter(x=>x.endsWith(".json"))){const t=JSON.parse(readFileSync3(join6(dir,f),"utf8"));if(!t.template_id)continue;const types=new Map;for(const p of t.pages??[])for(const s of p.sections??[]){for(const fl of s.fields??[])types.set(fl.fieldId,fl.fieldType);if(s.sectionType==="upload"&&s.uploadConfig?.fieldId)types.set(s.uploadConfig.fieldId,"file")}out.set(t.template_name,{templateKey:t.template_id,types})}return out}var must2=(what,r)=>{if(r.error)throw new Error(`${what}: ${r.error.message}${r.error.details?` (${r.error.details})`:""}`);return r.data};function realWorkbookPort(deps){const clientIds=new Map;const clientId=async ref2=>{if(!clientIds.has(ref2)){const id=await deps.resolveClient(ref2);if(!id)throw new Error(`no client matches "${ref2}": seed dev-users first`);clientIds.set(ref2,id)}return clientIds.get(ref2)};const projectOf=async(client,project)=>{if(project){const rows2=must2("project",await deps.staff.from("project").select("id").eq("org_id",deps.orgId).eq("name",project).limit(2));if(rows2.length!==1)throw new Error(`${rows2.length?"more than one project":"no project"} named "${project}"`);return rows2[0].id}const rows=must2("client_project",await deps.staff.from("client_project").select("project_id").eq("client_id",await clientId(client)).limit(2));if(rows.length!==1)throw new Error(`client "${client}" is in ${rows.length} projects; name one in the fixture`);return rows[0].project_id};return{existing:async(client,templateKey)=>{const rows=must2("form_status",await deps.staff.from("form_status").select("structure_node_id").eq("client_id",await clientId(client)).eq("template_key",templateKey).limit(1));return rows[0]?.structure_node_id??null},instantiate:async(client,project,templateKey)=>{const projectId=await projectOf(client,project);const procs=must2("process",await deps.staff.from("process").select("id").eq("project_id",projectId).eq("is_project_process",true).eq("archived",false).eq("is_template",false).limit(2));if(procs.length!==1)throw new Error(`project ${projectId} has ${procs.length} project processes; open it with project_open first`);const out=must2("instantiate_process",await deps.staff.rpc("instantiate_process",{p_template_process_id:deps.formProcess,p_org_id:deps.orgId,p_project_id:projectId,p_template_key:templateKey,p_parent_binding:{process_id:procs[0].id,key:"workbook_forms"}}));if(out?.success===false||!out?.form)throw new Error(`instantiate_process (${templateKey}): ${out?.message??"no form returned"}`);return{structureNodeId:out.form.structure_node_id,questions:out.form.questions}},answer:async(client,node,key,value)=>{must2("answer_save",await(await deps.memberSession(client)).rpc("answer_save",{p_question_node:node,p_key:key,p_value:value}))},submit:async(client,structure)=>{must2("form_submit",await(await deps.memberSession(client)).rpc("form_submit",{p_structure_node:structure}))},complete:async structure=>{must2("form_review",await deps.staff.rpc("form_review",{p_structure_node:structure,p_status:"completed"}))}}}var CUTOVER_OWNED_COLUMNS=["remote_version","previous_version","remote_url","remote_storage_bucket"];var LAYOUT_SYNC_OWNED=["name","description"];var OrgManager=class extends LayerManager{get shape(){return{fields:{slug:{type:"string",required:true,minLength:1},name:{type:"string",required:true,minLength:1},advisor:{type:"boolean"},advisor_org_slug:{type:"string"},bundle_name:{type:"string"},asset_bucket:{type:"string"},description:{type:"string"}},ignored:["_comment"]}}naturalKeyOf(def){return def.slug}naturalKeyFilter(def){return{metadata_org_slug:def.slug}}toRow(){throw new Error("OrgManager writes via syncAll()")}files(){return["org.json"]}async fetchDbKeys(){const config=loadOrgConfig();const org=await findOrgBySlug(this.options.supabase,config.slug);return org?new Map([[config.slug,org.id]]):new Map}async syncAll(){const started=Date.now();const entities=[];const errors=[];const config=loadOrgConfig();const dryRun=this.options.dryRun;try{const existingOrg=await findOrgBySlug(this.options.supabase,config.slug);let advisorOrgId=null;if(!config.advisor&&config.advisor_org_slug){const advisorOrg=await findOrgBySlug(this.options.supabase,config.advisor_org_slug);if(advisorOrg){advisorOrgId=advisorOrg.id;this.mappings.set("org",config.advisor_org_slug,advisorOrg.id)}else{entities.push({entity:`advisor:${config.advisor_org_slug}`,action:"skipped",id:null,reason:`advisor org "${config.advisor_org_slug}" not found in ${this.options.targetEnv} \u2014 seed the advisor org repo first, then re-run. Advisor-scoped lookups stay unresolved until then.`})}}const orgMetadata={...existingOrg?{}:{seeded:true,created_at:new Date().toISOString()},org_slug:config.slug,organization_type:config.advisor?"advisor":"client",...config.advisor_org_slug?{advisor_org_slug:config.advisor_org_slug}:{},...advisorOrgId?{advisor_org_id:advisorOrgId}:{}};let orgId;if(existingOrg){orgId=existingOrg.id;if(!dryRun){const{data:current}=await this.options.supabase.from("organization").select("metadata").eq("id",orgId).single();const merged={...current?.metadata??{},...orgMetadata};const{error}=await this.options.supabase.from("organization").update({advisor:config.advisor,metadata:merged}).eq("id",orgId);if(error)throw new Error(`organization update failed: ${error.message}`)}entities.push({entity:`organization:${config.slug}`,action:"updated",id:orgId})}else if(dryRun){orgId="(new)";entities.push({entity:`organization:${config.slug}`,action:"created",id:orgId})}else{const{data,error}=await this.options.supabase.from("organization").insert([{name:config.name,advisor:config.advisor,created_by:"00000000-0000-0000-0000-000000000000",metadata:orgMetadata}]).select("id").single();if(error)throw new Error(`organization insert failed: ${error.message}`);orgId=data.id;entities.push({entity:`organization:${config.slug}`,action:"created",id:orgId})}if(!dryRun&&orgId!=="(new)")this.mappings.set("org",config.slug,orgId);const{data:templateRows,error:templateError}=await this.options.supabase.from("org_template").select("id, metadata").eq("org_id",orgId==="(new)"?"00000000-0000-0000-0000-000000000000":orgId).eq("is_archived",false).limit(2);if(templateError)throw new Error(`org_template lookup failed: ${templateError.message}`);const templates=templateRows??[];if(templates.length>1){throw new Error(`${templates.length} non-archived org_template rows for org ${orgId} \u2014 ambiguous. Fix the DB first.`)}const existingTemplate=templates[0];if(existingTemplate){const patch={advisor_org_id:advisorOrgId,is_published:true,is_archived:false,metadata:{...existingTemplate.metadata??{},org_slug:config.slug,organization_type:config.advisor?"advisor":"client",...config.advisor_org_slug?{advisor_org_slug:config.advisor_org_slug}:{}},updated_at:new Date().toISOString()};const leaked=[...CUTOVER_OWNED_COLUMNS,...LAYOUT_SYNC_OWNED,"remote_name","remote_enabled"].filter(c=>c in patch);if(leaked.length){throw new Error(`BUG: org layer patch contains non-owned column(s): ${leaked.join(", ")}`)}if(!dryRun){const{error}=await this.options.supabase.from("org_template").update(patch).eq("id",existingTemplate.id);if(error)throw new Error(`org_template update failed: ${error.message}`)}entities.push({entity:"org_template",action:"updated",id:existingTemplate.id});if(!dryRun)this.mappings.set("org_template",config.slug,existingTemplate.id)}else if(dryRun){entities.push({entity:"org_template",action:"created",id:"(new)"})}else{const{data,error}=await this.options.supabase.from("org_template").insert([{org_id:orgId,advisor_org_id:advisorOrgId,name:config.name,description:config.description??null,is_published:true,is_archived:false,remote_name:config.slug,remote_enabled:true,metadata:{org_slug:config.slug,organization_type:config.advisor?"advisor":"client",...config.advisor_org_slug?{advisor_org_slug:config.advisor_org_slug}:{},created_by:"template-cli"}}]).select("id").single();if(error)throw new Error(`org_template insert failed: ${error.message}`);const templateId=data.id;entities.push({entity:"org_template",action:"created",id:templateId});this.mappings.set("org_template",config.slug,templateId)}}catch(error){errors.push({entity:"org",error:error instanceof Error?error.message:String(error)})}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};import{existsSync as existsSync6,readFileSync as readFileSync4}from"node:fs";import{relative}from"node:path";var COLUMNS="id, org_id, org_slug, bundle_name, version, status, is_live, uploaded_at";async function resolveBundle(supabase,orgId,orgSlug,options={}){const bundleName=options.bundleName??"main";const version=options.version??null;if(version){const{data,error}=await supabase.from("component_bundle").select(COLUMNS).eq("org_id",orgId).eq("bundle_name",bundleName).eq("version",version).limit(2);if(error)throw new Error(`component_bundle lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} bundles match org=${orgSlug} bundle=${bundleName} version=${version} \u2014 ambiguous.`)}const row2=rows[0];if(!row2){throw new Error(`no component_bundle for org=${orgSlug} bundle=${bundleName} version=${version}.`)}return{...row2,resolved_by:"pin"}}const{data:liveRows,error:liveError}=await supabase.from("component_bundle").select(COLUMNS).eq("org_id",orgId).eq("bundle_name",bundleName).eq("is_live",true).limit(2);if(liveError)throw new Error(`component_bundle lookup failed: ${liveError.message}`);const live=liveRows??[];if(live.length>1){throw new Error(`${live.length} bundles in lineage "${bundleName}" (org=${orgSlug}) have is_live=true \u2014 exactly one row may be live. Re-run cutover from whichever repo owns this org's deploy pipeline (this one if it has scripts/cutover.mjs):
|
|
7
7
|
npm run deploy:cutover -- --org ${orgSlug} --version <v> --<env>`)}if(live[0]){return{...live[0],resolved_by:"is_live"}}const{data:latestRows,error:latestError}=await supabase.from("component_bundle").select(COLUMNS).eq("org_id",orgId).eq("bundle_name",bundleName).order("uploaded_at",{ascending:false}).limit(1);if(latestError)throw new Error(`component_bundle lookup failed: ${latestError.message}`);const latest=(latestRows??[])[0];if(!latest){throw new Error(`no component_bundle found for org=${orgSlug} bundle=${bundleName}. Run \`npm run deploy:upload\` first.`)}const row=latest;console.warn(`[template] \u26A0 no is_live bundle in lineage "${bundleName}" (org=${orgSlug}) \u2014 falling back to the most recently uploaded bundle (${row.version}). The host serves ONLY the is_live row, so this metadata may describe components the host is not running. Pin with --version, or promote one first from THIS repo:
|