@happyvertical/smrt-core 0.57.46 → 0.57.48

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 (189) hide show
  1. package/AGENTS.md +11 -3
  2. package/agents/browser-entry.md +16 -0
  3. package/agents/build-knowledge.md +58 -14
  4. package/agents/change-feed.md +20 -0
  5. package/agents/generators.md +69 -3
  6. package/agents/schema-paths.md +82 -0
  7. package/dist/_virtual/_rolldown/runtime.js +22 -1
  8. package/dist/browser.d.ts +2 -2
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +2 -2
  11. package/dist/cascade.d.ts +46 -0
  12. package/dist/cascade.d.ts.map +1 -1
  13. package/dist/cascade.js +96 -7
  14. package/dist/cascade.js.map +1 -1
  15. package/dist/change-feed.d.ts.map +1 -1
  16. package/dist/change-feed.js +7 -4
  17. package/dist/change-feed.js.map +1 -1
  18. package/dist/class.d.ts.map +1 -1
  19. package/dist/class.js +3 -3
  20. package/dist/class.js.map +1 -1
  21. package/dist/consumer-plugin/index.d.ts +13 -0
  22. package/dist/consumer-plugin/index.d.ts.map +1 -1
  23. package/dist/consumer-plugin/index.js +69 -13
  24. package/dist/consumer-plugin/index.js.map +1 -1
  25. package/dist/consumer-plugin.js +2 -2
  26. package/dist/database.d.ts.map +1 -1
  27. package/dist/database.js +3 -3
  28. package/dist/database.js.map +1 -1
  29. package/dist/dispatch/bus.d.ts.map +1 -1
  30. package/dist/dispatch/bus.js +1 -4
  31. package/dist/dispatch/bus.js.map +1 -1
  32. package/dist/dispatch/index.d.ts +1 -1
  33. package/dist/dispatch/index.d.ts.map +1 -1
  34. package/dist/dispatch/types.d.ts +22 -3
  35. package/dist/dispatch/types.d.ts.map +1 -1
  36. package/dist/generators/changes-route.d.ts.map +1 -1
  37. package/dist/generators/changes-route.js +2 -2
  38. package/dist/generators/changes-route.js.map +1 -1
  39. package/dist/generators/custom-action.d.ts +11 -0
  40. package/dist/generators/custom-action.d.ts.map +1 -1
  41. package/dist/generators/custom-action.js +23 -4
  42. package/dist/generators/custom-action.js.map +1 -1
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3 -2
  46. package/dist/lazy-config.d.ts +120 -3
  47. package/dist/lazy-config.d.ts.map +1 -1
  48. package/dist/lazy-config.js +81 -10
  49. package/dist/lazy-config.js.map +1 -1
  50. package/dist/manifest/generator.d.ts +8 -2
  51. package/dist/manifest/generator.d.ts.map +1 -1
  52. package/dist/manifest/generator.js +6 -5
  53. package/dist/manifest/generator.js.map +1 -1
  54. package/dist/manifest/index.d.ts +1 -0
  55. package/dist/manifest/index.d.ts.map +1 -1
  56. package/dist/manifest/index.js +2 -1
  57. package/dist/manifest/index.js.map +1 -1
  58. package/dist/manifest/local-manifest-reader.d.ts +13 -2
  59. package/dist/manifest/local-manifest-reader.d.ts.map +1 -1
  60. package/dist/manifest/local-manifest-reader.js +38 -6
  61. package/dist/manifest/local-manifest-reader.js.map +1 -1
  62. package/dist/manifest/manifest-loader.d.ts +9 -8
  63. package/dist/manifest/manifest-loader.d.ts.map +1 -1
  64. package/dist/manifest/manifest-loader.js +67 -27
  65. package/dist/manifest/manifest-loader.js.map +1 -1
  66. package/dist/manifest/object-declarations.d.ts +116 -0
  67. package/dist/manifest/object-declarations.d.ts.map +1 -0
  68. package/dist/manifest/object-declarations.js +161 -0
  69. package/dist/manifest/object-declarations.js.map +1 -0
  70. package/dist/manifest/static-manifest.d.ts.map +1 -1
  71. package/dist/manifest/static-manifest.js +5 -3
  72. package/dist/manifest/static-manifest.js.map +1 -1
  73. package/dist/manifest/store.d.ts.map +1 -1
  74. package/dist/manifest/store.js +18 -2
  75. package/dist/manifest/store.js.map +1 -1
  76. package/dist/manifest/test-artifacts.d.ts +57 -0
  77. package/dist/manifest/test-artifacts.d.ts.map +1 -0
  78. package/dist/manifest/test-artifacts.js +111 -0
  79. package/dist/manifest/test-artifacts.js.map +1 -0
  80. package/dist/manifest.js +2 -1
  81. package/dist/manifest.json +5 -3
  82. package/dist/migrations/differ.d.ts.map +1 -1
  83. package/dist/migrations/differ.js +11 -3
  84. package/dist/migrations/differ.js.map +1 -1
  85. package/dist/migrations/index.d.ts +2 -0
  86. package/dist/migrations/index.d.ts.map +1 -1
  87. package/dist/migrations/index.js +3 -1
  88. package/dist/migrations/qualified-name-aliases.d.ts.map +1 -1
  89. package/dist/migrations/qualified-name-aliases.js +1 -1
  90. package/dist/migrations/qualified-name-aliases.js.map +1 -1
  91. package/dist/migrations.js +3 -1
  92. package/dist/object.d.ts.map +1 -1
  93. package/dist/object.js +2 -2
  94. package/dist/object.js.map +1 -1
  95. package/dist/postgres-permissions.d.ts.map +1 -1
  96. package/dist/postgres-permissions.js +4 -3
  97. package/dist/postgres-permissions.js.map +1 -1
  98. package/dist/postgres-timeouts.d.ts +30 -3
  99. package/dist/postgres-timeouts.d.ts.map +1 -1
  100. package/dist/postgres-timeouts.js +39 -2
  101. package/dist/postgres-timeouts.js.map +1 -1
  102. package/dist/prebuild/index.d.ts.map +1 -1
  103. package/dist/prebuild/index.js +14 -11
  104. package/dist/prebuild/index.js.map +1 -1
  105. package/dist/registry/class-registration.d.ts.map +1 -1
  106. package/dist/registry/class-registration.js +24 -2
  107. package/dist/registry/class-registration.js.map +1 -1
  108. package/dist/registry/collection-exposure.d.ts +27 -0
  109. package/dist/registry/collection-exposure.d.ts.map +1 -1
  110. package/dist/registry/collection-exposure.js +74 -1
  111. package/dist/registry/collection-exposure.js.map +1 -1
  112. package/dist/registry/collection-resolution.d.ts.map +1 -1
  113. package/dist/registry/collection-resolution.js +1 -0
  114. package/dist/registry/collection-resolution.js.map +1 -1
  115. package/dist/registry/framework-base-classes.d.ts.map +1 -1
  116. package/dist/registry/framework-base-classes.js +5 -0
  117. package/dist/registry/framework-base-classes.js.map +1 -1
  118. package/dist/registry/schema-builder.d.ts +8 -1
  119. package/dist/registry/schema-builder.d.ts.map +1 -1
  120. package/dist/registry/schema-builder.js +17 -4
  121. package/dist/registry/schema-builder.js.map +1 -1
  122. package/dist/registry/types.d.ts +12 -0
  123. package/dist/registry/types.d.ts.map +1 -1
  124. package/dist/registry.d.ts +2 -1
  125. package/dist/registry.d.ts.map +1 -1
  126. package/dist/registry.js +30 -10
  127. package/dist/registry.js.map +1 -1
  128. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  129. package/dist/scanner/manifest-generator.js +10 -8
  130. package/dist/scanner/manifest-generator.js.map +1 -1
  131. package/dist/scanner/types.d.ts +29 -0
  132. package/dist/scanner/types.d.ts.map +1 -1
  133. package/dist/scanner/types.js.map +1 -1
  134. package/dist/schema/generator.d.ts +32 -0
  135. package/dist/schema/generator.d.ts.map +1 -1
  136. package/dist/schema/generator.js +59 -0
  137. package/dist/schema/generator.js.map +1 -1
  138. package/dist/schema/live-parity.d.ts.map +1 -1
  139. package/dist/schema/live-parity.js +4 -2
  140. package/dist/schema/live-parity.js.map +1 -1
  141. package/dist/scripts/browser-unbundled-dev.mjs +90 -0
  142. package/dist/scripts/generate-test-manifest.js +9 -49
  143. package/dist/smrt-knowledge.json +16 -16
  144. package/dist/system/compatibility.d.ts.map +1 -1
  145. package/dist/system/compatibility.js +12 -4
  146. package/dist/system/compatibility.js.map +1 -1
  147. package/dist/system/schema.d.ts +70 -8
  148. package/dist/system/schema.d.ts.map +1 -1
  149. package/dist/system/schema.js +231 -24
  150. package/dist/system/schema.js.map +1 -1
  151. package/dist/testing/database.d.ts.map +1 -1
  152. package/dist/testing/database.js +14 -2
  153. package/dist/testing/database.js.map +1 -1
  154. package/dist/tools/tool-generator.d.ts.map +1 -1
  155. package/dist/tools/tool-generator.js +2 -2
  156. package/dist/tools/tool-generator.js.map +1 -1
  157. package/dist/utils/import-workspace-module.d.ts.map +1 -1
  158. package/dist/utils/import-workspace-module.js +23 -0
  159. package/dist/utils/import-workspace-module.js.map +1 -1
  160. package/dist/utils/naming.js +5 -3
  161. package/dist/utils/naming.js.map +1 -1
  162. package/dist/vendor/pluralize/pluralize.js +421 -0
  163. package/dist/vendor/pluralize/pluralize.js.map +1 -0
  164. package/dist/vite-plugin/api-client-entries.d.ts +5 -1
  165. package/dist/vite-plugin/api-client-entries.d.ts.map +1 -1
  166. package/dist/vite-plugin/api-client-entries.js +11 -6
  167. package/dist/vite-plugin/api-client-entries.js.map +1 -1
  168. package/dist/vite-plugin/consumer-import.d.ts +28 -0
  169. package/dist/vite-plugin/consumer-import.d.ts.map +1 -0
  170. package/dist/vite-plugin/consumer-import.js +42 -0
  171. package/dist/vite-plugin/consumer-import.js.map +1 -0
  172. package/dist/vite-plugin/dev-plane-route.d.ts +6 -1
  173. package/dist/vite-plugin/dev-plane-route.d.ts.map +1 -1
  174. package/dist/vite-plugin/dev-plane-route.js +8 -9
  175. package/dist/vite-plugin/dev-plane-route.js.map +1 -1
  176. package/dist/vite-plugin/index.d.ts.map +1 -1
  177. package/dist/vite-plugin/index.js +12 -9
  178. package/dist/vite-plugin/index.js.map +1 -1
  179. package/dist/vite-plugin/resources-route.d.ts.map +1 -1
  180. package/dist/vite-plugin/resources-route.js +3 -8
  181. package/dist/vite-plugin/resources-route.js.map +1 -1
  182. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  183. package/dist/vite-plugin/sveltekit-generator.js +7 -11
  184. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  185. package/dist/vite-plugin/sync-apply-route.d.ts +16 -0
  186. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
  187. package/dist/vite-plugin/sync-apply-route.js +48 -6
  188. package/dist/vite-plugin/sync-apply-route.js.map +1 -1
  189. package/package.json +12 -12
