@happyvertical/smrt-core 0.55.22 → 0.55.24
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/agents/build-knowledge.md +11 -0
- package/agents/run-once.md +7 -0
- package/agents/schema-paths.md +10 -0
- package/dist/browser.js +2 -2
- package/dist/class.d.ts +1 -0
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +20 -8
- package/dist/class.js.map +1 -1
- package/dist/decorators/index.d.ts +7 -0
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/prebuild/index.d.ts.map +1 -1
- package/dist/prebuild/index.js +2 -0
- package/dist/prebuild/index.js.map +1 -1
- package/dist/run-once.js +2 -2
- package/dist/run-once.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +12 -2
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +13 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/smrt-knowledge.json +9 -9
- package/dist/ui-metadata.d.ts +13 -0
- package/dist/ui-metadata.d.ts.map +1 -1
- package/dist/ui-metadata.js +26 -1
- package/dist/ui-metadata.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +2 -0
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts +6 -0
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +6 -3
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +10 -10
package/dist/manifest.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/prebuild/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAkB,CAAC;AAW5D,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,GAAG,mBAAmB,CAAC;IACvC,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,sCAAsC;IACtC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,IAAI,CAAC,CAkCf;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/prebuild/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAkB,CAAC;AAW5D,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,GAAG,mBAAmB,CAAC;IACvC,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,sCAAsC;IACtC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,IAAI,CAAC,CAkCf;AAkaD;;GAEG;AACH,wBAAsB,2BAA2B,CAC/C,IAAI,EAAE,MAAM,EAAE,GACb,OAAO,CAAC,IAAI,CAAC,CAkBf"}
|
package/dist/prebuild/index.js
CHANGED
|
@@ -226,6 +226,7 @@ declare module '@smrt/web' {
|
|
|
226
226
|
order?: number;
|
|
227
227
|
locked?: boolean;
|
|
228
228
|
widget?: 'textarea' | 'currency' | 'email' | 'url' | 'phone';
|
|
229
|
+
format?: 'money' | 'date' | 'datetime' | 'percent';
|
|
229
230
|
}
|
|
230
231
|
|
|
231
232
|
export interface SmrtWebFieldDefinition {
|
|
@@ -254,6 +255,7 @@ declare module '@smrt/web' {
|
|
|
254
255
|
field: string;
|
|
255
256
|
kind: SmrtWebRelationshipKind;
|
|
256
257
|
relatedCollection: string;
|
|
258
|
+
role?: 'items';
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
export interface WebToolRouteDescriptor {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/prebuild/index.ts"],"sourcesContent":["/**\n * Pre-build utilities for generating TypeScript declarations\n * Solves virtual module resolution by creating physical .d.ts files\n */\n\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport type { SmartObjectManifest } from '../scanner/types';\nimport {\n renderApiClientCrudType,\n renderApiClientCustomMethodParameters,\n selectApiClientEntries,\n} from '../vite-plugin/api-client-entries.js';\nimport {\n orderedManifestObjectEntries,\n selectWebCollectionEntries,\n} from '../vite-plugin/web-collections.js';\n\nexport interface PrebuildOptions {\n /** Path to manifest file or manifest object */\n manifest: string | SmartObjectManifest;\n /** Output directory for generated types */\n outDir: string;\n /** Include virtual module declarations */\n includeVirtualModules?: boolean;\n /** Include object type definitions */\n includeObjectTypes?: boolean;\n /** Project root path for resolving relative paths */\n projectRoot?: string;\n}\n\n/**\n * Generate TypeScript declaration files from SMRT manifest\n */\nexport async function generateDeclarations(\n options: PrebuildOptions,\n): Promise<void> {\n const {\n manifest: manifestInput,\n outDir,\n includeVirtualModules = true,\n includeObjectTypes = true,\n projectRoot = process.cwd(),\n } = options;\n\n // Load manifest\n const manifest: SmartObjectManifest =\n typeof manifestInput === 'string'\n ? JSON.parse(fs.readFileSync(manifestInput, 'utf-8'))\n : manifestInput;\n\n // Ensure output directory exists\n const fullOutDir = path.isAbsolute(outDir)\n ? outDir\n : path.join(projectRoot, outDir);\n fs.mkdirSync(fullOutDir, { recursive: true });\n\n console.log(`[smrt:prebuild] Generating declarations to ${fullOutDir}`);\n\n if (includeObjectTypes) {\n await generateObjectTypeDeclarations(manifest, fullOutDir);\n }\n\n if (includeVirtualModules) {\n await generateVirtualModuleDeclarations(manifest, fullOutDir);\n }\n\n console.log(\n `[smrt:prebuild] Generated declarations for ${Object.keys(manifest.objects).length} SMRT objects`,\n );\n}\n\n/**\n * Generate TypeScript interfaces for SMRT objects\n */\nasync function generateObjectTypeDeclarations(\n manifest: SmartObjectManifest,\n outDir: string,\n): Promise<void> {\n const interfaces: string[] = [];\n\n // Generate interfaces for each discovered SMRT object. Manifest key order\n // follows scan/discovery order, which is not stable across runs (#2749),\n // so sort deterministically before emitting.\n const sortedObjectEntries = orderedManifestObjectEntries(manifest);\n for (const [_objectName, objectMeta] of sortedObjectEntries) {\n const fields = objectMeta.fields || {};\n const propertyLines: string[] = [];\n\n // Add standard SmrtObject properties\n propertyLines.push(' id?: string;');\n propertyLines.push(' created_at?: string;');\n propertyLines.push(' updated_at?: string;');\n\n // Add object-specific properties\n for (const [fieldName, fieldDef] of Object.entries(fields)) {\n const type = mapFieldTypeToTypeScript(fieldDef.type);\n const optional = !fieldDef.required ? '?' : '';\n propertyLines.push(` ${fieldName}${optional}: ${type};`);\n }\n\n const interfaceDef = `export interface ${objectMeta.className}Data {\n${propertyLines.join('\\n')}\n}`;\n interfaces.push(interfaceDef);\n }\n\n // Write object types file\n const objectTypesContent = `/**\n * Auto-generated TypeScript interfaces for SMRT objects\n * Generated at build time from @smrt() decorated classes\n *\n * DO NOT EDIT THIS FILE MANUALLY\n */\n\n${interfaces.join('\\n\\n')}\n`;\n\n fs.writeFileSync(path.join(outDir, 'smrt-objects.d.ts'), objectTypesContent);\n}\n\n/**\n * Generate virtual module declarations\n */\nasync function generateVirtualModuleDeclarations(\n manifest: SmartObjectManifest,\n outDir: string,\n): Promise<void> {\n // Generate manifest module declaration\n const manifestDeclaration = `/**\n * Auto-generated manifest module declaration\n */\ndeclare module '@smrt/manifest' {\n export interface SmrtObjectField {\n type: string;\n required?: boolean;\n default?: any;\n }\n\n export interface SmrtObjectMethod {\n name: string;\n parameters: Array<{\n name: string;\n type: string;\n optional?: boolean;\n default?: any;\n }>;\n returnType: string;\n async: boolean;\n isStatic: boolean;\n isPublic: boolean;\n }\n\n export interface SmrtObjectDefinition {\n name: string;\n className: string;\n collection: string;\n filePath: string;\n fields: Record<string, SmrtObjectField>;\n methods: Record<string, SmrtObjectMethod>;\n decoratorConfig: any;\n extends?: string;\n }\n\n export interface SmrtManifest {\n version: string;\n timestamp: number;\n objects: Record<string, SmrtObjectDefinition>;\n }\n\n export const manifest: SmrtManifest;\n export default manifest;\n}`;\n\n // Generate client module declaration\n const apiClientInterface = selectApiClientEntries(manifest)\n .map(({ clientKey, dataInterfaceName, crudMethods, customMethods }) => {\n const overriddenMethods = customMethods.map(({ name }) => name);\n const crudType = renderApiClientCrudType(\n dataInterfaceName,\n crudMethods,\n overriddenMethods,\n );\n const customSignatures = customMethods\n .map(\n (method) =>\n ` ${method.name}(${renderApiClientCustomMethodParameters(\n method,\n mapFieldTypeToTypeScript,\n )}): Promise<any>;`,\n )\n .join('\\n');\n\n if (customSignatures) {\n const intersection = crudType ? `${crudType} & ` : '';\n return ` ${JSON.stringify(clientKey)}: ${intersection}{\\n${customSignatures}\\n };`;\n }\n\n return ` ${JSON.stringify(clientKey)}: ${crudType ?? 'Record<string, never>'};`;\n })\n .join('\\n');\n\n // Wire-shape policy (#1797): the server returns BARE JSON — a bare array for\n // list/search, a bare object for get/create/update — with snake_case field\n // names (created_at, updated_at). These physical declarations share client\n // entry selection and method-signature rendering with the Vite declaration.\n // Fetchers reject on non-2xx with a SmrtClientError (#1796).\n const clientDeclaration = `/**\n * Auto-generated API client module declaration\n */\ndeclare module '@smrt/client' {\n /** Structured custom-action failure nested under an API error body's \\`error\\` key. */\n export interface SmrtClientFailure {\n ok: false;\n code: string;\n message: string;\n status?: number;\n }\n\n /** Shape of a JSON error body carried by a rejected request (SmrtClientError.body). */\n export interface ApiError {\n error?: string | SmrtClientFailure;\n message?: string;\n }\n\n /** Typed error thrown by every fetcher on a non-2xx response (#1796). */\n export interface SmrtClientError extends Error {\n name: 'SmrtClientError';\n status: number;\n /** Machine-readable code from a structured custom-action failure, when present. */\n code?: string;\n body?: ApiError | string;\n }\n\n export interface CrudOperations<T = any> {\n list(params?: Record<string, any>): Promise<T[]>;\n get(id: string): Promise<T>;\n create(data: Partial<T>): Promise<T>;\n update(id: string, data: Partial<T>): Promise<T>;\n delete(id: string): Promise<boolean>;\n search(query: string): Promise<T[]>;\n }\n\n export interface ApiClient {\n${apiClientInterface}\n }\n\n export function createClient(basePath?: string): ApiClient;\n export default createClient;\n}`;\n\n // Generate routes module declaration\n const routesDeclaration = `/**\n * Auto-generated routes module declaration\n */\ndeclare module '@smrt/routes' {\n export interface RouteApp {\n get(path: string, handler: (req: any, res: any) => void): void;\n post(path: string, handler: (req: any, res: any) => void): void;\n put(path: string, handler: (req: any, res: any) => void): void;\n delete(path: string, handler: (req: any, res: any) => void): void;\n }\n\n export function setupRoutes(app: RouteApp): void;\n export default setupRoutes;\n}`;\n\n // Generate MCP module declaration\n const mcpDeclaration = `/**\n * Auto-generated MCP module declaration\n */\ndeclare module '@smrt/mcp' {\n export interface McpTool {\n name: string;\n description: string;\n inputSchema: {\n type: string;\n properties: Record<string, any>;\n required?: string[];\n };\n }\n\n export const tools: McpTool[];\n export function createMCPServer(): {\n name: string;\n version: string;\n tools: McpTool[]\n };\n export default createMCPServer;\n}`;\n\n // Generate types module declaration with object imports. Sorted for the\n // same reason as generateObjectTypeDeclarations (#2749).\n const objectImports = orderedManifestObjectEntries(manifest)\n .map(\n ([, obj]) =>\n ` export type ${obj.className}Data = import('./smrt-objects').${obj.className}Data;`,\n )\n .join('\\n');\n\n const typesDeclaration = `/**\n * Auto-generated types module declaration\n */\ndeclare module '@smrt/types' {\n${objectImports}\n\n export interface Request {\n params: Record<string, string>;\n query: Record<string, any>;\n json(): Promise<any>;\n }\n\n export interface Response {\n json(data: any, init?: { status?: number }): Response;\n status(code: number): Response;\n }\n}`;\n\n // Generate web collection-definition module declaration (#1761). Selection\n // shares selectWebCollectionEntries with the vite-plugin runtime module and\n // its virt-web d.ts, so this `tsc`-only consumer declaration cannot drift\n // from the emitted values.\n const webCollectionEntries = selectWebCollectionEntries(manifest)\n .map(\n ({ collection, obj }) =>\n ` ${JSON.stringify(collection)}: SmrtWebCollectionDefinition<import('./smrt-objects').${obj.className}Data>;`,\n )\n .join('\\n');\n\n const webDeclaration = `/**\n * Auto-generated web collection-definition module declaration (#1761)\n */\ndeclare module '@smrt/web' {\n export type SmrtWebFieldType =\n | 'text'\n | 'decimal'\n | 'boolean'\n | 'integer'\n | 'datetime'\n | 'json'\n | 'foreignKey'\n | 'crossPackageRef';\n\n /** Static \\`@field({ ui })\\` hints (#2046) — the field-policy rail seed. */\n export interface SmrtWebFieldUIHints {\n basic?: boolean;\n group?: string;\n order?: number;\n locked?: boolean;\n widget?: 'textarea' | 'currency' | 'email' | 'url' | 'phone';\n }\n\n export interface SmrtWebFieldDefinition {\n type: SmrtWebFieldType;\n required?: boolean;\n nullable?: boolean;\n default?: unknown;\n /** Developer-authored \\`@field({ description })\\` (#2046) — end-user help seed. */\n description?: string;\n /** Static \\`@field({ ui })\\` hints (#2046). */\n ui?: SmrtWebFieldUIHints;\n }\n\n export type SmrtWebRelationshipKind =\n | 'foreignKey'\n | 'crossPackageRef'\n | 'oneToMany'\n | 'manyToMany';\n\n /**\n * A manifest-derived edge to a sibling REST collection. Mutating this\n * collection invalidates the caches of the collections named by these edges\n * (#1761 relationship-derived invalidation).\n */\n export interface SmrtWebRelationship {\n field: string;\n kind: SmrtWebRelationshipKind;\n relatedCollection: string;\n }\n\n export interface WebToolRouteDescriptor {\n method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n scope: 'item' | 'collection';\n path: string[];\n parameterAliases?: Record<string, string>;\n optionsBag?: boolean;\n }\n\n /** A WebMCP/MCP tool descriptor for one collection action (#1812). */\n export interface WebToolDescriptor {\n action: string;\n name: string;\n description: string;\n inputSchema: Record<string, unknown>;\n readOnly: boolean;\n effect: 'read' | 'write' | 'destructive';\n idempotent: boolean;\n openWorld: boolean;\n route?: WebToolRouteDescriptor;\n }\n\n /** Canonical data-only definition for one generated browser tool. */\n export interface WebMcpToolDefinition extends WebToolDescriptor {\n collection: string;\n objectRef: string;\n className: string;\n endpoint: string;\n idField: string;\n idType: 'uuid' | 'text';\n route: WebToolRouteDescriptor;\n relationships: SmrtWebRelationship[];\n }\n\n export interface SmrtWebCollectionDefinition<TData = Record<string, unknown>> {\n name: string;\n /** Canonical qualified model identity (\\`@package/name:ClassName\\`) for policy APIs. */\n objectRef: string;\n className: string;\n endpoint: string;\n idField: string;\n actions: string[];\n fields: Record<string, SmrtWebFieldDefinition>;\n /** Manifest-derived relationship edges to sibling REST collections. */\n relationships: SmrtWebRelationship[];\n /** WebMCP/MCP tool descriptors for the exposed actions (#1812). */\n toolDescriptors: WebToolDescriptor[];\n /** Phantom row-type carrier for inference — never present at runtime. */\n _row?: TData;\n }\n\n export interface SmrtWebCollectionDefinitions {\n${webCollectionEntries}\n }\n\n export const collectionDefinitions: SmrtWebCollectionDefinitions;\n /** Every API-backed WebMCP tool, independent of list materialization. */\n export const webMcpToolDefinitions: readonly WebMcpToolDefinition[];\n export function getCollectionDefinition<\n K extends keyof SmrtWebCollectionDefinitions,\n >(name: K): SmrtWebCollectionDefinitions[K];\n /**\n * Build-time web-collection shape digest (#1764). A deterministic,\n * replica-stable hash of the emitted collection definitions; a change means\n * old persisted client rows may mis-hydrate. Consumers fold it into the\n * durable persistence namespace and the version-awareness updateAvailable\n * contract signal.\n */\n export const manifestHash: string;\n export default collectionDefinitions;\n}`;\n\n // Write all virtual module declarations\n fs.writeFileSync(\n path.join(outDir, 'smrt-manifest.d.ts'),\n manifestDeclaration,\n );\n fs.writeFileSync(path.join(outDir, 'smrt-client.d.ts'), clientDeclaration);\n fs.writeFileSync(path.join(outDir, 'smrt-routes.d.ts'), routesDeclaration);\n fs.writeFileSync(path.join(outDir, 'smrt-mcp.d.ts'), mcpDeclaration);\n fs.writeFileSync(path.join(outDir, 'smrt-types.d.ts'), typesDeclaration);\n fs.writeFileSync(path.join(outDir, 'smrt-web.d.ts'), webDeclaration);\n}\n\n/**\n * Map SMRT field types to TypeScript types\n */\nfunction mapFieldTypeToTypeScript(smrtType: string): string {\n switch (smrtType) {\n case 'text':\n return 'string';\n case 'decimal':\n case 'integer':\n return 'number';\n case 'boolean':\n return 'boolean';\n case 'datetime':\n return 'string | Date';\n case 'json':\n return 'any';\n case 'foreignKey':\n return 'string';\n default:\n return 'any';\n }\n}\n\n/**\n * CLI command for generating declarations\n */\nexport async function generateDeclarationsFromCLI(\n args: string[],\n): Promise<void> {\n const manifestPath = args[0];\n const outDir = args[1] || 'src/types/generated';\n\n if (!manifestPath) {\n console.error('Usage: generate-declarations <manifest-path> [output-dir]');\n process.exit(1);\n }\n\n if (!fs.existsSync(manifestPath)) {\n console.error(`Manifest file not found: ${manifestPath}`);\n process.exit(1);\n }\n\n await generateDeclarations({\n manifest: manifestPath,\n outDir,\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/prebuild/index.ts"],"sourcesContent":["/**\n * Pre-build utilities for generating TypeScript declarations\n * Solves virtual module resolution by creating physical .d.ts files\n */\n\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport type { SmartObjectManifest } from '../scanner/types';\nimport {\n renderApiClientCrudType,\n renderApiClientCustomMethodParameters,\n selectApiClientEntries,\n} from '../vite-plugin/api-client-entries.js';\nimport {\n orderedManifestObjectEntries,\n selectWebCollectionEntries,\n} from '../vite-plugin/web-collections.js';\n\nexport interface PrebuildOptions {\n /** Path to manifest file or manifest object */\n manifest: string | SmartObjectManifest;\n /** Output directory for generated types */\n outDir: string;\n /** Include virtual module declarations */\n includeVirtualModules?: boolean;\n /** Include object type definitions */\n includeObjectTypes?: boolean;\n /** Project root path for resolving relative paths */\n projectRoot?: string;\n}\n\n/**\n * Generate TypeScript declaration files from SMRT manifest\n */\nexport async function generateDeclarations(\n options: PrebuildOptions,\n): Promise<void> {\n const {\n manifest: manifestInput,\n outDir,\n includeVirtualModules = true,\n includeObjectTypes = true,\n projectRoot = process.cwd(),\n } = options;\n\n // Load manifest\n const manifest: SmartObjectManifest =\n typeof manifestInput === 'string'\n ? JSON.parse(fs.readFileSync(manifestInput, 'utf-8'))\n : manifestInput;\n\n // Ensure output directory exists\n const fullOutDir = path.isAbsolute(outDir)\n ? outDir\n : path.join(projectRoot, outDir);\n fs.mkdirSync(fullOutDir, { recursive: true });\n\n console.log(`[smrt:prebuild] Generating declarations to ${fullOutDir}`);\n\n if (includeObjectTypes) {\n await generateObjectTypeDeclarations(manifest, fullOutDir);\n }\n\n if (includeVirtualModules) {\n await generateVirtualModuleDeclarations(manifest, fullOutDir);\n }\n\n console.log(\n `[smrt:prebuild] Generated declarations for ${Object.keys(manifest.objects).length} SMRT objects`,\n );\n}\n\n/**\n * Generate TypeScript interfaces for SMRT objects\n */\nasync function generateObjectTypeDeclarations(\n manifest: SmartObjectManifest,\n outDir: string,\n): Promise<void> {\n const interfaces: string[] = [];\n\n // Generate interfaces for each discovered SMRT object. Manifest key order\n // follows scan/discovery order, which is not stable across runs (#2749),\n // so sort deterministically before emitting.\n const sortedObjectEntries = orderedManifestObjectEntries(manifest);\n for (const [_objectName, objectMeta] of sortedObjectEntries) {\n const fields = objectMeta.fields || {};\n const propertyLines: string[] = [];\n\n // Add standard SmrtObject properties\n propertyLines.push(' id?: string;');\n propertyLines.push(' created_at?: string;');\n propertyLines.push(' updated_at?: string;');\n\n // Add object-specific properties\n for (const [fieldName, fieldDef] of Object.entries(fields)) {\n const type = mapFieldTypeToTypeScript(fieldDef.type);\n const optional = !fieldDef.required ? '?' : '';\n propertyLines.push(` ${fieldName}${optional}: ${type};`);\n }\n\n const interfaceDef = `export interface ${objectMeta.className}Data {\n${propertyLines.join('\\n')}\n}`;\n interfaces.push(interfaceDef);\n }\n\n // Write object types file\n const objectTypesContent = `/**\n * Auto-generated TypeScript interfaces for SMRT objects\n * Generated at build time from @smrt() decorated classes\n *\n * DO NOT EDIT THIS FILE MANUALLY\n */\n\n${interfaces.join('\\n\\n')}\n`;\n\n fs.writeFileSync(path.join(outDir, 'smrt-objects.d.ts'), objectTypesContent);\n}\n\n/**\n * Generate virtual module declarations\n */\nasync function generateVirtualModuleDeclarations(\n manifest: SmartObjectManifest,\n outDir: string,\n): Promise<void> {\n // Generate manifest module declaration\n const manifestDeclaration = `/**\n * Auto-generated manifest module declaration\n */\ndeclare module '@smrt/manifest' {\n export interface SmrtObjectField {\n type: string;\n required?: boolean;\n default?: any;\n }\n\n export interface SmrtObjectMethod {\n name: string;\n parameters: Array<{\n name: string;\n type: string;\n optional?: boolean;\n default?: any;\n }>;\n returnType: string;\n async: boolean;\n isStatic: boolean;\n isPublic: boolean;\n }\n\n export interface SmrtObjectDefinition {\n name: string;\n className: string;\n collection: string;\n filePath: string;\n fields: Record<string, SmrtObjectField>;\n methods: Record<string, SmrtObjectMethod>;\n decoratorConfig: any;\n extends?: string;\n }\n\n export interface SmrtManifest {\n version: string;\n timestamp: number;\n objects: Record<string, SmrtObjectDefinition>;\n }\n\n export const manifest: SmrtManifest;\n export default manifest;\n}`;\n\n // Generate client module declaration\n const apiClientInterface = selectApiClientEntries(manifest)\n .map(({ clientKey, dataInterfaceName, crudMethods, customMethods }) => {\n const overriddenMethods = customMethods.map(({ name }) => name);\n const crudType = renderApiClientCrudType(\n dataInterfaceName,\n crudMethods,\n overriddenMethods,\n );\n const customSignatures = customMethods\n .map(\n (method) =>\n ` ${method.name}(${renderApiClientCustomMethodParameters(\n method,\n mapFieldTypeToTypeScript,\n )}): Promise<any>;`,\n )\n .join('\\n');\n\n if (customSignatures) {\n const intersection = crudType ? `${crudType} & ` : '';\n return ` ${JSON.stringify(clientKey)}: ${intersection}{\\n${customSignatures}\\n };`;\n }\n\n return ` ${JSON.stringify(clientKey)}: ${crudType ?? 'Record<string, never>'};`;\n })\n .join('\\n');\n\n // Wire-shape policy (#1797): the server returns BARE JSON — a bare array for\n // list/search, a bare object for get/create/update — with snake_case field\n // names (created_at, updated_at). These physical declarations share client\n // entry selection and method-signature rendering with the Vite declaration.\n // Fetchers reject on non-2xx with a SmrtClientError (#1796).\n const clientDeclaration = `/**\n * Auto-generated API client module declaration\n */\ndeclare module '@smrt/client' {\n /** Structured custom-action failure nested under an API error body's \\`error\\` key. */\n export interface SmrtClientFailure {\n ok: false;\n code: string;\n message: string;\n status?: number;\n }\n\n /** Shape of a JSON error body carried by a rejected request (SmrtClientError.body). */\n export interface ApiError {\n error?: string | SmrtClientFailure;\n message?: string;\n }\n\n /** Typed error thrown by every fetcher on a non-2xx response (#1796). */\n export interface SmrtClientError extends Error {\n name: 'SmrtClientError';\n status: number;\n /** Machine-readable code from a structured custom-action failure, when present. */\n code?: string;\n body?: ApiError | string;\n }\n\n export interface CrudOperations<T = any> {\n list(params?: Record<string, any>): Promise<T[]>;\n get(id: string): Promise<T>;\n create(data: Partial<T>): Promise<T>;\n update(id: string, data: Partial<T>): Promise<T>;\n delete(id: string): Promise<boolean>;\n search(query: string): Promise<T[]>;\n }\n\n export interface ApiClient {\n${apiClientInterface}\n }\n\n export function createClient(basePath?: string): ApiClient;\n export default createClient;\n}`;\n\n // Generate routes module declaration\n const routesDeclaration = `/**\n * Auto-generated routes module declaration\n */\ndeclare module '@smrt/routes' {\n export interface RouteApp {\n get(path: string, handler: (req: any, res: any) => void): void;\n post(path: string, handler: (req: any, res: any) => void): void;\n put(path: string, handler: (req: any, res: any) => void): void;\n delete(path: string, handler: (req: any, res: any) => void): void;\n }\n\n export function setupRoutes(app: RouteApp): void;\n export default setupRoutes;\n}`;\n\n // Generate MCP module declaration\n const mcpDeclaration = `/**\n * Auto-generated MCP module declaration\n */\ndeclare module '@smrt/mcp' {\n export interface McpTool {\n name: string;\n description: string;\n inputSchema: {\n type: string;\n properties: Record<string, any>;\n required?: string[];\n };\n }\n\n export const tools: McpTool[];\n export function createMCPServer(): {\n name: string;\n version: string;\n tools: McpTool[]\n };\n export default createMCPServer;\n}`;\n\n // Generate types module declaration with object imports. Sorted for the\n // same reason as generateObjectTypeDeclarations (#2749).\n const objectImports = orderedManifestObjectEntries(manifest)\n .map(\n ([, obj]) =>\n ` export type ${obj.className}Data = import('./smrt-objects').${obj.className}Data;`,\n )\n .join('\\n');\n\n const typesDeclaration = `/**\n * Auto-generated types module declaration\n */\ndeclare module '@smrt/types' {\n${objectImports}\n\n export interface Request {\n params: Record<string, string>;\n query: Record<string, any>;\n json(): Promise<any>;\n }\n\n export interface Response {\n json(data: any, init?: { status?: number }): Response;\n status(code: number): Response;\n }\n}`;\n\n // Generate web collection-definition module declaration (#1761). Selection\n // shares selectWebCollectionEntries with the vite-plugin runtime module and\n // its virt-web d.ts, so this `tsc`-only consumer declaration cannot drift\n // from the emitted values.\n const webCollectionEntries = selectWebCollectionEntries(manifest)\n .map(\n ({ collection, obj }) =>\n ` ${JSON.stringify(collection)}: SmrtWebCollectionDefinition<import('./smrt-objects').${obj.className}Data>;`,\n )\n .join('\\n');\n\n const webDeclaration = `/**\n * Auto-generated web collection-definition module declaration (#1761)\n */\ndeclare module '@smrt/web' {\n export type SmrtWebFieldType =\n | 'text'\n | 'decimal'\n | 'boolean'\n | 'integer'\n | 'datetime'\n | 'json'\n | 'foreignKey'\n | 'crossPackageRef';\n\n /** Static \\`@field({ ui })\\` hints (#2046) — the field-policy rail seed. */\n export interface SmrtWebFieldUIHints {\n basic?: boolean;\n group?: string;\n order?: number;\n locked?: boolean;\n widget?: 'textarea' | 'currency' | 'email' | 'url' | 'phone';\n format?: 'money' | 'date' | 'datetime' | 'percent';\n }\n\n export interface SmrtWebFieldDefinition {\n type: SmrtWebFieldType;\n required?: boolean;\n nullable?: boolean;\n default?: unknown;\n /** Developer-authored \\`@field({ description })\\` (#2046) — end-user help seed. */\n description?: string;\n /** Static \\`@field({ ui })\\` hints (#2046). */\n ui?: SmrtWebFieldUIHints;\n }\n\n export type SmrtWebRelationshipKind =\n | 'foreignKey'\n | 'crossPackageRef'\n | 'oneToMany'\n | 'manyToMany';\n\n /**\n * A manifest-derived edge to a sibling REST collection. Mutating this\n * collection invalidates the caches of the collections named by these edges\n * (#1761 relationship-derived invalidation).\n */\n export interface SmrtWebRelationship {\n field: string;\n kind: SmrtWebRelationshipKind;\n relatedCollection: string;\n role?: 'items';\n }\n\n export interface WebToolRouteDescriptor {\n method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n scope: 'item' | 'collection';\n path: string[];\n parameterAliases?: Record<string, string>;\n optionsBag?: boolean;\n }\n\n /** A WebMCP/MCP tool descriptor for one collection action (#1812). */\n export interface WebToolDescriptor {\n action: string;\n name: string;\n description: string;\n inputSchema: Record<string, unknown>;\n readOnly: boolean;\n effect: 'read' | 'write' | 'destructive';\n idempotent: boolean;\n openWorld: boolean;\n route?: WebToolRouteDescriptor;\n }\n\n /** Canonical data-only definition for one generated browser tool. */\n export interface WebMcpToolDefinition extends WebToolDescriptor {\n collection: string;\n objectRef: string;\n className: string;\n endpoint: string;\n idField: string;\n idType: 'uuid' | 'text';\n route: WebToolRouteDescriptor;\n relationships: SmrtWebRelationship[];\n }\n\n export interface SmrtWebCollectionDefinition<TData = Record<string, unknown>> {\n name: string;\n /** Canonical qualified model identity (\\`@package/name:ClassName\\`) for policy APIs. */\n objectRef: string;\n className: string;\n endpoint: string;\n idField: string;\n actions: string[];\n fields: Record<string, SmrtWebFieldDefinition>;\n /** Manifest-derived relationship edges to sibling REST collections. */\n relationships: SmrtWebRelationship[];\n /** WebMCP/MCP tool descriptors for the exposed actions (#1812). */\n toolDescriptors: WebToolDescriptor[];\n /** Phantom row-type carrier for inference — never present at runtime. */\n _row?: TData;\n }\n\n export interface SmrtWebCollectionDefinitions {\n${webCollectionEntries}\n }\n\n export const collectionDefinitions: SmrtWebCollectionDefinitions;\n /** Every API-backed WebMCP tool, independent of list materialization. */\n export const webMcpToolDefinitions: readonly WebMcpToolDefinition[];\n export function getCollectionDefinition<\n K extends keyof SmrtWebCollectionDefinitions,\n >(name: K): SmrtWebCollectionDefinitions[K];\n /**\n * Build-time web-collection shape digest (#1764). A deterministic,\n * replica-stable hash of the emitted collection definitions; a change means\n * old persisted client rows may mis-hydrate. Consumers fold it into the\n * durable persistence namespace and the version-awareness updateAvailable\n * contract signal.\n */\n export const manifestHash: string;\n export default collectionDefinitions;\n}`;\n\n // Write all virtual module declarations\n fs.writeFileSync(\n path.join(outDir, 'smrt-manifest.d.ts'),\n manifestDeclaration,\n );\n fs.writeFileSync(path.join(outDir, 'smrt-client.d.ts'), clientDeclaration);\n fs.writeFileSync(path.join(outDir, 'smrt-routes.d.ts'), routesDeclaration);\n fs.writeFileSync(path.join(outDir, 'smrt-mcp.d.ts'), mcpDeclaration);\n fs.writeFileSync(path.join(outDir, 'smrt-types.d.ts'), typesDeclaration);\n fs.writeFileSync(path.join(outDir, 'smrt-web.d.ts'), webDeclaration);\n}\n\n/**\n * Map SMRT field types to TypeScript types\n */\nfunction mapFieldTypeToTypeScript(smrtType: string): string {\n switch (smrtType) {\n case 'text':\n return 'string';\n case 'decimal':\n case 'integer':\n return 'number';\n case 'boolean':\n return 'boolean';\n case 'datetime':\n return 'string | Date';\n case 'json':\n return 'any';\n case 'foreignKey':\n return 'string';\n default:\n return 'any';\n }\n}\n\n/**\n * CLI command for generating declarations\n */\nexport async function generateDeclarationsFromCLI(\n args: string[],\n): Promise<void> {\n const manifestPath = args[0];\n const outDir = args[1] || 'src/types/generated';\n\n if (!manifestPath) {\n console.error('Usage: generate-declarations <manifest-path> [output-dir]');\n process.exit(1);\n }\n\n if (!fs.existsSync(manifestPath)) {\n console.error(`Manifest file not found: ${manifestPath}`);\n process.exit(1);\n }\n\n await generateDeclarations({\n manifest: manifestPath,\n outDir,\n });\n}\n"],"mappings":"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"}
|
package/dist/run-once.js
CHANGED
|
@@ -286,7 +286,7 @@ async function runOnce(params, work) {
|
|
|
286
286
|
INSERT INTO _smrt_run_once_claims
|
|
287
287
|
(claim_key, tenant_id, actor, content_digest, status, result, created_at)
|
|
288
288
|
VALUES
|
|
289
|
-
(${claimKey}, ${tenantId}, ${actor}, ${contentDigest}, ${"in_progress"}, ${null}, ${/* @__PURE__ */ new Date()})
|
|
289
|
+
(${claimKey}, ${tenantId}, ${actor}, ${contentDigest}, ${"in_progress"}, ${null}, ${(/* @__PURE__ */ new Date()).toISOString()})
|
|
290
290
|
ON CONFLICT (claim_key) DO NOTHING
|
|
291
291
|
RETURNING claim_key
|
|
292
292
|
`) return resolveExistingRunOnceClaim(await tx.get(RUN_ONCE_CLAIMS_TABLE, {
|
|
@@ -300,7 +300,7 @@ async function runOnce(params, work) {
|
|
|
300
300
|
}, {
|
|
301
301
|
status: "completed",
|
|
302
302
|
result: JSON.stringify(result ?? null),
|
|
303
|
-
completed_at: /* @__PURE__ */ new Date()
|
|
303
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
304
304
|
});
|
|
305
305
|
return result;
|
|
306
306
|
});
|
package/dist/run-once.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-once.js","names":[],"sources":["../src/run-once.ts"],"sourcesContent":["/**\n * Idempotency seam: run an action exactly once per submission (#3080).\n *\n * Every consumer with a form, a retried request, or a double-tapped button\n * needs the same guard: a claim row, insert-only against a UNIQUE column\n * (`claim_key`) in the SAME transaction as the work it guards, so a retry\n * either runs the work once or replays the first attempt's stored result.\n * This module is that primitive. It is DB/transaction-level — it takes a\n * `DatabaseInterface`, not a `SmrtObject`/`SmrtCollection` — so it works\n * outside SvelteKit and outside the ORM: any caller with a database handle\n * and a JSON-serializable work result can use it. The claim insert itself\n * is raw SQL (`## Concurrent submissions` below explains why), not the\n * ORM's `SmrtObject`-level `_insertOnly` option.\n *\n * ## Key derivation (not the token alone)\n *\n * The claim key is the sha256 hex digest of the sorted-key-JSON encoding of\n * `[tenantId, actor, token, contentDigest]`, where `contentDigest` is itself\n * the sha256 hex digest of the sorted-key-JSON encoding of `content`\n * ({@link digestRunOnceContent}). Sorted-key JSON ({@link stableStringify},\n * `./knowledge-graph.ts`) makes the digest stable regardless of property\n * insertion order.\n *\n * The token alone is NOT the key. A first attempt at this exact problem\n * (reported against #3080) keyed on the token alone and silently collapsed\n * two genuinely different submissions that happened to reuse one form's\n * token. Folding in the content digest means a retry of the SAME submission\n * (same token, same content) replays; a DIFFERENT submission through the\n * same token (edited content before a resubmit) runs as its own claim.\n * Folding in `tenantId` and `actor` means two tenants or two actors can never\n * collide on the same claim even if a token were somehow shared between them.\n *\n * Neither the raw token nor the raw content is ever persisted — only their\n * digests — so the claims table (`_smrt_run_once_claims`,\n * `./system/schema.ts`) cannot leak submitted business data.\n *\n * ## Atomicity\n *\n * `runOnce()` opens its own `db.transaction()` (or nests under the caller's,\n * per `@happyvertical/sql`'s own SAVEPOINT/`NestedTransactionError` rules —\n * see that package's `DatabaseInterface.transaction` doc) and, inside it:\n *\n * 1. Inserts the claim row (`status: 'in_progress'`).\n * 2. Runs `work(tx)`, passing the SAME transaction-bound handle, so the\n * caller's writes and the claim insert commit or roll back together.\n * 3. Updates the claim row to `status: 'completed'` with the JSON-serialized\n * result, still inside the same transaction.\n *\n * If `work()` throws, the callback throws, and `db.transaction()`'s own\n * \"commits on success, rolls back on error\" contract undoes the claim insert\n * along with whatever `work()` partially wrote — a claim is never stranded\n * `'in_progress'` by a failed attempt, and a retry with the same key can\n * always run again.\n *\n * ## Concurrent submissions\n *\n * The claim insert uses `INSERT ... ON CONFLICT (claim_key) DO NOTHING\n * RETURNING claim_key` (the same portable idiom `_smrt_migrations` already\n * uses in `system/bootstrap.ts`), not a plain `INSERT` caught for a\n * classified `unique_violation`. This matters on PostgreSQL specifically: a\n * statement that raises an error aborts the WHOLE transaction (\"current\n * transaction is aborted, commands ignored until end of transaction block\"),\n * so a plain `INSERT` failing on the claim key would make every subsequent\n * statement in the same transaction — including the very `SELECT` this\n * function needs to read the winner's stored result back — fail too.\n * `DO NOTHING` never raises: it returns zero rows instead, which this\n * function reads as \"lost the race\" without ever touching the transaction's\n * error state. SQLite and DuckDB both support the same `ON CONFLICT ... DO\n * NOTHING` / `RETURNING` syntax, so one query works on every adapter.\n *\n * `@happyvertical/sql`'s single-connection adapters (SQLite, DuckDB, JSON)\n * serialize `transaction()` calls on one connection: a second, concurrent\n * `runOnce()` call does not begin its own transaction until the first one\n * ends. PostgreSQL pools, but this `INSERT`'s arbiter still has to check the\n * unique index against another transaction's uncommitted row of the same\n * PRIMARY KEY, and blocks until that transaction resolves, then either\n * no-ops (committed — the row exists) or inserts (rolled back — no row).\n * Either way, two concurrent callers with the same key never both run\n * `work()`: exactly one does, and the loser's `INSERT` returns zero rows —\n * never an error — at which point it reads the now-committed claim row back\n * and replays its stored result.\n *\n * ## Typed answers for a claim that is not (yet) a definite result\n *\n * A `runOnce()` conflict is a *definite* decision — the row exists — but\n * whether it is safe to hand back a result depends on `status`. This mirrors\n * the \"refused vs unknown outcome\" distinction #2990 drew for AssistantDock\n * actions (`./errors.ts#RunOnceClaimError` carries the two codes):\n *\n * - `status: 'completed'` — replay the stored `result`. No error.\n * - `status` anything else (still `'in_progress'`) — some execution holds\n * this key right now. `RunOnceClaimError.inFlight()`. Under this module's\n * own transaction semantics this is normally unreachable through\n * `runOnce()`-to-`runOnce()` races alone (the losing transaction can only\n * ever observe a COMMITTED row, and this module only ever commits a row\n * once it is `'completed'`) — it is reachable if a claim row was inserted\n * outside this module's own completion discipline, and is kept as a\n * defined answer rather than an assumption.\n * - the row cannot be read back at all after `DO NOTHING` reported zero\n * rows inserted — `RunOnceClaimError.outcomeUnknown()`. Also normally\n * unreachable (the zero-rows case only happens when a committed row\n * already exists); kept for adapters/drivers where that and a subsequent\n * read are not guaranteed to observe the same committed state.\n *\n * Both errors carry `claimKey` and are safe to retry with the SAME token and\n * content; `runOnce()` never retries them itself.\n *\n * @module\n */\n\nimport { createHash } from 'node:crypto';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { RunOnceClaimError } from './errors.js';\nimport { stableStringify } from './knowledge-graph.js';\nimport { RUN_ONCE_CLAIMS_TABLE } from './system/schema.js';\n\nexport { RUN_ONCE_CLAIMS_TABLE };\n\n/** Input identifying one submission for {@link runOnce}. */\nexport interface RunOnceParams {\n /** Transaction-capable database handle. May itself already be inside a transaction. */\n db: DatabaseInterface;\n /** Tenant scope. Part of the claim key: claims never cross tenants. */\n tenantId: string;\n /** The submitting principal. Part of the claim key: claims never cross actors. */\n actor: string;\n /** The per-form/per-submission token minted by the caller (e.g. a hidden form field). */\n token: string;\n /**\n * The content being written: plain JSON data, or a `FormData` (digested by\n * its ordered entries; see {@link digestRunOnceContent}). Only its\n * digest is persisted — never the raw value.\n */\n content: unknown;\n}\n\ninterface RunOnceClaimRow {\n claim_key: string;\n status: string;\n result: string | null;\n}\n\n/** Key tagging a normalized `FormData` inside the FormData hash domain. */\nconst FORM_DATA_TAG = '\\u0000FormData';\n\n/**\n * Prefix of the hash input for content containing a `FormData`. Sorted-key\n * JSON always starts with a JSON token, never `\\u0001`, so FormData digests\n * can never equal a plain-JSON digest, and plain-JSON digests stay exactly\n * what #3080 stored.\n */\nconst FORM_DATA_DOMAIN = '\\u0001run-once-formdata-v1\\n';\n\n/** A captured `FormData`: its ordered entries, already normalized. */\nclass NormalizedFormData {\n constructor(readonly entries: Array<[string, unknown]>) {}\n}\n\ninterface NormalizeState {\n sawFormData: boolean;\n}\n\n/** Marks a value JSON omits from an object (and writes as `null` in an array). */\nconst OMIT: unique symbol = Symbol('run-once-omit');\n\n/**\n * Reduce `content` to plain JSON data before hashing, following JSON\n * serialization exactly — `toJSON(key)` is called with the property name or\n * array index (so a `Date` digests as its ISO string), and `undefined`,\n * function and symbol values are omitted from objects and written as `null` in\n * arrays — with two differences:\n *\n * - A native `FormData` has no own enumerable properties, so JSON reduces\n * every form to `{}` and would collapse distinct submissions (#3136). It\n * becomes its ordered entry list instead — repeated fields and their order\n * are significant — and a `File`/`Blob` entry digests by name, type and\n * size, never its bytes.\n * - Values JSON would silently misrepresent (`Map`, `Set`, `RegExp`, any other\n * non-plain object without `toJSON()`) or cannot serialize (`bigint`) are\n * rejected, not hashed as `{}`.\n */\nfunction normalizeRunOnceContent(\n input: unknown,\n state: NormalizeState,\n key: string,\n): unknown {\n let value = input;\n if (\n value !== null &&\n (typeof value === 'object' || typeof value === 'bigint') &&\n typeof (value as { toJSON?: unknown }).toJSON === 'function'\n ) {\n value = (value as { toJSON: (key: string) => unknown }).toJSON(key);\n }\n if (value === null) return null;\n switch (typeof value) {\n case 'undefined':\n case 'function':\n case 'symbol':\n return OMIT;\n case 'string':\n case 'boolean':\n return value;\n case 'number':\n return Number.isFinite(value) ? value : null;\n case 'bigint':\n throw new TypeError(\n 'runOnce content cannot contain a bigint; pass plain JSON data',\n );\n }\n if (typeof FormData !== 'undefined' && value instanceof FormData) {\n state.sawFormData = true;\n const entries: Array<[string, unknown]> = [];\n // `forEach`, not `entries()`: consumers type-check core's source against\n // DOM libs without `DOM.Iterable`, where FormData is not iterable.\n value.forEach((entry, name) => {\n entries.push([name, normalizeRunOnceContent(entry, state, name)]);\n });\n return new NormalizedFormData(entries);\n }\n if (typeof Blob !== 'undefined' && value instanceof Blob) {\n return {\n name: (value as Blob & { name?: string }).name ?? null,\n size: value.size,\n type: value.type,\n };\n }\n if (Array.isArray(value)) {\n return value.map((item, index) => {\n const normalized = normalizeRunOnceContent(item, state, String(index));\n return normalized === OMIT ? null : normalized;\n });\n }\n const prototype = Object.getPrototypeOf(value);\n if (prototype !== Object.prototype && prototype !== null) {\n const name =\n (value as { constructor?: { name?: string } }).constructor?.name ||\n 'non-plain object';\n throw new TypeError(\n `runOnce content cannot contain a ${name}; convert it to plain JSON data`,\n );\n }\n const result: Record<string, unknown> = Object.create(null);\n for (const [member, memberValue] of Object.entries(value as object)) {\n const normalized = normalizeRunOnceContent(memberValue, state, member);\n if (normalized !== OMIT) result[member] = normalized;\n }\n return result;\n}\n\n/**\n * Map a normalized tree containing {@link NormalizedFormData} into the\n * FormData hash domain: each form becomes `{ [FORM_DATA_TAG]: entries }`, and\n * user keys starting with NUL are escaped (NUL doubled) so no plain object can\n * forge the tag. Works on the captured tree, so no getter or `toJSON()` on the\n * caller's content runs twice.\n */\nfunction toFormDataDomain(value: unknown): unknown {\n if (value instanceof NormalizedFormData) {\n return {\n [FORM_DATA_TAG]: value.entries.map(([key, entry]) => [\n key,\n toFormDataDomain(entry),\n ]),\n };\n }\n if (Array.isArray(value)) return value.map(toFormDataDomain);\n if (value !== null && typeof value === 'object') {\n const result: Record<string, unknown> = Object.create(null);\n for (const [key, member] of Object.entries(value)) {\n result[key.startsWith('\\u0000') ? `\\u0000${key}` : key] =\n toFormDataDomain(member);\n }\n return result;\n }\n return value;\n}\n\n/**\n * Deterministic content digest: sha256 of sorted-key JSON.\n *\n * Property order never changes the digest, so callers do not need to worry\n * about object construction order producing two different claims for what is\n * semantically the same submission. Content is normalized exactly once (see\n * {@link normalizeRunOnceContent}): `toJSON()` values such as `Date` digest as\n * what they serialize to, a `FormData` digests as its ordered entries in a\n * separate hash domain, and values JSON would reduce to `{}` (`Map`, `Set`,\n * `RegExp`, …) are rejected.\n *\n * @throws {TypeError} when `content` contains a value with no faithful JSON form.\n */\nexport function digestRunOnceContent(content: unknown): string {\n const state: NormalizeState = { sawFormData: false };\n const captured = normalizeRunOnceContent(content, state, '');\n // JSON.stringify of a top-level omitted value is `undefined`; #3080 hashed it as null.\n const normalized = captured === OMIT ? null : captured;\n const input = state.sawFormData\n ? FORM_DATA_DOMAIN + stableStringify(toFormDataDomain(normalized))\n : stableStringify(normalized);\n return createHash('sha256').update(input).digest('hex');\n}\n\n/**\n * Derive the {@link runOnce} claim key.\n *\n * NOT the token alone — see the module doc for why that collapses distinct\n * submissions. Folds in `tenantId`, `actor`, the raw `token`, and the\n * content digest, all through sorted-key JSON before hashing.\n */\nexport function deriveRunOnceClaimKey(params: {\n tenantId: string;\n actor: string;\n token: string;\n contentDigest: string;\n}): string {\n const canonical = stableStringify([\n params.tenantId,\n params.actor,\n params.token,\n params.contentDigest,\n ]);\n return createHash('sha256').update(canonical).digest('hex');\n}\n\n/**\n * Resolve an existing claim row into either a replayable result or a typed\n * error. Pulled out of {@link runOnce} as a pure function so both branches\n * (`completed` vs. anything else vs. unreadable) are directly unit-testable\n * without forcing a genuine database race.\n */\nexport function resolveExistingRunOnceClaim<T>(\n existing: RunOnceClaimRow | null,\n claimKey: string,\n): T {\n if (!existing) {\n throw RunOnceClaimError.outcomeUnknown(claimKey);\n }\n if (existing.status !== 'completed') {\n throw RunOnceClaimError.inFlight(claimKey);\n }\n return JSON.parse(existing.result ?? 'null') as T;\n}\n\n/**\n * Run `work` exactly once per submission.\n *\n * ```ts\n * const purchaseOrderId = await runOnce(\n * { db, tenantId, actor, token, content: formData },\n * async (tx) => createPurchaseOrder(tx, formData),\n * );\n * ```\n *\n * Resolves to `work`'s return value on both a fresh run and a replay of a\n * prior completed run — the caller cannot tell the difference from the\n * return value alone (by design: both are \"your submission is durably\n * applied\"). Rejects with `RunOnceClaimError` (`./errors.ts`) when the claim\n * cannot be resolved to a definite result right now; see the module doc for\n * the `RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN` distinction. Any\n * other rejection (including one from `work` itself) propagates unchanged,\n * and the claim is rolled back with the transaction so a retry can run again.\n */\nexport async function runOnce<T>(\n params: RunOnceParams,\n work: (tx: DatabaseInterface) => Promise<T>,\n): Promise<T> {\n const { db, tenantId, actor, token, content } = params;\n if (!db.transaction) {\n throw new Error(\n 'runOnce: the supplied database does not support transaction()',\n );\n }\n\n const contentDigest = digestRunOnceContent(content);\n const claimKey = deriveRunOnceClaimKey({\n tenantId,\n actor,\n token,\n contentDigest,\n });\n\n return db.transaction(async (tx) => {\n // `ON CONFLICT ... DO NOTHING` never raises — see the module doc for why\n // a plain INSERT-and-catch aborts the whole transaction on PostgreSQL,\n // taking the recovery SELECT below down with it. `RETURNING claim_key`\n // comes back with one row when this call won the claim, zero when it\n // lost it — that is the only signal this function needs.\n const won = await tx.single`\n INSERT INTO _smrt_run_once_claims\n (claim_key, tenant_id, actor, content_digest, status, result, created_at)\n VALUES\n (${claimKey}, ${tenantId}, ${actor}, ${contentDigest}, ${'in_progress'}, ${null}, ${new Date()})\n ON CONFLICT (claim_key) DO NOTHING\n RETURNING claim_key\n `;\n\n if (!won) {\n const existing = (await tx.get(RUN_ONCE_CLAIMS_TABLE, {\n claim_key: claimKey,\n tenant_id: tenantId,\n })) as RunOnceClaimRow | null;\n return resolveExistingRunOnceClaim<T>(existing, claimKey);\n }\n\n const result = await work(tx);\n await tx.update(\n RUN_ONCE_CLAIMS_TABLE,\n { claim_key: claimKey, tenant_id: tenantId },\n {\n status: 'completed',\n result: JSON.stringify(result ?? null),\n completed_at: new Date(),\n },\n );\n return result;\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"run-once.js","names":[],"sources":["../src/run-once.ts"],"sourcesContent":["/**\n * Idempotency seam: run an action exactly once per submission (#3080).\n *\n * Every consumer with a form, a retried request, or a double-tapped button\n * needs the same guard: a claim row, insert-only against a UNIQUE column\n * (`claim_key`) in the SAME transaction as the work it guards, so a retry\n * either runs the work once or replays the first attempt's stored result.\n * This module is that primitive. It is DB/transaction-level — it takes a\n * `DatabaseInterface`, not a `SmrtObject`/`SmrtCollection` — so it works\n * outside SvelteKit and outside the ORM: any caller with a database handle\n * and a JSON-serializable work result can use it. The claim insert itself\n * is raw SQL (`## Concurrent submissions` below explains why), not the\n * ORM's `SmrtObject`-level `_insertOnly` option.\n *\n * ## Key derivation (not the token alone)\n *\n * The claim key is the sha256 hex digest of the sorted-key-JSON encoding of\n * `[tenantId, actor, token, contentDigest]`, where `contentDigest` is itself\n * the sha256 hex digest of the sorted-key-JSON encoding of `content`\n * ({@link digestRunOnceContent}). Sorted-key JSON ({@link stableStringify},\n * `./knowledge-graph.ts`) makes the digest stable regardless of property\n * insertion order.\n *\n * The token alone is NOT the key. A first attempt at this exact problem\n * (reported against #3080) keyed on the token alone and silently collapsed\n * two genuinely different submissions that happened to reuse one form's\n * token. Folding in the content digest means a retry of the SAME submission\n * (same token, same content) replays; a DIFFERENT submission through the\n * same token (edited content before a resubmit) runs as its own claim.\n * Folding in `tenantId` and `actor` means two tenants or two actors can never\n * collide on the same claim even if a token were somehow shared between them.\n *\n * Neither the raw token nor the raw content is ever persisted — only their\n * digests — so the claims table (`_smrt_run_once_claims`,\n * `./system/schema.ts`) cannot leak submitted business data.\n *\n * ## Atomicity\n *\n * `runOnce()` opens its own `db.transaction()` (or nests under the caller's,\n * per `@happyvertical/sql`'s own SAVEPOINT/`NestedTransactionError` rules —\n * see that package's `DatabaseInterface.transaction` doc) and, inside it:\n *\n * 1. Inserts the claim row (`status: 'in_progress'`).\n * 2. Runs `work(tx)`, passing the SAME transaction-bound handle, so the\n * caller's writes and the claim insert commit or roll back together.\n * 3. Updates the claim row to `status: 'completed'` with the JSON-serialized\n * result, still inside the same transaction.\n *\n * If `work()` throws, the callback throws, and `db.transaction()`'s own\n * \"commits on success, rolls back on error\" contract undoes the claim insert\n * along with whatever `work()` partially wrote — a claim is never stranded\n * `'in_progress'` by a failed attempt, and a retry with the same key can\n * always run again.\n *\n * ## Concurrent submissions\n *\n * The claim insert uses `INSERT ... ON CONFLICT (claim_key) DO NOTHING\n * RETURNING claim_key` (the same portable idiom `_smrt_migrations` already\n * uses in `system/bootstrap.ts`), not a plain `INSERT` caught for a\n * classified `unique_violation`. This matters on PostgreSQL specifically: a\n * statement that raises an error aborts the WHOLE transaction (\"current\n * transaction is aborted, commands ignored until end of transaction block\"),\n * so a plain `INSERT` failing on the claim key would make every subsequent\n * statement in the same transaction — including the very `SELECT` this\n * function needs to read the winner's stored result back — fail too.\n * `DO NOTHING` never raises: it returns zero rows instead, which this\n * function reads as \"lost the race\" without ever touching the transaction's\n * error state. SQLite and DuckDB both support the same `ON CONFLICT ... DO\n * NOTHING` / `RETURNING` syntax, so one query works on every adapter.\n *\n * `@happyvertical/sql`'s single-connection adapters (SQLite, DuckDB, JSON)\n * serialize `transaction()` calls on one connection: a second, concurrent\n * `runOnce()` call does not begin its own transaction until the first one\n * ends. PostgreSQL pools, but this `INSERT`'s arbiter still has to check the\n * unique index against another transaction's uncommitted row of the same\n * PRIMARY KEY, and blocks until that transaction resolves, then either\n * no-ops (committed — the row exists) or inserts (rolled back — no row).\n * Either way, two concurrent callers with the same key never both run\n * `work()`: exactly one does, and the loser's `INSERT` returns zero rows —\n * never an error — at which point it reads the now-committed claim row back\n * and replays its stored result.\n *\n * ## Typed answers for a claim that is not (yet) a definite result\n *\n * A `runOnce()` conflict is a *definite* decision — the row exists — but\n * whether it is safe to hand back a result depends on `status`. This mirrors\n * the \"refused vs unknown outcome\" distinction #2990 drew for AssistantDock\n * actions (`./errors.ts#RunOnceClaimError` carries the two codes):\n *\n * - `status: 'completed'` — replay the stored `result`. No error.\n * - `status` anything else (still `'in_progress'`) — some execution holds\n * this key right now. `RunOnceClaimError.inFlight()`. Under this module's\n * own transaction semantics this is normally unreachable through\n * `runOnce()`-to-`runOnce()` races alone (the losing transaction can only\n * ever observe a COMMITTED row, and this module only ever commits a row\n * once it is `'completed'`) — it is reachable if a claim row was inserted\n * outside this module's own completion discipline, and is kept as a\n * defined answer rather than an assumption.\n * - the row cannot be read back at all after `DO NOTHING` reported zero\n * rows inserted — `RunOnceClaimError.outcomeUnknown()`. Also normally\n * unreachable (the zero-rows case only happens when a committed row\n * already exists); kept for adapters/drivers where that and a subsequent\n * read are not guaranteed to observe the same committed state.\n *\n * Both errors carry `claimKey` and are safe to retry with the SAME token and\n * content; `runOnce()` never retries them itself.\n *\n * @module\n */\n\nimport { createHash } from 'node:crypto';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { RunOnceClaimError } from './errors.js';\nimport { stableStringify } from './knowledge-graph.js';\nimport { RUN_ONCE_CLAIMS_TABLE } from './system/schema.js';\n\nexport { RUN_ONCE_CLAIMS_TABLE };\n\n/** Input identifying one submission for {@link runOnce}. */\nexport interface RunOnceParams {\n /** Transaction-capable database handle. May itself already be inside a transaction. */\n db: DatabaseInterface;\n /** Tenant scope. Part of the claim key: claims never cross tenants. */\n tenantId: string;\n /** The submitting principal. Part of the claim key: claims never cross actors. */\n actor: string;\n /** The per-form/per-submission token minted by the caller (e.g. a hidden form field). */\n token: string;\n /**\n * The content being written: plain JSON data, or a `FormData` (digested by\n * its ordered entries; see {@link digestRunOnceContent}). Only its\n * digest is persisted — never the raw value.\n */\n content: unknown;\n}\n\ninterface RunOnceClaimRow {\n claim_key: string;\n status: string;\n result: string | null;\n}\n\n/** Key tagging a normalized `FormData` inside the FormData hash domain. */\nconst FORM_DATA_TAG = '\\u0000FormData';\n\n/**\n * Prefix of the hash input for content containing a `FormData`. Sorted-key\n * JSON always starts with a JSON token, never `\\u0001`, so FormData digests\n * can never equal a plain-JSON digest, and plain-JSON digests stay exactly\n * what #3080 stored.\n */\nconst FORM_DATA_DOMAIN = '\\u0001run-once-formdata-v1\\n';\n\n/** A captured `FormData`: its ordered entries, already normalized. */\nclass NormalizedFormData {\n constructor(readonly entries: Array<[string, unknown]>) {}\n}\n\ninterface NormalizeState {\n sawFormData: boolean;\n}\n\n/** Marks a value JSON omits from an object (and writes as `null` in an array). */\nconst OMIT: unique symbol = Symbol('run-once-omit');\n\n/**\n * Reduce `content` to plain JSON data before hashing, following JSON\n * serialization exactly — `toJSON(key)` is called with the property name or\n * array index (so a `Date` digests as its ISO string), and `undefined`,\n * function and symbol values are omitted from objects and written as `null` in\n * arrays — with two differences:\n *\n * - A native `FormData` has no own enumerable properties, so JSON reduces\n * every form to `{}` and would collapse distinct submissions (#3136). It\n * becomes its ordered entry list instead — repeated fields and their order\n * are significant — and a `File`/`Blob` entry digests by name, type and\n * size, never its bytes.\n * - Values JSON would silently misrepresent (`Map`, `Set`, `RegExp`, any other\n * non-plain object without `toJSON()`) or cannot serialize (`bigint`) are\n * rejected, not hashed as `{}`.\n */\nfunction normalizeRunOnceContent(\n input: unknown,\n state: NormalizeState,\n key: string,\n): unknown {\n let value = input;\n if (\n value !== null &&\n (typeof value === 'object' || typeof value === 'bigint') &&\n typeof (value as { toJSON?: unknown }).toJSON === 'function'\n ) {\n value = (value as { toJSON: (key: string) => unknown }).toJSON(key);\n }\n if (value === null) return null;\n switch (typeof value) {\n case 'undefined':\n case 'function':\n case 'symbol':\n return OMIT;\n case 'string':\n case 'boolean':\n return value;\n case 'number':\n return Number.isFinite(value) ? value : null;\n case 'bigint':\n throw new TypeError(\n 'runOnce content cannot contain a bigint; pass plain JSON data',\n );\n }\n if (typeof FormData !== 'undefined' && value instanceof FormData) {\n state.sawFormData = true;\n const entries: Array<[string, unknown]> = [];\n // `forEach`, not `entries()`: consumers type-check core's source against\n // DOM libs without `DOM.Iterable`, where FormData is not iterable.\n value.forEach((entry, name) => {\n entries.push([name, normalizeRunOnceContent(entry, state, name)]);\n });\n return new NormalizedFormData(entries);\n }\n if (typeof Blob !== 'undefined' && value instanceof Blob) {\n return {\n name: (value as Blob & { name?: string }).name ?? null,\n size: value.size,\n type: value.type,\n };\n }\n if (Array.isArray(value)) {\n return value.map((item, index) => {\n const normalized = normalizeRunOnceContent(item, state, String(index));\n return normalized === OMIT ? null : normalized;\n });\n }\n const prototype = Object.getPrototypeOf(value);\n if (prototype !== Object.prototype && prototype !== null) {\n const name =\n (value as { constructor?: { name?: string } }).constructor?.name ||\n 'non-plain object';\n throw new TypeError(\n `runOnce content cannot contain a ${name}; convert it to plain JSON data`,\n );\n }\n const result: Record<string, unknown> = Object.create(null);\n for (const [member, memberValue] of Object.entries(value as object)) {\n const normalized = normalizeRunOnceContent(memberValue, state, member);\n if (normalized !== OMIT) result[member] = normalized;\n }\n return result;\n}\n\n/**\n * Map a normalized tree containing {@link NormalizedFormData} into the\n * FormData hash domain: each form becomes `{ [FORM_DATA_TAG]: entries }`, and\n * user keys starting with NUL are escaped (NUL doubled) so no plain object can\n * forge the tag. Works on the captured tree, so no getter or `toJSON()` on the\n * caller's content runs twice.\n */\nfunction toFormDataDomain(value: unknown): unknown {\n if (value instanceof NormalizedFormData) {\n return {\n [FORM_DATA_TAG]: value.entries.map(([key, entry]) => [\n key,\n toFormDataDomain(entry),\n ]),\n };\n }\n if (Array.isArray(value)) return value.map(toFormDataDomain);\n if (value !== null && typeof value === 'object') {\n const result: Record<string, unknown> = Object.create(null);\n for (const [key, member] of Object.entries(value)) {\n result[key.startsWith('\\u0000') ? `\\u0000${key}` : key] =\n toFormDataDomain(member);\n }\n return result;\n }\n return value;\n}\n\n/**\n * Deterministic content digest: sha256 of sorted-key JSON.\n *\n * Property order never changes the digest, so callers do not need to worry\n * about object construction order producing two different claims for what is\n * semantically the same submission. Content is normalized exactly once (see\n * {@link normalizeRunOnceContent}): `toJSON()` values such as `Date` digest as\n * what they serialize to, a `FormData` digests as its ordered entries in a\n * separate hash domain, and values JSON would reduce to `{}` (`Map`, `Set`,\n * `RegExp`, …) are rejected.\n *\n * @throws {TypeError} when `content` contains a value with no faithful JSON form.\n */\nexport function digestRunOnceContent(content: unknown): string {\n const state: NormalizeState = { sawFormData: false };\n const captured = normalizeRunOnceContent(content, state, '');\n // JSON.stringify of a top-level omitted value is `undefined`; #3080 hashed it as null.\n const normalized = captured === OMIT ? null : captured;\n const input = state.sawFormData\n ? FORM_DATA_DOMAIN + stableStringify(toFormDataDomain(normalized))\n : stableStringify(normalized);\n return createHash('sha256').update(input).digest('hex');\n}\n\n/**\n * Derive the {@link runOnce} claim key.\n *\n * NOT the token alone — see the module doc for why that collapses distinct\n * submissions. Folds in `tenantId`, `actor`, the raw `token`, and the\n * content digest, all through sorted-key JSON before hashing.\n */\nexport function deriveRunOnceClaimKey(params: {\n tenantId: string;\n actor: string;\n token: string;\n contentDigest: string;\n}): string {\n const canonical = stableStringify([\n params.tenantId,\n params.actor,\n params.token,\n params.contentDigest,\n ]);\n return createHash('sha256').update(canonical).digest('hex');\n}\n\n/**\n * Resolve an existing claim row into either a replayable result or a typed\n * error. Pulled out of {@link runOnce} as a pure function so both branches\n * (`completed` vs. anything else vs. unreadable) are directly unit-testable\n * without forcing a genuine database race.\n */\nexport function resolveExistingRunOnceClaim<T>(\n existing: RunOnceClaimRow | null,\n claimKey: string,\n): T {\n if (!existing) {\n throw RunOnceClaimError.outcomeUnknown(claimKey);\n }\n if (existing.status !== 'completed') {\n throw RunOnceClaimError.inFlight(claimKey);\n }\n return JSON.parse(existing.result ?? 'null') as T;\n}\n\n/**\n * Run `work` exactly once per submission.\n *\n * ```ts\n * const purchaseOrderId = await runOnce(\n * { db, tenantId, actor, token, content: formData },\n * async (tx) => createPurchaseOrder(tx, formData),\n * );\n * ```\n *\n * Resolves to `work`'s return value on both a fresh run and a replay of a\n * prior completed run — the caller cannot tell the difference from the\n * return value alone (by design: both are \"your submission is durably\n * applied\"). Rejects with `RunOnceClaimError` (`./errors.ts`) when the claim\n * cannot be resolved to a definite result right now; see the module doc for\n * the `RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN` distinction. Any\n * other rejection (including one from `work` itself) propagates unchanged,\n * and the claim is rolled back with the transaction so a retry can run again.\n */\nexport async function runOnce<T>(\n params: RunOnceParams,\n work: (tx: DatabaseInterface) => Promise<T>,\n): Promise<T> {\n const { db, tenantId, actor, token, content } = params;\n if (!db.transaction) {\n throw new Error(\n 'runOnce: the supplied database does not support transaction()',\n );\n }\n\n const contentDigest = digestRunOnceContent(content);\n const claimKey = deriveRunOnceClaimKey({\n tenantId,\n actor,\n token,\n contentDigest,\n });\n\n return db.transaction(async (tx) => {\n // `ON CONFLICT ... DO NOTHING` never raises — see the module doc for why\n // a plain INSERT-and-catch aborts the whole transaction on PostgreSQL,\n // taking the recovery SELECT below down with it. `RETURNING claim_key`\n // comes back with one row when this call won the claim, zero when it\n // lost it — that is the only signal this function needs.\n const won = await tx.single`\n INSERT INTO _smrt_run_once_claims\n (claim_key, tenant_id, actor, content_digest, status, result, created_at)\n VALUES\n (${claimKey}, ${tenantId}, ${actor}, ${contentDigest}, ${'in_progress'}, ${null}, ${new Date().toISOString()})\n ON CONFLICT (claim_key) DO NOTHING\n RETURNING claim_key\n `;\n\n if (!won) {\n const existing = (await tx.get(RUN_ONCE_CLAIMS_TABLE, {\n claim_key: claimKey,\n tenant_id: tenantId,\n })) as RunOnceClaimRow | null;\n return resolveExistingRunOnceClaim<T>(existing, claimKey);\n }\n\n const result = await work(tx);\n await tx.update(\n RUN_ONCE_CLAIMS_TABLE,\n { claim_key: claimKey, tenant_id: tenantId },\n {\n status: 'completed',\n result: JSON.stringify(result ?? null),\n completed_at: new Date().toISOString(),\n },\n );\n return result;\n });\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest-generator.d.ts","sourceRoot":"","sources":["../../src/scanner/manifest-generator.ts"],"names":[],"mappings":"AAAA;;GAEG;
|
|
1
|
+
{"version":3,"file":"manifest-generator.d.ts","sourceRoot":"","sources":["../../src/scanner/manifest-generator.ts"],"names":[],"mappings":"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"}
|
|
@@ -8,7 +8,7 @@ import { loadExternalManifestSync, lookupInManifest } from "../manifest/manifest
|
|
|
8
8
|
import { VERBOSE_ENABLED } from "../registry/shared-state.js";
|
|
9
9
|
import { generateToolManifest } from "../tools/tool-generator.js";
|
|
10
10
|
import { createManifestClassNamePredicate, resolveApiMethodExposure, resolveCustomActionMetadata, resolveEffectiveActionMetadata } from "../generators/custom-action.js";
|
|
11
|
-
import { FIELD_UI_WIDGETS, FIELD_UI_WIDGET_TYPES, displayLabelFieldProblem, isFieldUIWidget, resolveDisplayLabelField } from "../ui-metadata.js";
|
|
11
|
+
import { FIELD_UI_FORMATS, FIELD_UI_FORMAT_TYPES, FIELD_UI_WIDGETS, FIELD_UI_WIDGET_TYPES, RELATIONSHIP_ROLES, displayLabelFieldProblem, isFieldUIFormat, isFieldUIWidget, isRelationshipRole, resolveDisplayLabelField } from "../ui-metadata.js";
|
|
12
12
|
import { findField, validateHelp } from "../recipe-help.js";
|
|
13
13
|
import { SchemaGenerator } from "../schema/generator.js";
|
|
14
14
|
import { applyRecipeDemo } from "./recipe-demo-pass.js";
|
|
@@ -412,8 +412,18 @@ var ManifestGenerator = class {
|
|
|
412
412
|
const obj = manifest.objects[key];
|
|
413
413
|
const owner = obj.qualifiedName ?? key;
|
|
414
414
|
for (const [fieldName, field] of Object.entries(obj.fields)) {
|
|
415
|
+
const role = field._meta?.role;
|
|
416
|
+
if (role !== void 0) {
|
|
417
|
+
if (!isRelationshipRole(role)) failures.push(`${owner}.${fieldName}: role ${JSON.stringify(role)} is not one of ${RELATIONSHIP_ROLES.join(", ")}.`);
|
|
418
|
+
else if (field.type !== "foreignKey" && field.type !== "crossPackageRef") failures.push(`${owner}.${fieldName}: role "${role}" needs a foreignKey or crossPackageRef field, but this field is ${field.type}.`);
|
|
419
|
+
}
|
|
415
420
|
const ui = field._meta?.ui;
|
|
416
|
-
if (!ui || typeof ui !== "object"
|
|
421
|
+
if (!ui || typeof ui !== "object") continue;
|
|
422
|
+
if (ui.format !== void 0) {
|
|
423
|
+
if (!isFieldUIFormat(ui.format)) failures.push(`${owner}.${fieldName}: ui.format ${JSON.stringify(ui.format)} is not one of ${FIELD_UI_FORMATS.join(", ")}.`);
|
|
424
|
+
else if (!FIELD_UI_FORMAT_TYPES[ui.format].includes(field.type)) failures.push(`${owner}.${fieldName}: ui.format "${ui.format}" needs a field of type ${FIELD_UI_FORMAT_TYPES[ui.format].join(" or ")}, but this field is ${field.type}.`);
|
|
425
|
+
}
|
|
426
|
+
if (ui.widget === void 0) continue;
|
|
417
427
|
if (!isFieldUIWidget(ui.widget)) failures.push(`${owner}.${fieldName}: ui.widget ${JSON.stringify(ui.widget)} is not one of ${FIELD_UI_WIDGETS.join(", ")}.`);
|
|
418
428
|
else if (!FIELD_UI_WIDGET_TYPES[ui.widget].includes(field.type)) failures.push(`${owner}.${fieldName}: ui.widget "${ui.widget}" needs a field of type ${FIELD_UI_WIDGET_TYPES[ui.widget].join(" or ")}, but this field is ${field.type}.`);
|
|
419
429
|
}
|