@happyvertical/smrt-core 0.37.7 → 0.37.9

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.
Files changed (88) hide show
  1. package/AGENTS.md +25 -6
  2. package/dist/change-feed.d.ts +261 -0
  3. package/dist/change-feed.d.ts.map +1 -0
  4. package/dist/change-feed.js +482 -0
  5. package/dist/change-feed.js.map +1 -0
  6. package/dist/class.d.ts.map +1 -1
  7. package/dist/class.js +4 -2
  8. package/dist/class.js.map +1 -1
  9. package/dist/collection.js +2 -2
  10. package/dist/consumer-plugin/index.d.ts.map +1 -1
  11. package/dist/consumer-plugin/index.js +24 -19
  12. package/dist/consumer-plugin/index.js.map +1 -1
  13. package/dist/dispatch/bus.js +2 -2
  14. package/dist/dispatch/index.d.ts +1 -1
  15. package/dist/dispatch/index.d.ts.map +1 -1
  16. package/dist/dispatch/index.js +2 -2
  17. package/dist/dispatch/tenant-resolver.d.ts +23 -0
  18. package/dist/dispatch/tenant-resolver.d.ts.map +1 -1
  19. package/dist/dispatch/tenant-resolver.js +33 -1
  20. package/dist/dispatch/tenant-resolver.js.map +1 -1
  21. package/dist/generated-client-runtime.d.ts +26 -0
  22. package/dist/generated-client-runtime.d.ts.map +1 -0
  23. package/dist/generated-client-runtime.js +88 -0
  24. package/dist/generated-client-runtime.js.map +1 -0
  25. package/dist/generators/changes-route.d.ts +52 -0
  26. package/dist/generators/changes-route.d.ts.map +1 -0
  27. package/dist/generators/changes-route.js +111 -0
  28. package/dist/generators/changes-route.js.map +1 -0
  29. package/dist/generators/conditional-get.d.ts +12 -0
  30. package/dist/generators/conditional-get.d.ts.map +1 -1
  31. package/dist/generators/conditional-get.js +30 -2
  32. package/dist/generators/conditional-get.js.map +1 -1
  33. package/dist/generators/rest.d.ts +58 -0
  34. package/dist/generators/rest.d.ts.map +1 -1
  35. package/dist/generators/rest.js +114 -21
  36. package/dist/generators/rest.js.map +1 -1
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +6 -5
  40. package/dist/manifest/manifest-loader.d.ts +10 -1
  41. package/dist/manifest/manifest-loader.d.ts.map +1 -1
  42. package/dist/manifest/manifest-loader.js +18 -5
  43. package/dist/manifest/manifest-loader.js.map +1 -1
  44. package/dist/manifest/static-manifest.js +2 -2
  45. package/dist/manifest/static-manifest.js.map +1 -1
  46. package/dist/manifest/store.js +1 -1
  47. package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
  48. package/dist/manifest/test-manifest-stub.js +880 -105
  49. package/dist/manifest/test-manifest-stub.js.map +1 -1
  50. package/dist/manifest.json +2 -2
  51. package/dist/object.js +2 -2
  52. package/dist/prebuild/index.d.ts.map +1 -1
  53. package/dist/prebuild/index.js +14 -7
  54. package/dist/prebuild/index.js.map +1 -1
  55. package/dist/registry/shared-state.d.ts +8 -1
  56. package/dist/registry/shared-state.d.ts.map +1 -1
  57. package/dist/registry/shared-state.js +11 -3
  58. package/dist/registry/shared-state.js.map +1 -1
  59. package/dist/smrt-knowledge.json +6 -6
  60. package/dist/system/schema.d.ts +24 -1
  61. package/dist/system/schema.d.ts.map +1 -1
  62. package/dist/system/schema.js +45 -3
  63. package/dist/system/schema.js.map +1 -1
  64. package/dist/testing/database.js +1 -1
  65. package/dist/utils/stack-frames.d.ts +50 -0
  66. package/dist/utils/stack-frames.d.ts.map +1 -0
  67. package/dist/utils/stack-frames.js +64 -0
  68. package/dist/utils/stack-frames.js.map +1 -0
  69. package/dist/vite-plugin/changes-route.d.ts +10 -0
  70. package/dist/vite-plugin/changes-route.d.ts.map +1 -0
  71. package/dist/vite-plugin/changes-route.js +174 -0
  72. package/dist/vite-plugin/changes-route.js.map +1 -0
  73. package/dist/vite-plugin/index.d.ts +7 -0
  74. package/dist/vite-plugin/index.d.ts.map +1 -1
  75. package/dist/vite-plugin/index.js +38 -20
  76. package/dist/vite-plugin/index.js.map +1 -1
  77. package/dist/vite-plugin/route-header.d.ts +10 -0
  78. package/dist/vite-plugin/route-header.d.ts.map +1 -0
  79. package/dist/vite-plugin/route-header.js +14 -0
  80. package/dist/vite-plugin/route-header.js.map +1 -0
  81. package/dist/vite-plugin/sveltekit-generator.d.ts +7 -0
  82. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  83. package/dist/vite-plugin/sveltekit-generator.js +46 -20
  84. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  85. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
  86. package/dist/vite-plugin/sync-apply-route.js +2 -2
  87. package/dist/vite-plugin/sync-apply-route.js.map +1 -1
  88. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"sync-apply-route.js","names":[],"sources":["../../src/vite-plugin/sync-apply-route.ts"],"sourcesContent":["/**\n * SvelteKit generator for the sync-apply batch write route (#1759).\n *\n * Emits ONE route — `{routesDir}/sync/apply/+server.ts` — implementing the\n * shared web/mobile write contract for every syncable model in the manifest\n * (models whose API config exposes at least one of create/update/delete). The\n * generated file is deliberately thin: it maps `object` segments to\n * collections plus per-model policy data, and delegates all batch processing\n * to `processSyncApplyBatch` from `@happyvertical/smrt-core` — the same\n * engine the runtime REST generator uses, so the two transports cannot drift.\n *\n * This lives in its own module (rather than sveltekit-generator.ts) so the\n * shared generator file only needs a one-line registration hook; sibling\n * work (#1757, #1758) touches the same generator files in parallel.\n *\n * Contract documentation: docs/content/architecture/sync-apply-contract.md\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type { ApiConfig } from '../registry/types.js';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\n\nconst AUTO_GENERATED_ROUTE_HEADER =\n '// Auto-generated by @smrt/core vite plugin';\n\nconst MUTATING_ACTIONS = ['create', 'update', 'delete'] as const;\n\ntype MutatingAction = (typeof MUTATING_ACTIONS)[number];\n\n/** Per-model data embedded into the generated route. */\ninterface SyncTargetSpec {\n segment: string;\n /**\n * The manifest registry key, passed VERBATIM to `getCollection()` exactly\n * as the generated CRUD routes do. May be package-qualified\n * (`@happyvertical/smrt-ledgers:Account`) — collapsing it to a simple class\n * name would resolve ambiguously when two loaded packages declare the same\n * simple name.\n */\n registryKey: string;\n ops: MutatingAction[];\n readonlyFields: string[];\n writableAllowlist: string[] | null;\n publicAccess: boolean | 'read';\n tenantScoped: boolean;\n}\n\nfunction getApiConfigObject(apiConfig: unknown): ApiConfig | null {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return null;\n }\n return apiConfig as ApiConfig;\n}\n\n/**\n * Mirror of sveltekit-generator.ts `resolveStandardCrudActions`, narrowed to\n * the mutating subset the sync contract can carry. Kept local so this module\n * needs no exports added to the (parallel-edited) shared generator file.\n */\nfunction resolveMutatingActions(apiConfig: unknown): MutatingAction[] {\n if (apiConfig === false) return [];\n if (\n apiConfig === true ||\n apiConfig === undefined ||\n typeof apiConfig !== 'object'\n ) {\n return [...MUTATING_ACTIONS];\n }\n\n const config = apiConfig as { include?: string[]; exclude?: string[] };\n let actions: MutatingAction[] = Array.isArray(config.include)\n ? MUTATING_ACTIONS.filter((action) => config.include?.includes(action))\n : [...MUTATING_ACTIONS];\n if (Array.isArray(config.exclude)) {\n const excluded = config.exclude;\n actions = actions.filter((action) => !excluded.includes(action));\n }\n return actions;\n}\n\n/** Mirror of sveltekit-generator.ts `getApiPublicAccess` (fail-closed). */\nfunction getApiPublicAccess(apiConfig: unknown): boolean | 'read' {\n const config = getApiConfigObject(apiConfig);\n const value = config?.public;\n if (value === true || value === 'read') {\n return value;\n }\n return false;\n}\n\n/** Mirror of sveltekit-generator.ts `collectReadonlyFieldNames` (#1540, 2b). */\nfunction collectReadonlyFieldNames(objectDef: SmartObjectDefinition): string[] {\n const fields = objectDef.fields || {};\n const names: string[] = [];\n for (const [name, def] of Object.entries(fields)) {\n const meta = (def as { _meta?: Record<string, unknown> })._meta;\n if (\n (def as { readonly?: boolean }).readonly === true ||\n meta?.readonly === true\n ) {\n names.push(name);\n }\n }\n return names;\n}\n\n/** Mirror of sveltekit-generator.ts `getApiWritableAllowlist`. */\nfunction getApiWritableAllowlist(apiConfig: unknown): string[] | null {\n const config = getApiConfigObject(apiConfig);\n return Array.isArray(config?.writable) ? config.writable : null;\n}\n\n/** Mirror of sveltekit-generator.ts `isCollectionClass`. */\nfunction isCollectionClass(objectDef: SmartObjectDefinition): boolean {\n return objectDef.extends === 'SmrtCollection' || !!objectDef.extendsTypeArg;\n}\n\n/**\n * Collect the syncable targets from a manifest: non-collection objects whose\n * API config is enabled and exposes at least one mutating action.\n */\nexport function collectSyncApplyTargets(\n manifest: SmartObjectManifest,\n): SyncTargetSpec[] {\n const targets: SyncTargetSpec[] = [];\n\n for (const [className, objectDef] of Object.entries(manifest.objects)) {\n if (isCollectionClass(objectDef)) continue;\n\n const apiConfig = objectDef.decoratorConfig?.api;\n if (apiConfig === false) continue;\n\n const ops = resolveMutatingActions(apiConfig);\n if (ops.length === 0) continue;\n\n if (!objectDef.collection) continue;\n\n targets.push({\n segment: objectDef.collection,\n registryKey: className,\n ops,\n readonlyFields: collectReadonlyFieldNames(objectDef),\n writableAllowlist: getApiWritableAllowlist(apiConfig),\n publicAccess: getApiPublicAccess(apiConfig),\n tenantScoped: !!objectDef.decoratorConfig?.tenantScoped,\n });\n }\n\n return targets.sort((a, b) => a.segment.localeCompare(b.segment));\n}\n\nfunction serializeTargets(targets: SyncTargetSpec[]): string {\n const entries = targets.map((target) => {\n const fields = [\n `registryKey: ${JSON.stringify(target.registryKey)}`,\n `ops: ${JSON.stringify(target.ops)}`,\n `readonlyFields: ${JSON.stringify(target.readonlyFields)}`,\n `writableAllowlist: ${JSON.stringify(target.writableAllowlist)}`,\n `publicAccess: ${JSON.stringify(target.publicAccess)}`,\n ];\n return ` ${JSON.stringify(target.segment)}: {\\n ${fields.join(',\\n ')},\\n },`;\n });\n return `{\\n${entries.join('\\n')}\\n}`;\n}\n\n/**\n * Render the generated `+server.ts` content for the sync-apply route.\n * Exported for tests.\n */\nexport function generateSyncApplyRouteTemplate(\n targets: SyncTargetSpec[],\n): string {\n const anyTenantScoped = targets.some((target) => target.tenantScoped);\n\n const tenantHelper = anyTenantScoped\n ? `\nimport { enterTenantContext, hasTenantContext } from '@happyvertical/smrt-tenancy';\n\nfunction establishTenantContext(locals: unknown): void {\n if (hasTenantContext()) return;\n if (!locals || typeof locals !== 'object') return;\n const l = locals as Record<string, unknown>;\n const user = l.user as Record<string, unknown> | undefined;\n const session = l.session as Record<string, unknown> | undefined;\n const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId;\n if (typeof tenantId === 'string' && tenantId) {\n enterTenantContext({ tenantId });\n }\n}\n`\n : '';\n\n return `${AUTO_GENERATED_ROUTE_HEADER}\n// DO NOT EDIT - changes will be overwritten\n//\n// Idempotent sync-apply batch write contract (#1759) — the shared write path\n// for the web offline outbox (#1762) and the KMP mobile write queue (#1739).\n// Contract: docs/content/architecture/sync-apply-contract.md\n\nimport {\n applySyncWritablePolicy,\n processSyncApplyBatch,\n type SyncApplyOp,\n type SyncApplyTarget,\n} from '@happyvertical/smrt-core';\nimport { json } from '@sveltejs/kit';\nimport { getCollection } from '$lib/server/smrt';\nimport type { RequestHandler } from './$types';\n${tenantHelper}\ninterface SyncTargetConfig {\n /** Manifest registry key (may be package-qualified), as CRUD routes use. */\n registryKey: string;\n ops: SyncApplyOp[];\n readonlyFields: string[];\n writableAllowlist: string[] | null;\n publicAccess: boolean | 'read';\n}\n\n// One entry per syncable model, keyed by the collection route segment its\n// generated CRUD routes use.\nconst SYNC_TARGETS: Record<string, SyncTargetConfig> = ${serializeTargets(targets)};\n\n// Fail-closed authorization (#1540): sync mutations require an authenticated\n// principal on \\`locals\\` unless the target model is \\`api: { public: true }\\`.\nfunction hasAuthenticatedPrincipal(locals: unknown): boolean {\n if (!locals || typeof locals !== 'object') return false;\n const l = locals as Record<string, unknown>;\n const isResolvedPrincipal = (v: unknown) =>\n typeof v === 'object' && v !== null;\n return (\n isResolvedPrincipal(l.user) ||\n isResolvedPrincipal(l.session) ||\n l.smrtAuth === true\n );\n}\n\nexport const POST: RequestHandler = async ({ locals, request }) => {\n const authenticated = hasAuthenticatedPrincipal(locals);\n${anyTenantScoped ? ' establishTenantContext(locals);\\n' : ''}\n let body: unknown;\n try {\n body = await request.json();\n } catch {\n return json(\n { error: { code: 'invalid_batch', message: 'Invalid JSON body' } },\n { status: 400 },\n );\n }\n\n const outcome = await processSyncApplyBatch(body, {\n resolveTarget: async (segment: string): Promise<SyncApplyTarget | null> => {\n const target = SYNC_TARGETS[segment];\n if (!target) return null;\n // The verbatim manifest key, exactly as the generated CRUD routes pass\n // it — package-qualified keys stay qualified so same-simple-name\n // classes across packages resolve unambiguously.\n const collection = await getCollection(target.registryKey);\n return {\n objectName: target.registryKey,\n collection,\n isOpAllowed: (op: SyncApplyOp) => target.ops.includes(op),\n authorize: () =>\n authenticated || target.publicAccess === true\n ? ('ok' as const)\n : ('auth_required' as const),\n prepare: (payload: Record<string, unknown>) =>\n applySyncWritablePolicy(payload, {\n readonlyFields: target.readonlyFields,\n writableAllowlist: target.writableAllowlist,\n }),\n };\n },\n });\n\n return json(outcome.body, { status: outcome.status });\n};\n`;\n}\n\n/**\n * Generate `{routesDir}/sync/apply/+server.ts` for the manifest's syncable\n * models. Returns true when a route was written. Skips generation (with a\n * warning) if a non-generated file already occupies the path, and writes\n * nothing when the manifest has no syncable models.\n */\nexport function generateSyncApplyRoute(\n projectRoot: string,\n manifest: SmartObjectManifest,\n options: SvelteKitOptions,\n): boolean {\n const targets = collectSyncApplyTargets(manifest);\n if (targets.length === 0) {\n return false;\n }\n\n const routeDir = join(projectRoot, options.routesDir, 'sync', 'apply');\n const routePath = join(routeDir, '+server.ts');\n\n if (existsSync(routePath)) {\n const existing = readFileSync(routePath, 'utf-8');\n if (!existing.startsWith(AUTO_GENERATED_ROUTE_HEADER)) {\n console.warn(\n `[smrt] Skipping sync-apply route - ${routePath} exists and is not generated`,\n );\n return false;\n }\n }\n\n if (!existsSync(routeDir)) {\n mkdirSync(routeDir, { recursive: true });\n }\n\n writeFileSync(routePath, generateSyncApplyRouteTemplate(targets), 'utf-8');\n console.log(`[smrt] Generated: ${routePath}`);\n return true;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sync-apply-route.js","names":[],"sources":["../../src/vite-plugin/sync-apply-route.ts"],"sourcesContent":["/**\n * SvelteKit generator for the sync-apply batch write route (#1759).\n *\n * Emits ONE route — `{routesDir}/sync/apply/+server.ts` — implementing the\n * shared web/mobile write contract for every syncable model in the manifest\n * (models whose API config exposes at least one of create/update/delete). The\n * generated file is deliberately thin: it maps `object` segments to\n * collections plus per-model policy data, and delegates all batch processing\n * to `processSyncApplyBatch` from `@happyvertical/smrt-core` — the same\n * engine the runtime REST generator uses, so the two transports cannot drift.\n *\n * This lives in its own module (rather than sveltekit-generator.ts) so the\n * shared generator file only needs a one-line registration hook; sibling\n * work (#1757, #1758) touches the same generator files in parallel.\n *\n * Contract documentation: docs/content/architecture/sync-apply-contract.md\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type { ApiConfig } from '../registry/types.js';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types';\nimport { AUTO_GENERATED_ROUTE_HEADER } from './route-header.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\n\nconst MUTATING_ACTIONS = ['create', 'update', 'delete'] as const;\n\ntype MutatingAction = (typeof MUTATING_ACTIONS)[number];\n\n/** Per-model data embedded into the generated route. */\ninterface SyncTargetSpec {\n segment: string;\n /**\n * The manifest registry key, passed VERBATIM to `getCollection()` exactly\n * as the generated CRUD routes do. May be package-qualified\n * (`@happyvertical/smrt-ledgers:Account`) — collapsing it to a simple class\n * name would resolve ambiguously when two loaded packages declare the same\n * simple name.\n */\n registryKey: string;\n ops: MutatingAction[];\n readonlyFields: string[];\n writableAllowlist: string[] | null;\n publicAccess: boolean | 'read';\n tenantScoped: boolean;\n}\n\nfunction getApiConfigObject(apiConfig: unknown): ApiConfig | null {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return null;\n }\n return apiConfig as ApiConfig;\n}\n\n/**\n * Mirror of sveltekit-generator.ts `resolveStandardCrudActions`, narrowed to\n * the mutating subset the sync contract can carry. Kept local so this module\n * needs no exports added to the (parallel-edited) shared generator file.\n */\nfunction resolveMutatingActions(apiConfig: unknown): MutatingAction[] {\n if (apiConfig === false) return [];\n if (\n apiConfig === true ||\n apiConfig === undefined ||\n typeof apiConfig !== 'object'\n ) {\n return [...MUTATING_ACTIONS];\n }\n\n const config = apiConfig as { include?: string[]; exclude?: string[] };\n let actions: MutatingAction[] = Array.isArray(config.include)\n ? MUTATING_ACTIONS.filter((action) => config.include?.includes(action))\n : [...MUTATING_ACTIONS];\n if (Array.isArray(config.exclude)) {\n const excluded = config.exclude;\n actions = actions.filter((action) => !excluded.includes(action));\n }\n return actions;\n}\n\n/** Mirror of sveltekit-generator.ts `getApiPublicAccess` (fail-closed). */\nfunction getApiPublicAccess(apiConfig: unknown): boolean | 'read' {\n const config = getApiConfigObject(apiConfig);\n const value = config?.public;\n if (value === true || value === 'read') {\n return value;\n }\n return false;\n}\n\n/** Mirror of sveltekit-generator.ts `collectReadonlyFieldNames` (#1540, 2b). */\nfunction collectReadonlyFieldNames(objectDef: SmartObjectDefinition): string[] {\n const fields = objectDef.fields || {};\n const names: string[] = [];\n for (const [name, def] of Object.entries(fields)) {\n const meta = (def as { _meta?: Record<string, unknown> })._meta;\n if (\n (def as { readonly?: boolean }).readonly === true ||\n meta?.readonly === true\n ) {\n names.push(name);\n }\n }\n return names;\n}\n\n/** Mirror of sveltekit-generator.ts `getApiWritableAllowlist`. */\nfunction getApiWritableAllowlist(apiConfig: unknown): string[] | null {\n const config = getApiConfigObject(apiConfig);\n return Array.isArray(config?.writable) ? config.writable : null;\n}\n\n/** Mirror of sveltekit-generator.ts `isCollectionClass`. */\nfunction isCollectionClass(objectDef: SmartObjectDefinition): boolean {\n return objectDef.extends === 'SmrtCollection' || !!objectDef.extendsTypeArg;\n}\n\n/**\n * Collect the syncable targets from a manifest: non-collection objects whose\n * API config is enabled and exposes at least one mutating action.\n */\nexport function collectSyncApplyTargets(\n manifest: SmartObjectManifest,\n): SyncTargetSpec[] {\n const targets: SyncTargetSpec[] = [];\n\n for (const [className, objectDef] of Object.entries(manifest.objects)) {\n if (isCollectionClass(objectDef)) continue;\n\n const apiConfig = objectDef.decoratorConfig?.api;\n if (apiConfig === false) continue;\n\n const ops = resolveMutatingActions(apiConfig);\n if (ops.length === 0) continue;\n\n if (!objectDef.collection) continue;\n\n targets.push({\n segment: objectDef.collection,\n registryKey: className,\n ops,\n readonlyFields: collectReadonlyFieldNames(objectDef),\n writableAllowlist: getApiWritableAllowlist(apiConfig),\n publicAccess: getApiPublicAccess(apiConfig),\n tenantScoped: !!objectDef.decoratorConfig?.tenantScoped,\n });\n }\n\n return targets.sort((a, b) => a.segment.localeCompare(b.segment));\n}\n\nfunction serializeTargets(targets: SyncTargetSpec[]): string {\n const entries = targets.map((target) => {\n const fields = [\n `registryKey: ${JSON.stringify(target.registryKey)}`,\n `ops: ${JSON.stringify(target.ops)}`,\n `readonlyFields: ${JSON.stringify(target.readonlyFields)}`,\n `writableAllowlist: ${JSON.stringify(target.writableAllowlist)}`,\n `publicAccess: ${JSON.stringify(target.publicAccess)}`,\n ];\n return ` ${JSON.stringify(target.segment)}: {\\n ${fields.join(',\\n ')},\\n },`;\n });\n return `{\\n${entries.join('\\n')}\\n}`;\n}\n\n/**\n * Render the generated `+server.ts` content for the sync-apply route.\n * Exported for tests.\n */\nexport function generateSyncApplyRouteTemplate(\n targets: SyncTargetSpec[],\n): string {\n const anyTenantScoped = targets.some((target) => target.tenantScoped);\n\n const tenantHelper = anyTenantScoped\n ? `\nimport { enterTenantContext, hasTenantContext } from '@happyvertical/smrt-tenancy';\n\nfunction establishTenantContext(locals: unknown): void {\n if (hasTenantContext()) return;\n if (!locals || typeof locals !== 'object') return;\n const l = locals as Record<string, unknown>;\n const user = l.user as Record<string, unknown> | undefined;\n const session = l.session as Record<string, unknown> | undefined;\n const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId;\n if (typeof tenantId === 'string' && tenantId) {\n enterTenantContext({ tenantId });\n }\n}\n`\n : '';\n\n return `${AUTO_GENERATED_ROUTE_HEADER}\n// DO NOT EDIT - changes will be overwritten\n//\n// Idempotent sync-apply batch write contract (#1759) — the shared write path\n// for the web offline outbox (#1762) and the KMP mobile write queue (#1739).\n// Contract: docs/content/architecture/sync-apply-contract.md\n\nimport {\n applySyncWritablePolicy,\n processSyncApplyBatch,\n type SyncApplyOp,\n type SyncApplyTarget,\n} from '@happyvertical/smrt-core';\nimport { json } from '@sveltejs/kit';\nimport { getCollection } from '$lib/server/smrt';\nimport type { RequestHandler } from './$types';\n${tenantHelper}\ninterface SyncTargetConfig {\n /** Manifest registry key (may be package-qualified), as CRUD routes use. */\n registryKey: string;\n ops: SyncApplyOp[];\n readonlyFields: string[];\n writableAllowlist: string[] | null;\n publicAccess: boolean | 'read';\n}\n\n// One entry per syncable model, keyed by the collection route segment its\n// generated CRUD routes use.\nconst SYNC_TARGETS: Record<string, SyncTargetConfig> = ${serializeTargets(targets)};\n\n// Fail-closed authorization (#1540): sync mutations require an authenticated\n// principal on \\`locals\\` unless the target model is \\`api: { public: true }\\`.\nfunction hasAuthenticatedPrincipal(locals: unknown): boolean {\n if (!locals || typeof locals !== 'object') return false;\n const l = locals as Record<string, unknown>;\n const isResolvedPrincipal = (v: unknown) =>\n typeof v === 'object' && v !== null;\n return (\n isResolvedPrincipal(l.user) ||\n isResolvedPrincipal(l.session) ||\n l.smrtAuth === true\n );\n}\n\nexport const POST: RequestHandler = async ({ locals, request }) => {\n const authenticated = hasAuthenticatedPrincipal(locals);\n${anyTenantScoped ? ' establishTenantContext(locals);\\n' : ''}\n let body: unknown;\n try {\n body = await request.json();\n } catch {\n return json(\n { error: { code: 'invalid_batch', message: 'Invalid JSON body' } },\n { status: 400 },\n );\n }\n\n const outcome = await processSyncApplyBatch(body, {\n resolveTarget: async (segment: string): Promise<SyncApplyTarget | null> => {\n const target = SYNC_TARGETS[segment];\n if (!target) return null;\n // The verbatim manifest key, exactly as the generated CRUD routes pass\n // it — package-qualified keys stay qualified so same-simple-name\n // classes across packages resolve unambiguously.\n const collection = await getCollection(target.registryKey);\n return {\n objectName: target.registryKey,\n collection,\n isOpAllowed: (op: SyncApplyOp) => target.ops.includes(op),\n authorize: () =>\n authenticated || target.publicAccess === true\n ? ('ok' as const)\n : ('auth_required' as const),\n prepare: (payload: Record<string, unknown>) =>\n applySyncWritablePolicy(payload, {\n readonlyFields: target.readonlyFields,\n writableAllowlist: target.writableAllowlist,\n }),\n };\n },\n });\n\n return json(outcome.body, { status: outcome.status });\n};\n`;\n}\n\n/**\n * Generate `{routesDir}/sync/apply/+server.ts` for the manifest's syncable\n * models. Returns true when a route was written. Skips generation (with a\n * warning) if a non-generated file already occupies the path, and writes\n * nothing when the manifest has no syncable models.\n */\nexport function generateSyncApplyRoute(\n projectRoot: string,\n manifest: SmartObjectManifest,\n options: SvelteKitOptions,\n): boolean {\n const targets = collectSyncApplyTargets(manifest);\n if (targets.length === 0) {\n return false;\n }\n\n const routeDir = join(projectRoot, options.routesDir, 'sync', 'apply');\n const routePath = join(routeDir, '+server.ts');\n\n if (existsSync(routePath)) {\n const existing = readFileSync(routePath, 'utf-8');\n if (!existing.startsWith(AUTO_GENERATED_ROUTE_HEADER)) {\n console.warn(\n `[smrt] Skipping sync-apply route - ${routePath} exists and is not generated`,\n );\n return false;\n }\n }\n\n if (!existsSync(routeDir)) {\n mkdirSync(routeDir, { recursive: true });\n }\n\n writeFileSync(routePath, generateSyncApplyRouteTemplate(targets), 'utf-8');\n console.log(`[smrt] Generated: ${routePath}`);\n return true;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-core",
3
- "version": "0.37.7",
3
+ "version": "0.37.9",
4
4
  "description": "Core AI agent framework with standardized collections, object-relational mapping, and code generators",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -154,9 +154,9 @@
154
154
  "tsx": "^4.22.4",
155
155
  "typescript": "^5.9.3",
156
156
  "yaml": "^2.9.0",
157
- "@happyvertical/smrt-config": "0.37.7",
158
- "@happyvertical/smrt-scanner": "0.37.7",
159
- "@happyvertical/smrt-types": "0.37.7"
157
+ "@happyvertical/smrt-config": "0.37.9",
158
+ "@happyvertical/smrt-types": "0.37.9",
159
+ "@happyvertical/smrt-scanner": "0.37.9"
160
160
  },
161
161
  "peerDependencies": {
162
162
  "@huggingface/transformers": ">=3.0.0 <4.0.0",