@@ -46,6 +46,22 @@ interface SyncTargetSpec {
46
46
  /**
47
47
  * Collect the syncable targets from a manifest: non-collection objects whose
48
48
  * API config is enabled and exposes at least one mutating action.
49
+ *
50
+ * `SYNC_TARGETS` is keyed by segment, so exactly ONE model owns each segment
51
+ * (#2807). Several models can share a `collection` (an STI family, or two
52
+ * unrelated models); emitting one entry per model made the generated object
53
+ * literal's last duplicate key win, i.e. manifest scan order. The owner uses
54
+ * the tie rule of the web collection definitions (`selectWebCollectionEntries`)
55
+ * over models that expose a mutating op: the STI base model beats its
56
+ * children, and otherwise the first candidate in deterministic
57
+ * qualified-identity order ({@link orderedManifestObjectEntries}) wins.
58
+ *
59
+ * An STI child never owns a segment whose API-enabled base shares it, even
60
+ * when that base exposes no mutating op: the base's (narrower) policy decides,
61
+ * so the segment then has no sync target (fail closed). Candidate sets differ
62
+ * from the web selector's (`list`) and from CRUD route emission (last writer
63
+ * per route file, #2754), so with unrelated models sharing a segment the sync
64
+ * owner can differ from those owners; it is deterministic either way.
49
65
  */
50
66
  export declare function collectSyncApplyTargets(manifest: SmartObjectManifest): SyncTargetSpec[];
51
67
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"sync-apply-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sync-apply-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAEV,mBAAmB,EACpB,MAAM,qBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAGjE,QAAA,MAAM,gBAAgB,yCAA0C,CAAC;AAEjE,KAAK,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAExD,wDAAwD;AACxD,UAAU,cAAc;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,cAAc,EAAE,CAAC;IACtB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,iBAAiB,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACnC,YAAY,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,YAAY,EAAE,OAAO,CAAC;CACvB;AA8ED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,mBAAmB,GAC5B,cAAc,EAAE,CAiClB;AAiBD;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,cAAc,EAAE,EACzB,uBAAuB,EAAE,MAAM,GAC9B,MAAM,CA4JR;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAmCT"}
1
+ {"version":3,"file":"sync-apply-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sync-apply-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAEV,mBAAmB,EACpB,MAAM,qBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AASjE,QAAA,MAAM,gBAAgB,yCAA0C,CAAC;AAEjE,KAAK,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAExD,wDAAwD;AACxD,UAAU,cAAc;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,cAAc,EAAE,CAAC;IACtB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,iBAAiB,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACnC,YAAY,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,YAAY,EAAE,OAAO,CAAC;CACvB;AAyGD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,mBAAmB,GAC5B,cAAc,EAAE,CA4ClB;AAiBD;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,cAAc,EAAE,EACzB,uBAAuB,EAAE,MAAM,GAC9B,MAAM,CA4JR;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAmCT"}
@@ -1,7 +1,7 @@
1
1
  import { isFrameworkBaseClass } from "../registry/framework-base-classes.js";
2
2
  import { AUTO_GENERATED_ROUTE_HEADER } from "./route-header.js";
3
3
  import { generateCollectionAccessImports } from "./sveltekit-config-import.js";
4
- import { isCollectionManifestClass } from "./web-collections.js";
4
+ import { compareText, findManifestObjectByName, isCollectionManifestClass, isStiChildModel, orderedManifestObjectEntries } from "./web-collections.js";
5
5
  import { join } from "node:path";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  //#region src/vite-plugin/sync-apply-route.ts
@@ -75,20 +75,58 @@ function resolvePermissionCollection(objectDef) {
75
75
  return typeof configured === "string" && configured.length > 0 ? configured : objectDef.collection;
76
76
  }
77
77
  /**
78
+ * True when an ancestor sharing `objectDef`'s collection (its STI base) is in
79
+ * the manifest with its API enabled. Such a base owns the segment.
80
+ */
81
+ function hasApiEnabledStiBase(manifest, objectDef) {
82
+ const seen = /* @__PURE__ */ new Set();
83
+ let child = objectDef;
84
+ let parentName = child.extendsQualified || child.extends;
85
+ while (parentName && !seen.has(parentName)) {
86
+ seen.add(parentName);
87
+ const parent = findManifestObjectByName(manifest, parentName, child);
88
+ if (!parent || parent.collection !== objectDef.collection) return false;
89
+ if (parent.decoratorConfig?.api !== false && !isFrameworkBaseClass(parent.className, parent.packageName)) return true;
90
+ child = parent;
91
+ parentName = parent.extendsQualified || parent.extends;
92
+ }
93
+ return false;
94
+ }
95
+ /**
78
96
  * Collect the syncable targets from a manifest: non-collection objects whose
79
97
  * API config is enabled and exposes at least one mutating action.
98
+ *
99
+ * `SYNC_TARGETS` is keyed by segment, so exactly ONE model owns each segment
100
+ * (#2807). Several models can share a `collection` (an STI family, or two
101
+ * unrelated models); emitting one entry per model made the generated object
102
+ * literal's last duplicate key win, i.e. manifest scan order. The owner uses
103
+ * the tie rule of the web collection definitions (`selectWebCollectionEntries`)
104
+ * over models that expose a mutating op: the STI base model beats its
105
+ * children, and otherwise the first candidate in deterministic
106
+ * qualified-identity order ({@link orderedManifestObjectEntries}) wins.
107
+ *
108
+ * An STI child never owns a segment whose API-enabled base shares it, even
109
+ * when that base exposes no mutating op: the base's (narrower) policy decides,
110
+ * so the segment then has no sync target (fail closed). Candidate sets differ
111
+ * from the web selector's (`list`) and from CRUD route emission (last writer
112
+ * per route file, #2754), so with unrelated models sharing a segment the sync
113
+ * owner can differ from those owners; it is deterministic either way.
80
114
  */
81
115
  function collectSyncApplyTargets(manifest) {
82
- const targets = [];
83
- for (const [className, objectDef] of Object.entries(manifest.objects)) {
116
+ const bySegment = /* @__PURE__ */ new Map();
117
+ for (const [className, objectDef] of orderedManifestObjectEntries(manifest)) {
84
118
  if (isCollectionManifestClass(manifest, objectDef)) continue;
85
119
  if (isFrameworkBaseClass(objectDef.className, objectDef.packageName)) continue;
86
120
  const apiConfig = objectDef.decoratorConfig?.api;
87
121
  if (apiConfig === false) continue;
122
+ if (!objectDef.collection) continue;
123
+ if (hasApiEnabledStiBase(manifest, objectDef)) continue;
88
124
  const ops = resolveMutatingActions(apiConfig);
89
125
  if (ops.length === 0) continue;
90
- if (!objectDef.collection) continue;
91
- targets.push({
126
+ const isStiChild = isStiChildModel(manifest, objectDef);
127
+ const existing = bySegment.get(objectDef.collection);
128
+ if (existing && !(existing.isStiChild && !isStiChild)) continue;
129
+ const target = {
92
130
  segment: objectDef.collection,
93
131
  registryKey: className,
94
132
  permissionCollection: resolvePermissionCollection(objectDef),
@@ -97,9 +135,13 @@ function collectSyncApplyTargets(manifest) {
97
135
  writableAllowlist: getApiWritableAllowlist(apiConfig),
98
136
  publicAccess: getApiPublicAccess(apiConfig),
99
137
  tenantScoped: !!objectDef.decoratorConfig?.tenantScoped
138
+ };
139
+ bySegment.set(objectDef.collection, {
140
+ target,
141
+ isStiChild
100
142
  });
101
143
  }
102
- return targets.sort((a, b) => a.segment.localeCompare(b.segment));
144
+ return [...bySegment.values()].map(({ target }) => target).sort((a, b) => compareText(a.segment, b.segment));
103
145
  }
104
146
  function serializeTargets(targets) {
105
147
  return `{\n${targets.map((target) => {
@@ -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 { isFrameworkBaseClass } from '../registry/framework-base-classes.js';\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 { generateCollectionAccessImports } from './sveltekit-config-import.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\nimport { isCollectionManifestClass } from './web-collections.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 /**\n * `<collection>` prefix of the `<collection>.<action>` operation permission\n * the mirrored generated write routes require (#2977) — the same\n * `decoratorConfig.collection ?? collection` resolution as\n * sveltekit-generator.ts `resolvePermissionCollection`.\n */\n permissionCollection: 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 `resolvePermissionCollection` (#2977). */\nfunction resolvePermissionCollection(objectDef: SmartObjectDefinition): string {\n if (objectDef.permissionCollection) return objectDef.permissionCollection;\n const configured = (\n objectDef.decoratorConfig as { collection?: unknown } | undefined\n )?.collection;\n return typeof configured === 'string' && configured.length > 0\n ? configured\n : objectDef.collection;\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 (isCollectionManifestClass(manifest, objectDef)) continue;\n // The framework's own abstract base classes are scaffolding, not\n // resources — never make them a sync-apply target, regardless of\n // config (#2642).\n if (isFrameworkBaseClass(objectDef.className, objectDef.packageName)) {\n continue;\n }\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 permissionCollection: resolvePermissionCollection(objectDef),\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 `permissionCollection: ${JSON.stringify(target.permissionCollection)}`,\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 collectionAccessImports: string,\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 type { RequestHandler } from './$types';\n${collectionAccessImports}\n${tenantHelper}\ninterface SyncTargetConfig {\n /** Manifest registry key (may be package-qualified), as CRUD routes use. */\n registryKey: string;\n /** \\`<collection>\\` prefix of the required \\`<collection>.<op>\\` permission. */\n permissionCollection: 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\nfunction readRecord(value: unknown): Record<string, unknown> {\n return value && typeof value === 'object'\n ? (value as Record<string, unknown>)\n : {};\n}\n\nfunction hasGrantedPermission(value: unknown, permission: string): boolean {\n if (Array.isArray(value)) return value.includes(permission);\n if (value instanceof Set) return value.has(permission);\n return false;\n}\n\n// Fail-closed operation permission (#2977, #3011): each applied op requires\n// the same \\`<collection>.<op>\\` permission the mirrored generated write route\n// enforces, read from the principal's session permission snapshot. A missing\n// snapshot denies; only an explicit tenancy super-admin bypass skips it.\nfunction hasOperationPermission(\n locals: unknown,\n permissionCollection: string,\n op: SyncApplyOp,\n): boolean {\n const l = readRecord(locals);\n const tenantContext = readRecord(l.tenantContext);\n if (tenantContext.superAdminBypass === true) return true;\n const permission = \\`\\${permissionCollection}.\\${op}\\`;\n return (\n hasGrantedPermission(l.permissions, permission) ||\n hasGrantedPermission(l.permissionSet, permission) ||\n hasGrantedPermission(l.smrtPermissions, permission) ||\n hasGrantedPermission(tenantContext.permissions, permission)\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 mutate: async (row, op, data) => {\n await collection.withAuditMutation(row as import('@happyvertical/smrt-core').SmrtObject, op === 'update' ? 'updated' : 'deleted', async (bound) => {\n if (op === 'update') {\n Object.assign(bound, data);\n await bound.save();\n } else {\n await bound.delete({ expectedUpdatedAt: bound.updated_at ?? undefined });\n }\n });\n },\n isOpAllowed: (op: SyncApplyOp) => target.ops.includes(op),\n authorize: (op: SyncApplyOp) => {\n // \\`public: true\\` keeps its unauthenticated write semantics, as on\n // the generated CRUD routes.\n if (target.publicAccess === true) return 'ok' as const;\n if (!authenticated) return 'auth_required' as const;\n return hasOperationPermission(locals, target.permissionCollection, op)\n ? ('ok' as const)\n : ('forbidden' as const);\n },\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(\n routePath,\n generateSyncApplyRouteTemplate(\n targets,\n generateCollectionAccessImports(projectRoot, routeDir, options, [\n 'getCollection',\n ]),\n ),\n 'utf-8',\n );\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 { isFrameworkBaseClass } from '../registry/framework-base-classes.js';\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 { generateCollectionAccessImports } from './sveltekit-config-import.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\nimport {\n compareText,\n findManifestObjectByName,\n isCollectionManifestClass,\n isStiChildModel,\n orderedManifestObjectEntries,\n} from './web-collections.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 /**\n * `<collection>` prefix of the `<collection>.<action>` operation permission\n * the mirrored generated write routes require (#2977) — the same\n * `decoratorConfig.collection ?? collection` resolution as\n * sveltekit-generator.ts `resolvePermissionCollection`.\n */\n permissionCollection: 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 `resolvePermissionCollection` (#2977). */\nfunction resolvePermissionCollection(objectDef: SmartObjectDefinition): string {\n if (objectDef.permissionCollection) return objectDef.permissionCollection;\n const configured = (\n objectDef.decoratorConfig as { collection?: unknown } | undefined\n )?.collection;\n return typeof configured === 'string' && configured.length > 0\n ? configured\n : objectDef.collection;\n}\n\n/**\n * True when an ancestor sharing `objectDef`'s collection (its STI base) is in\n * the manifest with its API enabled. Such a base owns the segment.\n */\nfunction hasApiEnabledStiBase(\n manifest: SmartObjectManifest,\n objectDef: SmartObjectDefinition,\n): boolean {\n const seen = new Set<string>();\n let child = objectDef;\n let parentName = child.extendsQualified || child.extends;\n while (parentName && !seen.has(parentName)) {\n seen.add(parentName);\n const parent = findManifestObjectByName(manifest, parentName, child);\n if (!parent || parent.collection !== objectDef.collection) return false;\n if (\n parent.decoratorConfig?.api !== false &&\n !isFrameworkBaseClass(parent.className, parent.packageName)\n ) {\n return true;\n }\n child = parent;\n parentName = parent.extendsQualified || parent.extends;\n }\n return false;\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 *\n * `SYNC_TARGETS` is keyed by segment, so exactly ONE model owns each segment\n * (#2807). Several models can share a `collection` (an STI family, or two\n * unrelated models); emitting one entry per model made the generated object\n * literal's last duplicate key win, i.e. manifest scan order. The owner uses\n * the tie rule of the web collection definitions (`selectWebCollectionEntries`)\n * over models that expose a mutating op: the STI base model beats its\n * children, and otherwise the first candidate in deterministic\n * qualified-identity order ({@link orderedManifestObjectEntries}) wins.\n *\n * An STI child never owns a segment whose API-enabled base shares it, even\n * when that base exposes no mutating op: the base's (narrower) policy decides,\n * so the segment then has no sync target (fail closed). Candidate sets differ\n * from the web selector's (`list`) and from CRUD route emission (last writer\n * per route file, #2754), so with unrelated models sharing a segment the sync\n * owner can differ from those owners; it is deterministic either way.\n */\nexport function collectSyncApplyTargets(\n manifest: SmartObjectManifest,\n): SyncTargetSpec[] {\n const bySegment = new Map<\n string,\n { target: SyncTargetSpec; isStiChild: boolean }\n >();\n\n for (const [className, objectDef] of orderedManifestObjectEntries(manifest)) {\n if (isCollectionManifestClass(manifest, objectDef)) continue;\n // The framework's own abstract base classes are scaffolding, not\n // resources — never make them a sync-apply target, regardless of\n // config (#2642).\n if (isFrameworkBaseClass(objectDef.className, objectDef.packageName)) {\n continue;\n }\n\n const apiConfig = objectDef.decoratorConfig?.api;\n if (apiConfig === false) continue;\n\n if (!objectDef.collection) continue;\n if (hasApiEnabledStiBase(manifest, objectDef)) continue;\n\n const ops = resolveMutatingActions(apiConfig);\n if (ops.length === 0) continue;\n\n const isStiChild = isStiChildModel(manifest, objectDef);\n const existing = bySegment.get(objectDef.collection);\n if (existing && !(existing.isStiChild && !isStiChild)) continue;\n\n const target: SyncTargetSpec = {\n segment: objectDef.collection,\n registryKey: className,\n permissionCollection: resolvePermissionCollection(objectDef),\n ops,\n readonlyFields: collectReadonlyFieldNames(objectDef),\n writableAllowlist: getApiWritableAllowlist(apiConfig),\n publicAccess: getApiPublicAccess(apiConfig),\n tenantScoped: !!objectDef.decoratorConfig?.tenantScoped,\n };\n bySegment.set(objectDef.collection, { target, isStiChild });\n }\n\n return [...bySegment.values()]\n .map(({ target }) => target)\n .sort((a, b) => compareText(a.segment, 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 `permissionCollection: ${JSON.stringify(target.permissionCollection)}`,\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 collectionAccessImports: string,\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 type { RequestHandler } from './$types';\n${collectionAccessImports}\n${tenantHelper}\ninterface SyncTargetConfig {\n /** Manifest registry key (may be package-qualified), as CRUD routes use. */\n registryKey: string;\n /** \\`<collection>\\` prefix of the required \\`<collection>.<op>\\` permission. */\n permissionCollection: 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\nfunction readRecord(value: unknown): Record<string, unknown> {\n return value && typeof value === 'object'\n ? (value as Record<string, unknown>)\n : {};\n}\n\nfunction hasGrantedPermission(value: unknown, permission: string): boolean {\n if (Array.isArray(value)) return value.includes(permission);\n if (value instanceof Set) return value.has(permission);\n return false;\n}\n\n// Fail-closed operation permission (#2977, #3011): each applied op requires\n// the same \\`<collection>.<op>\\` permission the mirrored generated write route\n// enforces, read from the principal's session permission snapshot. A missing\n// snapshot denies; only an explicit tenancy super-admin bypass skips it.\nfunction hasOperationPermission(\n locals: unknown,\n permissionCollection: string,\n op: SyncApplyOp,\n): boolean {\n const l = readRecord(locals);\n const tenantContext = readRecord(l.tenantContext);\n if (tenantContext.superAdminBypass === true) return true;\n const permission = \\`\\${permissionCollection}.\\${op}\\`;\n return (\n hasGrantedPermission(l.permissions, permission) ||\n hasGrantedPermission(l.permissionSet, permission) ||\n hasGrantedPermission(l.smrtPermissions, permission) ||\n hasGrantedPermission(tenantContext.permissions, permission)\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 mutate: async (row, op, data) => {\n await collection.withAuditMutation(row as import('@happyvertical/smrt-core').SmrtObject, op === 'update' ? 'updated' : 'deleted', async (bound) => {\n if (op === 'update') {\n Object.assign(bound, data);\n await bound.save();\n } else {\n await bound.delete({ expectedUpdatedAt: bound.updated_at ?? undefined });\n }\n });\n },\n isOpAllowed: (op: SyncApplyOp) => target.ops.includes(op),\n authorize: (op: SyncApplyOp) => {\n // \\`public: true\\` keeps its unauthenticated write semantics, as on\n // the generated CRUD routes.\n if (target.publicAccess === true) return 'ok' as const;\n if (!authenticated) return 'auth_required' as const;\n return hasOperationPermission(locals, target.permissionCollection, op)\n ? ('ok' as const)\n : ('forbidden' as const);\n },\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(\n routePath,\n generateSyncApplyRouteTemplate(\n targets,\n generateCollectionAccessImports(projectRoot, routeDir, options, [\n 'getCollection',\n ]),\n ),\n 'utf-8',\n );\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.57.46",
3
+ "version": "0.57.48",
4
4
  "description": "Core SMRT framework for business objects, persistence, and generated REST, CLI, MCP, and AI interfaces",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -195,22 +195,21 @@
195
195
  "./dist/utils/json.js": "./dist/utils/json.browser.js"
196
196
  },
197
197
  "dependencies": {
198
- "@happyvertical/ai": "^0.111.0",
199
- "@happyvertical/files": "^0.111.0",
200
- "@happyvertical/json": "^0.111.0",
201
- "@happyvertical/logger": "^0.111.0",
202
- "@happyvertical/smrt-config": "0.57.46",
203
- "@happyvertical/smrt-scanner": "0.57.46",
204
- "@happyvertical/smrt-types": "0.57.46",
205
- "@happyvertical/sql": "^0.111.0",
206
- "@happyvertical/utils": "^0.111.0",
198
+ "@happyvertical/ai": "^0.111.3",
199
+ "@happyvertical/files": "^0.111.3",
200
+ "@happyvertical/json": "^0.111.3",
201
+ "@happyvertical/logger": "^0.111.3",
202
+ "@happyvertical/smrt-config": "0.57.48",
203
+ "@happyvertical/smrt-scanner": "0.57.48",
204
+ "@happyvertical/smrt-types": "0.57.48",
205
+ "@happyvertical/sql": "^0.111.3",
206
+ "@happyvertical/utils": "^0.111.3",
207
207
  "@noble/hashes": "^2.4.0",
208
208
  "@oxc-project/runtime": "^0.150.0",
209
209
  "cheerio": "^1.2.0",
210
210
  "import-meta-resolve": "4.2.0",
211
211
  "minimatch": "10.2.6",
212
212
  "oxc-transform": "0.150.0",
213
- "pluralize": "^8.0.0",
214
213
  "tsx": "^4.23.13",
215
214
  "yaml": "^2.9.1"
216
215
  },
@@ -235,6 +234,7 @@
235
234
  "@types/pluralize": "^0.0.33",
236
235
  "@xenova/transformers": "^2.17.2",
237
236
  "ajv": "8.20.0",
237
+ "pluralize": "^8.0.0",
238
238
  "reflect-metadata": "0.2.2",
239
239
  "svelte": "^5.57.1",
240
240
  "svelte-check": "^4.7.6",
@@ -256,7 +256,7 @@
256
256
  "generate": "node scripts/generate-manifest.js",
257
257
  "generate:test": "node scripts/generate-test-manifest.js",
258
258
  "test": "node scripts/generate-test-manifest.js && vitest run",
259
- "test:browser": "node scripts/browser-plain-json-regression.mjs && node scripts/browser-entry-evaluation.mjs",
259
+ "test:browser": "node scripts/browser-plain-json-regression.mjs && node scripts/browser-entry-evaluation.mjs && node scripts/browser-unbundled-dev.mjs",
260
260
  "test:postgres": "node scripts/generate-test-manifest.js && NODE_OPTIONS=--trace-deprecation node ../../scripts/run-with-ci-postgres.mjs -- pnpm exec vitest run optional.test.ts",
261
261
  "test:integration": "TEST_INTEGRATION=1 vitest run src/__tests__/full-registry-integration.test.ts src/__tests__/manifest-no-leak.test.ts",
262
262
  "test:watch": "node scripts/generate-test-manifest.js && vitest",