@milaboratories/pl-middle-layer 1.74.11 → 1.74.13

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 (320) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.137.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/usingCtx.cjs +1 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.137.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/usingCtx.js +1 -2
  3. package/dist/_virtual/_rolldown/runtime.cjs +2 -3
  4. package/dist/block_registry/location_provider.cjs +1 -2
  5. package/dist/block_registry/location_provider.cjs.map +1 -1
  6. package/dist/block_registry/location_provider.js +1 -2
  7. package/dist/block_registry/registry-v2-provider.cjs +1 -2
  8. package/dist/block_registry/registry-v2-provider.cjs.map +1 -1
  9. package/dist/block_registry/registry-v2-provider.d.ts +0 -2
  10. package/dist/block_registry/registry-v2-provider.d.ts.map +1 -1
  11. package/dist/block_registry/registry-v2-provider.js +1 -2
  12. package/dist/block_registry/registry.cjs +3 -3
  13. package/dist/block_registry/registry.cjs.map +1 -1
  14. package/dist/block_registry/registry.d.ts +0 -2
  15. package/dist/block_registry/registry.d.ts.map +1 -1
  16. package/dist/block_registry/registry.js +3 -3
  17. package/dist/block_registry/registry.js.map +1 -1
  18. package/dist/block_registry/template_provider.cjs +1 -2
  19. package/dist/block_registry/template_provider.cjs.map +1 -1
  20. package/dist/block_registry/template_provider.d.ts +0 -2
  21. package/dist/block_registry/template_provider.d.ts.map +1 -1
  22. package/dist/block_registry/template_provider.js +1 -2
  23. package/dist/block_registry/watcher.cjs +15 -14
  24. package/dist/block_registry/watcher.cjs.map +1 -1
  25. package/dist/block_registry/watcher.d.ts +0 -2
  26. package/dist/block_registry/watcher.d.ts.map +1 -1
  27. package/dist/block_registry/watcher.js +15 -14
  28. package/dist/block_registry/watcher.js.map +1 -1
  29. package/dist/block_registry/well_known_registries.cjs +1 -2
  30. package/dist/block_registry/well_known_registries.d.ts +0 -2
  31. package/dist/block_registry/well_known_registries.d.ts.map +1 -1
  32. package/dist/block_registry/well_known_registries.js +1 -2
  33. package/dist/cfg_render/executor.cjs +1 -2
  34. package/dist/cfg_render/executor.cjs.map +1 -1
  35. package/dist/cfg_render/executor.js +1 -2
  36. package/dist/cfg_render/renderer.cjs +1 -2
  37. package/dist/cfg_render/renderer.cjs.map +1 -1
  38. package/dist/cfg_render/renderer.js +1 -2
  39. package/dist/cfg_render/traverse.cjs +1 -2
  40. package/dist/cfg_render/traverse.js +1 -2
  41. package/dist/cfg_render/util.cjs +1 -2
  42. package/dist/cfg_render/util.js +1 -2
  43. package/dist/debug/index.cjs +1 -2
  44. package/dist/debug/index.js +1 -2
  45. package/dist/dev_env/index.cjs +1 -2
  46. package/dist/dev_env/index.cjs.map +1 -1
  47. package/dist/dev_env/index.js +1 -2
  48. package/dist/dev_env/util.cjs +1 -2
  49. package/dist/dev_env/util.js +1 -2
  50. package/dist/js_render/column_registry.cjs +1 -2
  51. package/dist/js_render/column_registry.cjs.map +1 -1
  52. package/dist/js_render/column_registry.js +1 -2
  53. package/dist/js_render/computable_context.cjs +4 -4
  54. package/dist/js_render/computable_context.cjs.map +1 -1
  55. package/dist/js_render/computable_context.js +4 -4
  56. package/dist/js_render/computable_context.js.map +1 -1
  57. package/dist/js_render/context.cjs +1 -2
  58. package/dist/js_render/context.cjs.map +1 -1
  59. package/dist/js_render/context.js +1 -2
  60. package/dist/js_render/index.cjs +12 -9
  61. package/dist/js_render/index.cjs.map +1 -1
  62. package/dist/js_render/index.js +12 -9
  63. package/dist/js_render/index.js.map +1 -1
  64. package/dist/js_render/service_injectors.cjs +2 -3
  65. package/dist/js_render/service_injectors.js +2 -3
  66. package/dist/js_render/spec_override_collapse.cjs +1 -2
  67. package/dist/js_render/spec_override_collapse.cjs.map +1 -1
  68. package/dist/js_render/spec_override_collapse.js +1 -2
  69. package/dist/middle_layer/active_cfg.cjs +1 -2
  70. package/dist/middle_layer/active_cfg.cjs.map +1 -1
  71. package/dist/middle_layer/active_cfg.js +1 -2
  72. package/dist/middle_layer/block.cjs +1 -2
  73. package/dist/middle_layer/block.cjs.map +1 -1
  74. package/dist/middle_layer/block.js +1 -2
  75. package/dist/middle_layer/block_ctx.cjs +3 -3
  76. package/dist/middle_layer/block_ctx.cjs.map +1 -1
  77. package/dist/middle_layer/block_ctx.js +3 -3
  78. package/dist/middle_layer/block_ctx.js.map +1 -1
  79. package/dist/middle_layer/block_ctx_unsafe.cjs +1 -2
  80. package/dist/middle_layer/block_ctx_unsafe.js +1 -2
  81. package/dist/middle_layer/build_stamp.cjs +2 -3
  82. package/dist/middle_layer/build_stamp.js +2 -3
  83. package/dist/middle_layer/driver_kit.cjs +1 -2
  84. package/dist/middle_layer/driver_kit.cjs.map +1 -1
  85. package/dist/middle_layer/driver_kit.d.ts +0 -2
  86. package/dist/middle_layer/driver_kit.d.ts.map +1 -1
  87. package/dist/middle_layer/driver_kit.js +1 -2
  88. package/dist/middle_layer/finality_stop_rules.cjs +1 -2
  89. package/dist/middle_layer/finality_stop_rules.js +1 -2
  90. package/dist/middle_layer/folders.cjs +12 -8
  91. package/dist/middle_layer/folders.cjs.map +1 -1
  92. package/dist/middle_layer/folders.d.ts +0 -2
  93. package/dist/middle_layer/folders.d.ts.map +1 -1
  94. package/dist/middle_layer/folders.js +12 -8
  95. package/dist/middle_layer/folders.js.map +1 -1
  96. package/dist/middle_layer/frontend_path.cjs +1 -2
  97. package/dist/middle_layer/frontend_path.cjs.map +1 -1
  98. package/dist/middle_layer/frontend_path.js +1 -2
  99. package/dist/middle_layer/middle_layer.cjs +7 -6
  100. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  101. package/dist/middle_layer/middle_layer.d.ts +0 -2
  102. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  103. package/dist/middle_layer/middle_layer.js +7 -6
  104. package/dist/middle_layer/middle_layer.js.map +1 -1
  105. package/dist/middle_layer/navigation_states.cjs +1 -2
  106. package/dist/middle_layer/navigation_states.js +1 -2
  107. package/dist/middle_layer/ops.cjs +10 -11
  108. package/dist/middle_layer/ops.cjs.map +1 -1
  109. package/dist/middle_layer/ops.d.ts +0 -2
  110. package/dist/middle_layer/ops.d.ts.map +1 -1
  111. package/dist/middle_layer/ops.js +10 -11
  112. package/dist/middle_layer/ops.js.map +1 -1
  113. package/dist/middle_layer/project.cjs +3 -3
  114. package/dist/middle_layer/project.cjs.map +1 -1
  115. package/dist/middle_layer/project.d.ts +3 -5
  116. package/dist/middle_layer/project.d.ts.map +1 -1
  117. package/dist/middle_layer/project.js +3 -3
  118. package/dist/middle_layer/project.js.map +1 -1
  119. package/dist/middle_layer/project_list.cjs +1 -2
  120. package/dist/middle_layer/project_list.cjs.map +1 -1
  121. package/dist/middle_layer/project_list.d.ts +0 -2
  122. package/dist/middle_layer/project_list.d.ts.map +1 -1
  123. package/dist/middle_layer/project_list.js +1 -2
  124. package/dist/middle_layer/project_overview.cjs +8 -5
  125. package/dist/middle_layer/project_overview.cjs.map +1 -1
  126. package/dist/middle_layer/project_overview.js +8 -5
  127. package/dist/middle_layer/project_overview.js.map +1 -1
  128. package/dist/middle_layer/project_overview_light.cjs +4 -3
  129. package/dist/middle_layer/project_overview_light.cjs.map +1 -1
  130. package/dist/middle_layer/project_overview_light.js +4 -3
  131. package/dist/middle_layer/project_overview_light.js.map +1 -1
  132. package/dist/middle_layer/render.cjs +1 -2
  133. package/dist/middle_layer/render.cjs.map +1 -1
  134. package/dist/middle_layer/render.js +1 -2
  135. package/dist/middle_layer/sharing_list.cjs +1 -2
  136. package/dist/middle_layer/sharing_list.cjs.map +1 -1
  137. package/dist/middle_layer/sharing_list.d.ts +0 -2
  138. package/dist/middle_layer/sharing_list.d.ts.map +1 -1
  139. package/dist/middle_layer/sharing_list.js +1 -2
  140. package/dist/middle_layer/sharing_list.js.map +1 -1
  141. package/dist/middle_layer/template_list.cjs +1 -2
  142. package/dist/middle_layer/template_list.cjs.map +1 -1
  143. package/dist/middle_layer/template_list.d.ts +0 -2
  144. package/dist/middle_layer/template_list.d.ts.map +1 -1
  145. package/dist/middle_layer/template_list.js +1 -2
  146. package/dist/middle_layer/tree_snapshot_store.cjs +3 -3
  147. package/dist/middle_layer/tree_snapshot_store.cjs.map +1 -1
  148. package/dist/middle_layer/tree_snapshot_store.d.ts +0 -2
  149. package/dist/middle_layer/tree_snapshot_store.d.ts.map +1 -1
  150. package/dist/middle_layer/tree_snapshot_store.js +3 -3
  151. package/dist/middle_layer/tree_snapshot_store.js.map +1 -1
  152. package/dist/middle_layer/types.d.ts +0 -2
  153. package/dist/middle_layer/types.d.ts.map +1 -1
  154. package/dist/middle_layer/util.cjs +1 -2
  155. package/dist/middle_layer/util.cjs.map +1 -1
  156. package/dist/middle_layer/util.js +1 -2
  157. package/dist/model/args.cjs +1 -2
  158. package/dist/model/args.cjs.map +1 -1
  159. package/dist/model/args.js +1 -2
  160. package/dist/model/block_pack_spec.cjs +1 -2
  161. package/dist/model/block_pack_spec.d.ts +0 -2
  162. package/dist/model/block_pack_spec.d.ts.map +1 -1
  163. package/dist/model/block_pack_spec.js +1 -2
  164. package/dist/model/frontend.d.ts +0 -2
  165. package/dist/model/frontend.d.ts.map +1 -1
  166. package/dist/model/project_helper.cjs +1 -2
  167. package/dist/model/project_helper.cjs.map +1 -1
  168. package/dist/model/project_helper.d.ts +0 -2
  169. package/dist/model/project_helper.d.ts.map +1 -1
  170. package/dist/model/project_helper.js +1 -2
  171. package/dist/model/project_model.cjs +1 -2
  172. package/dist/model/project_model.d.ts +0 -2
  173. package/dist/model/project_model.d.ts.map +1 -1
  174. package/dist/model/project_model.js +1 -2
  175. package/dist/model/project_model_util.cjs +1 -2
  176. package/dist/model/project_model_util.js +1 -2
  177. package/dist/model/project_model_v1.cjs +1 -2
  178. package/dist/model/project_model_v1.js +1 -2
  179. package/dist/model/sharing_model.cjs +1 -2
  180. package/dist/model/sharing_model.cjs.map +1 -1
  181. package/dist/model/sharing_model.d.ts +0 -2
  182. package/dist/model/sharing_model.d.ts.map +1 -1
  183. package/dist/model/sharing_model.js +1 -2
  184. package/dist/model/template_apply.cjs +1 -2
  185. package/dist/model/template_apply.cjs.map +1 -1
  186. package/dist/model/template_apply.d.ts +0 -2
  187. package/dist/model/template_apply.d.ts.map +1 -1
  188. package/dist/model/template_apply.js +1 -2
  189. package/dist/model/template_export.cjs +1 -2
  190. package/dist/model/template_export.d.ts +0 -2
  191. package/dist/model/template_export.d.ts.map +1 -1
  192. package/dist/model/template_export.js +1 -2
  193. package/dist/model/template_parser.cjs +1 -2
  194. package/dist/model/template_parser.cjs.map +1 -1
  195. package/dist/model/template_parser.d.ts +0 -2
  196. package/dist/model/template_parser.d.ts.map +1 -1
  197. package/dist/model/template_parser.js +1 -2
  198. package/dist/model/template_resolve.cjs +1 -2
  199. package/dist/model/template_resolve.cjs.map +1 -1
  200. package/dist/model/template_resolve.d.ts +0 -2
  201. package/dist/model/template_resolve.d.ts.map +1 -1
  202. package/dist/model/template_resolve.js +1 -2
  203. package/dist/model/template_serializer.cjs +1 -2
  204. package/dist/model/template_serializer.cjs.map +1 -1
  205. package/dist/model/template_serializer.d.ts +0 -2
  206. package/dist/model/template_serializer.d.ts.map +1 -1
  207. package/dist/model/template_serializer.js +1 -2
  208. package/dist/model/template_spec.d.ts +0 -2
  209. package/dist/model/template_spec.d.ts.map +1 -1
  210. package/dist/mutator/block-pack/block_pack.cjs +7 -6
  211. package/dist/mutator/block-pack/block_pack.cjs.map +1 -1
  212. package/dist/mutator/block-pack/block_pack.d.ts +0 -2
  213. package/dist/mutator/block-pack/block_pack.d.ts.map +1 -1
  214. package/dist/mutator/block-pack/block_pack.js +7 -6
  215. package/dist/mutator/block-pack/block_pack.js.map +1 -1
  216. package/dist/mutator/block-pack/frontend.cjs +1 -2
  217. package/dist/mutator/block-pack/frontend.cjs.map +1 -1
  218. package/dist/mutator/block-pack/frontend.js +1 -2
  219. package/dist/mutator/block-pack/required_capabilities.cjs +1 -2
  220. package/dist/mutator/block-pack/required_capabilities.cjs.map +1 -1
  221. package/dist/mutator/block-pack/required_capabilities.js +1 -2
  222. package/dist/mutator/context_export.cjs +1 -2
  223. package/dist/mutator/context_export.cjs.map +1 -1
  224. package/dist/mutator/context_export.js +1 -2
  225. package/dist/mutator/list.cjs +1 -2
  226. package/dist/mutator/list.cjs.map +1 -1
  227. package/dist/mutator/list.js +1 -2
  228. package/dist/mutator/migration.cjs +1 -2
  229. package/dist/mutator/migration.cjs.map +1 -1
  230. package/dist/mutator/migration.js +1 -2
  231. package/dist/mutator/project.cjs +30 -22
  232. package/dist/mutator/project.cjs.map +1 -1
  233. package/dist/mutator/project.d.ts +0 -2
  234. package/dist/mutator/project.d.ts.map +1 -1
  235. package/dist/mutator/project.js +30 -22
  236. package/dist/mutator/project.js.map +1 -1
  237. package/dist/mutator/sharing.cjs +1 -2
  238. package/dist/mutator/sharing.cjs.map +1 -1
  239. package/dist/mutator/sharing.js +1 -2
  240. package/dist/mutator/template/direct_template_loader.cjs +1 -2
  241. package/dist/mutator/template/direct_template_loader.cjs.map +1 -1
  242. package/dist/mutator/template/direct_template_loader.js +1 -2
  243. package/dist/mutator/template/direct_template_loader_v3.cjs +1 -2
  244. package/dist/mutator/template/direct_template_loader_v3.cjs.map +1 -1
  245. package/dist/mutator/template/direct_template_loader_v3.js +1 -2
  246. package/dist/mutator/template/render_block.cjs +1 -2
  247. package/dist/mutator/template/render_block.cjs.map +1 -1
  248. package/dist/mutator/template/render_block.js +1 -2
  249. package/dist/mutator/template/render_template.cjs +1 -2
  250. package/dist/mutator/template/render_template.cjs.map +1 -1
  251. package/dist/mutator/template/render_template.d.ts +0 -2
  252. package/dist/mutator/template/render_template.d.ts.map +1 -1
  253. package/dist/mutator/template/render_template.js +1 -2
  254. package/dist/mutator/template/template_cache.cjs +6 -5
  255. package/dist/mutator/template/template_cache.cjs.map +1 -1
  256. package/dist/mutator/template/template_cache.d.ts +1 -2
  257. package/dist/mutator/template/template_cache.d.ts.map +1 -1
  258. package/dist/mutator/template/template_cache.js +6 -5
  259. package/dist/mutator/template/template_cache.js.map +1 -1
  260. package/dist/mutator/template/template_loading.cjs +1 -2
  261. package/dist/mutator/template/template_loading.cjs.map +1 -1
  262. package/dist/mutator/template/template_loading.d.ts +0 -2
  263. package/dist/mutator/template/template_loading.d.ts.map +1 -1
  264. package/dist/mutator/template/template_loading.js +1 -2
  265. package/dist/mutator/template.cjs +1 -2
  266. package/dist/mutator/template.cjs.map +1 -1
  267. package/dist/mutator/template.js +1 -2
  268. package/dist/mutator/template_construct.cjs +1 -2
  269. package/dist/mutator/template_construct.cjs.map +1 -1
  270. package/dist/mutator/template_construct.js +1 -2
  271. package/dist/network_check/network_check.cjs +2 -3
  272. package/dist/network_check/network_check.cjs.map +1 -1
  273. package/dist/network_check/network_check.d.ts +0 -2
  274. package/dist/network_check/network_check.d.ts.map +1 -1
  275. package/dist/network_check/network_check.js +2 -3
  276. package/dist/network_check/network_check.js.map +1 -1
  277. package/dist/network_check/pings.cjs +1 -2
  278. package/dist/network_check/pings.cjs.map +1 -1
  279. package/dist/network_check/pings.js +1 -2
  280. package/dist/network_check/template.cjs +5 -4
  281. package/dist/network_check/template.cjs.map +1 -1
  282. package/dist/network_check/template.js +5 -4
  283. package/dist/network_check/template.js.map +1 -1
  284. package/dist/pool/data.cjs +1 -2
  285. package/dist/pool/data.cjs.map +1 -1
  286. package/dist/pool/data.d.ts +0 -2
  287. package/dist/pool/data.d.ts.map +1 -1
  288. package/dist/pool/data.js +1 -2
  289. package/dist/pool/driver.cjs +5 -4
  290. package/dist/pool/driver.cjs.map +1 -1
  291. package/dist/pool/driver.d.ts +0 -2
  292. package/dist/pool/driver.d.ts.map +1 -1
  293. package/dist/pool/driver.js +5 -4
  294. package/dist/pool/driver.js.map +1 -1
  295. package/dist/pool/p_object_collection.cjs +9 -8
  296. package/dist/pool/p_object_collection.cjs.map +1 -1
  297. package/dist/pool/p_object_collection.d.ts +0 -2
  298. package/dist/pool/p_object_collection.d.ts.map +1 -1
  299. package/dist/pool/p_object_collection.js +9 -8
  300. package/dist/pool/p_object_collection.js.map +1 -1
  301. package/dist/pool/result_pool.cjs +8 -7
  302. package/dist/pool/result_pool.cjs.map +1 -1
  303. package/dist/pool/result_pool.js +8 -7
  304. package/dist/pool/result_pool.js.map +1 -1
  305. package/dist/pool/upstream_block_ctx.cjs +1 -2
  306. package/dist/pool/upstream_block_ctx.cjs.map +1 -1
  307. package/dist/pool/upstream_block_ctx.js +1 -2
  308. package/dist/service_factories.cjs +1 -2
  309. package/dist/service_factories.js +1 -2
  310. package/dist/worker/WorkerManager.cjs +1 -2
  311. package/dist/worker/WorkerManager.js +1 -2
  312. package/dist/worker/WorkerManager.js.map +1 -1
  313. package/dist/worker/worker.cjs +1 -2
  314. package/dist/worker/worker.js +1 -2
  315. package/dist/worker/workerApi.cjs +1 -2
  316. package/dist/worker/workerApi.cjs.map +1 -1
  317. package/dist/worker/workerApi.js +1 -2
  318. package/package.json +33 -33
  319. package/src/middle_layer/middle_layer.ts +4 -6
  320. package/src/middle_layer/tree_snapshot_store.test.ts +4 -1
@@ -1 +1 @@
1
- {"version":3,"file":"template_list.cjs","names":["treeFilter","SynchronizedTreeState","Computable"],"sources":["../../src/middle_layer/template_list.ts"],"sourcesContent":["import type { PruningFunction } from \"@milaboratories/pl-tree\";\nimport { SynchronizedTreeState } from \"@milaboratories/pl-tree\";\nimport type {\n Filter,\n PlClient,\n PlTransaction,\n ResourceType,\n SignedResourceId,\n} from \"@milaboratories/pl-client\";\nimport {\n field,\n isNullSignedResourceId,\n resourceIdToString,\n resourceTypesEqual,\n treeFilter,\n} from \"@milaboratories/pl-client\";\nimport type { TreeAndComputableU } from \"./types\";\nimport { Computable } from \"@milaboratories/computable\";\nimport type { MiddleLayerEnvironment } from \"./middle_layer\";\nimport type { ProjectTemplateV1, TemplateId } from \"@milaboratories/pl-model-common\";\nimport { asTemplateId } from \"@milaboratories/pl-model-common\";\nimport { normalizeDescription } from \"@milaboratories/pl-model-middle-layer\";\nimport type { TemplateExportProblem } from \"../model/template_export\";\nimport type { AppliedEntry, TemplateApplyProblem } from \"../model/template_apply\";\nimport type { ProjectId } from \"../model/project_model\";\n\nexport const TemplatesField = \"templates\";\nexport const TemplatesResourceType: ResourceType = { name: \"Templates\", version: \"1\" };\nexport const TemplateResourceType: ResourceType = { name: \"UserTemplate\", version: \"1\" };\n\n/** Mutable: the only part of a stored template a rename may touch. */\nexport const TemplateLabelKey = \"TemplateLabel\";\n/** Mutable: free text about the template. Absent on a template nobody described. */\nexport const TemplateDescriptionKey = \"TemplateDescription\";\nexport const TemplateCreatedTimestamp = \"TemplateCreated\";\n\n/**\n * Unique template identifier in middle layer, the stringified signed resource id of the\n * `UserTemplate`. Branded so it cannot be confused with a {@link ProjectId} — both are\n * stringified resource ids and every template method takes one of them.\n *\n * Declared in the model package because the folder document places templates as well as\n * projects, and that document is written by code that cannot see this file.\n */\nexport type { TemplateId };\n\n/**\n * Immutable `data` on a UserTemplate: the document plus what was true when it was taken.\n *\n * The document lives here and never in KV: KV is listed and fully re-read on every poll,\n * while resource data syncs incrementally, and a template document is a multi-kilobyte\n * value that never changes.\n */\nexport interface StoredTemplateData {\n schemaVersion: 1;\n document: ProjectTemplateV1;\n /** Provenance, display only; absent for a template that arrived as a share. */\n sourceProjectLabel?: string;\n /** Login of the sender, when it arrived as a share. */\n sender?: string;\n}\n\n/** Decodes the immutable `data` blob of a `UserTemplate` read through a transaction. The\n * single raw-decode site; the tree side reads the same JSON with `getDataAsJson`. */\nexport function decodeStoredTemplateData(data: Uint8Array): StoredTemplateData {\n return JSON.parse(Buffer.from(data).toString(\"utf-8\")) as StoredTemplateData;\n}\n\n/** One template as the template list surfaces it. */\nexport interface TemplateListEntry {\n /** Unique template identifier in middle layer. Use to operate with the given template. */\n id: TemplateId;\n /** The mutable label, the only part a rename changes. */\n label: string;\n /** Free text the user wrote about the template. Absent when there is none. */\n description?: string;\n created: Date;\n /** Number of blocks the stored document lists — derived, not stored. */\n blockCount: number;\n sourceProjectLabel?: string;\n sender?: string;\n}\n\n/** What saving a project as a template yields: the stored template, or every block in the way. */\nexport type SaveProjectAsTemplateOutcome =\n | { readonly ok: true; readonly templateId: TemplateId }\n | { readonly ok: false; readonly problems: readonly TemplateExportProblem[] };\n\n/**\n * What applying a stored template yields.\n *\n * `ok: false` carries no project id because no project was created: nothing is written\n * until every entry has an installable block.\n */\nexport type CreateProjectFromTemplateOutcome =\n | {\n readonly ok: true;\n readonly projectId: ProjectId;\n readonly added: readonly AppliedEntry[];\n }\n | { readonly ok: false; readonly problems: readonly TemplateApplyProblem[] };\n\n/**\n * Resolves the templates-list resource on the transaction's client root, lazily creating (and\n * locking) an empty one when the {@link TemplatesField} is not yet populated. Returns its signed\n * id. Used when writing into a root that may have no templates list yet, e.g. a template landing\n * in a recipient's root.\n */\nexport async function ensureTemplateListRid(tx: PlTransaction): Promise<SignedResourceId> {\n const templatesField = field(tx.clientRoot, TemplatesField);\n tx.createField(templatesField, \"Dynamic\");\n const fData = await tx.getField(templatesField);\n if (isNullSignedResourceId(fData.value)) {\n const ref = tx.createEphemeral(TemplatesResourceType);\n tx.lock(ref);\n tx.setField(templatesField, ref);\n return await ref.globalId;\n }\n return fData.value;\n}\n\nexport const TemplatesListTreePruningFunction: PruningFunction = (resource) => {\n if (!resourceTypesEqual(resource.type, TemplatesResourceType)) return [];\n return resource.fields;\n};\n\nexport const templatesListFieldFilter: Filter = treeFilter.resourceTypeEq(\n TemplatesResourceType.name,\n);\n\nexport async function createTemplateList(\n pl: PlClient,\n rid: SignedResourceId,\n env: MiddleLayerEnvironment,\n): Promise<TreeAndComputableU<TemplateListEntry[]>> {\n const tree = await SynchronizedTreeState.init(\n pl,\n rid,\n {\n ...env.ops.defaultTreeOptions,\n pruning: TemplatesListTreePruningFunction,\n fieldFilter: templatesListFieldFilter,\n },\n env.logger,\n );\n\n const c = Computable.make((ctx) => {\n const node = ctx.accessor(tree.entry()).node();\n if (node === undefined) return undefined;\n return templateListEntries(node);\n }).withStableType();\n\n return { computable: c, tree };\n}\n\n/** The part of a tree node this reader needs from a single entry of the templates list. */\ninterface TemplateListEntryNode {\n readonly id: SignedResourceId;\n readonly resourceType: ResourceType;\n getDataAsJson<T>(): T | undefined;\n getKeyValueAsJson<T>(key: string): T | undefined;\n}\n\n/** The part of a tree node this reader needs from the templates-list resource itself. */\ninterface TemplatesListNode {\n listDynamicFields(): string[];\n traverse(fieldName: string): TemplateListEntryNode | undefined;\n}\n\n/**\n * Every stored template of one root, most recently created first.\n *\n * A function rather than a closure inside {@link createTemplateList} because the folder listing\n * reads the same tree in its own computable: templates and projects share one folder tree, and\n * joining it against two independently published lists would let a template show up in a folder\n * the tree has not heard of.\n *\n * Skips, rather than throws on, anything that is not a fully synced template, by the same rule\n * the project list follows: the folder listing is built on this, so one template whose data or\n * label has not arrived must cost that entry and nothing else. The resource type is matched by\n * name only, to exclude foreign resources without hiding a template stored under an earlier\n * resource-type version.\n */\nexport function templateListEntries(node: TemplatesListNode): TemplateListEntry[] {\n const result: TemplateListEntry[] = [];\n\n // Templates list resource keeps templates assigned to fields. Each field name is a UUID\n for (const field of node.listDynamicFields()) {\n const tpl = node.traverse(field);\n if (tpl === undefined) continue;\n if (tpl.resourceType.name !== TemplateResourceType.name) continue;\n\n const data = tpl.getDataAsJson<StoredTemplateData>();\n const label = tpl.getKeyValueAsJson<string>(TemplateLabelKey);\n const created = tpl.getKeyValueAsJson<number>(TemplateCreatedTimestamp);\n if (data === undefined || label === undefined || created === undefined) continue;\n\n // Written only once something is said about the template, so its absence is the ordinary case.\n const description = normalizeDescription(tpl.getKeyValueAsJson<string>(TemplateDescriptionKey));\n result.push({\n id: asTemplateId(resourceIdToString(tpl.id)),\n label,\n ...(description === undefined ? {} : { description }),\n created: new Date(created),\n blockCount: data.document.blocks.length,\n ...(data.sourceProjectLabel !== undefined\n ? { sourceProjectLabel: data.sourceProjectLabel }\n : {}),\n ...(data.sender !== undefined ? { sender: data.sender } : {}),\n });\n }\n result.sort((a, b) => b.created.valueOf() - a.created.valueOf());\n return result;\n}\n"],"mappings":";;;;;;AA0BA,MAAa,iBAAiB;AAC9B,MAAa,wBAAsC;CAAE,MAAM;CAAa,SAAS;AAAI;AACrF,MAAa,uBAAqC;CAAE,MAAM;CAAgB,SAAS;AAAI;;AAGvF,MAAa,mBAAmB;;AAEhC,MAAa,yBAAyB;AACtC,MAAa,2BAA2B;;;AA8BxC,SAAgB,yBAAyB,MAAsC;CAC7E,OAAO,KAAK,MAAM,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC;AACvD;AAuDA,MAAa,oCAAqD,aAAa;CAC7E,IAAI,EAAA,GAAA,0BAAA,mBAAA,CAAoB,SAAS,MAAM,qBAAqB,GAAG,OAAO,CAAC;CACvE,OAAO,SAAS;AAClB;AAEA,MAAa,2BAAmCA,0BAAAA,WAAW,eACzD,sBAAsB,IACxB;AAEA,eAAsB,mBACpB,IACA,KACA,KACkD;CAClD,MAAM,OAAO,MAAMC,wBAAAA,sBAAsB,KACvC,IACA,KACA;EACE,GAAG,IAAI,IAAI;EACX,SAAS;EACT,aAAa;CACf,GACA,IAAI,MACN;CAQA,OAAO;EAAE,YANCC,2BAAAA,WAAW,MAAM,QAAQ;GACjC,MAAM,OAAO,IAAI,SAAS,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK;GAC7C,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;GAC/B,OAAO,oBAAoB,IAAI;EACjC,CAAC,CAAC,CAAC,eAEkB;EAAG;CAAK;AAC/B;;;;;;;;;;;;;;;AA8BA,SAAgB,oBAAoB,MAA8C;CAChF,MAAM,SAA8B,CAAC;CAGrC,KAAK,MAAM,SAAS,KAAK,kBAAkB,GAAG;EAC5C,MAAM,MAAM,KAAK,SAAS,KAAK;EAC/B,IAAI,QAAQ,KAAA,GAAW;EACvB,IAAI,IAAI,aAAa,SAAS,qBAAqB,MAAM;EAEzD,MAAM,OAAO,IAAI,cAAkC;EACnD,MAAM,QAAQ,IAAI,kBAA0B,gBAAgB;EAC5D,MAAM,UAAU,IAAI,kBAA0B,wBAAwB;EACtE,IAAI,SAAS,KAAA,KAAa,UAAU,KAAA,KAAa,YAAY,KAAA,GAAW;EAGxE,MAAM,eAAA,GAAA,sCAAA,qBAAA,CAAmC,IAAI,kBAA0B,sBAAsB,CAAC;EAC9F,OAAO,KAAK;GACV,KAAA,GAAA,gCAAA,aAAA,EAAA,GAAA,0BAAA,mBAAA,CAAoC,IAAI,EAAE,CAAC;GAC3C;GACA,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;GACnD,SAAS,IAAI,KAAK,OAAO;GACzB,YAAY,KAAK,SAAS,OAAO;GACjC,GAAI,KAAK,uBAAuB,KAAA,IAC5B,EAAE,oBAAoB,KAAK,mBAAmB,IAC9C,CAAC;GACL,GAAI,KAAK,WAAW,KAAA,IAAY,EAAE,QAAQ,KAAK,OAAO,IAAI,CAAC;EAC7D,CAAC;CACH;CACA,OAAO,MAAM,GAAG,MAAM,EAAE,QAAQ,QAAQ,IAAI,EAAE,QAAQ,QAAQ,CAAC;CAC/D,OAAO;AACT"}
1
+ {"version":3,"file":"template_list.cjs","names":["resourceTypesEqual","treeFilter","SynchronizedTreeState","Computable","normalizeDescription","asTemplateId","resourceIdToString"],"sources":["../../src/middle_layer/template_list.ts"],"sourcesContent":["import type { PruningFunction } from \"@milaboratories/pl-tree\";\nimport { SynchronizedTreeState } from \"@milaboratories/pl-tree\";\nimport type {\n Filter,\n PlClient,\n PlTransaction,\n ResourceType,\n SignedResourceId,\n} from \"@milaboratories/pl-client\";\nimport {\n field,\n isNullSignedResourceId,\n resourceIdToString,\n resourceTypesEqual,\n treeFilter,\n} from \"@milaboratories/pl-client\";\nimport type { TreeAndComputableU } from \"./types\";\nimport { Computable } from \"@milaboratories/computable\";\nimport type { MiddleLayerEnvironment } from \"./middle_layer\";\nimport type { ProjectTemplateV1, TemplateId } from \"@milaboratories/pl-model-common\";\nimport { asTemplateId } from \"@milaboratories/pl-model-common\";\nimport { normalizeDescription } from \"@milaboratories/pl-model-middle-layer\";\nimport type { TemplateExportProblem } from \"../model/template_export\";\nimport type { AppliedEntry, TemplateApplyProblem } from \"../model/template_apply\";\nimport type { ProjectId } from \"../model/project_model\";\n\nexport const TemplatesField = \"templates\";\nexport const TemplatesResourceType: ResourceType = { name: \"Templates\", version: \"1\" };\nexport const TemplateResourceType: ResourceType = { name: \"UserTemplate\", version: \"1\" };\n\n/** Mutable: the only part of a stored template a rename may touch. */\nexport const TemplateLabelKey = \"TemplateLabel\";\n/** Mutable: free text about the template. Absent on a template nobody described. */\nexport const TemplateDescriptionKey = \"TemplateDescription\";\nexport const TemplateCreatedTimestamp = \"TemplateCreated\";\n\n/**\n * Unique template identifier in middle layer, the stringified signed resource id of the\n * `UserTemplate`. Branded so it cannot be confused with a {@link ProjectId} — both are\n * stringified resource ids and every template method takes one of them.\n *\n * Declared in the model package because the folder document places templates as well as\n * projects, and that document is written by code that cannot see this file.\n */\nexport type { TemplateId };\n\n/**\n * Immutable `data` on a UserTemplate: the document plus what was true when it was taken.\n *\n * The document lives here and never in KV: KV is listed and fully re-read on every poll,\n * while resource data syncs incrementally, and a template document is a multi-kilobyte\n * value that never changes.\n */\nexport interface StoredTemplateData {\n schemaVersion: 1;\n document: ProjectTemplateV1;\n /** Provenance, display only; absent for a template that arrived as a share. */\n sourceProjectLabel?: string;\n /** Login of the sender, when it arrived as a share. */\n sender?: string;\n}\n\n/** Decodes the immutable `data` blob of a `UserTemplate` read through a transaction. The\n * single raw-decode site; the tree side reads the same JSON with `getDataAsJson`. */\nexport function decodeStoredTemplateData(data: Uint8Array): StoredTemplateData {\n return JSON.parse(Buffer.from(data).toString(\"utf-8\")) as StoredTemplateData;\n}\n\n/** One template as the template list surfaces it. */\nexport interface TemplateListEntry {\n /** Unique template identifier in middle layer. Use to operate with the given template. */\n id: TemplateId;\n /** The mutable label, the only part a rename changes. */\n label: string;\n /** Free text the user wrote about the template. Absent when there is none. */\n description?: string;\n created: Date;\n /** Number of blocks the stored document lists — derived, not stored. */\n blockCount: number;\n sourceProjectLabel?: string;\n sender?: string;\n}\n\n/** What saving a project as a template yields: the stored template, or every block in the way. */\nexport type SaveProjectAsTemplateOutcome =\n | { readonly ok: true; readonly templateId: TemplateId }\n | { readonly ok: false; readonly problems: readonly TemplateExportProblem[] };\n\n/**\n * What applying a stored template yields.\n *\n * `ok: false` carries no project id because no project was created: nothing is written\n * until every entry has an installable block.\n */\nexport type CreateProjectFromTemplateOutcome =\n | {\n readonly ok: true;\n readonly projectId: ProjectId;\n readonly added: readonly AppliedEntry[];\n }\n | { readonly ok: false; readonly problems: readonly TemplateApplyProblem[] };\n\n/**\n * Resolves the templates-list resource on the transaction's client root, lazily creating (and\n * locking) an empty one when the {@link TemplatesField} is not yet populated. Returns its signed\n * id. Used when writing into a root that may have no templates list yet, e.g. a template landing\n * in a recipient's root.\n */\nexport async function ensureTemplateListRid(tx: PlTransaction): Promise<SignedResourceId> {\n const templatesField = field(tx.clientRoot, TemplatesField);\n tx.createField(templatesField, \"Dynamic\");\n const fData = await tx.getField(templatesField);\n if (isNullSignedResourceId(fData.value)) {\n const ref = tx.createEphemeral(TemplatesResourceType);\n tx.lock(ref);\n tx.setField(templatesField, ref);\n return await ref.globalId;\n }\n return fData.value;\n}\n\nexport const TemplatesListTreePruningFunction: PruningFunction = (resource) => {\n if (!resourceTypesEqual(resource.type, TemplatesResourceType)) return [];\n return resource.fields;\n};\n\nexport const templatesListFieldFilter: Filter = treeFilter.resourceTypeEq(\n TemplatesResourceType.name,\n);\n\nexport async function createTemplateList(\n pl: PlClient,\n rid: SignedResourceId,\n env: MiddleLayerEnvironment,\n): Promise<TreeAndComputableU<TemplateListEntry[]>> {\n const tree = await SynchronizedTreeState.init(\n pl,\n rid,\n {\n ...env.ops.defaultTreeOptions,\n pruning: TemplatesListTreePruningFunction,\n fieldFilter: templatesListFieldFilter,\n },\n env.logger,\n );\n\n const c = Computable.make((ctx) => {\n const node = ctx.accessor(tree.entry()).node();\n if (node === undefined) return undefined;\n return templateListEntries(node);\n }).withStableType();\n\n return { computable: c, tree };\n}\n\n/** The part of a tree node this reader needs from a single entry of the templates list. */\ninterface TemplateListEntryNode {\n readonly id: SignedResourceId;\n readonly resourceType: ResourceType;\n getDataAsJson<T>(): T | undefined;\n getKeyValueAsJson<T>(key: string): T | undefined;\n}\n\n/** The part of a tree node this reader needs from the templates-list resource itself. */\ninterface TemplatesListNode {\n listDynamicFields(): string[];\n traverse(fieldName: string): TemplateListEntryNode | undefined;\n}\n\n/**\n * Every stored template of one root, most recently created first.\n *\n * A function rather than a closure inside {@link createTemplateList} because the folder listing\n * reads the same tree in its own computable: templates and projects share one folder tree, and\n * joining it against two independently published lists would let a template show up in a folder\n * the tree has not heard of.\n *\n * Skips, rather than throws on, anything that is not a fully synced template, by the same rule\n * the project list follows: the folder listing is built on this, so one template whose data or\n * label has not arrived must cost that entry and nothing else. The resource type is matched by\n * name only, to exclude foreign resources without hiding a template stored under an earlier\n * resource-type version.\n */\nexport function templateListEntries(node: TemplatesListNode): TemplateListEntry[] {\n const result: TemplateListEntry[] = [];\n\n // Templates list resource keeps templates assigned to fields. Each field name is a UUID\n for (const field of node.listDynamicFields()) {\n const tpl = node.traverse(field);\n if (tpl === undefined) continue;\n if (tpl.resourceType.name !== TemplateResourceType.name) continue;\n\n const data = tpl.getDataAsJson<StoredTemplateData>();\n const label = tpl.getKeyValueAsJson<string>(TemplateLabelKey);\n const created = tpl.getKeyValueAsJson<number>(TemplateCreatedTimestamp);\n if (data === undefined || label === undefined || created === undefined) continue;\n\n // Written only once something is said about the template, so its absence is the ordinary case.\n const description = normalizeDescription(tpl.getKeyValueAsJson<string>(TemplateDescriptionKey));\n result.push({\n id: asTemplateId(resourceIdToString(tpl.id)),\n label,\n ...(description === undefined ? {} : { description }),\n created: new Date(created),\n blockCount: data.document.blocks.length,\n ...(data.sourceProjectLabel !== undefined\n ? { sourceProjectLabel: data.sourceProjectLabel }\n : {}),\n ...(data.sender !== undefined ? { sender: data.sender } : {}),\n });\n }\n result.sort((a, b) => b.created.valueOf() - a.created.valueOf());\n return result;\n}\n"],"mappings":";;;;;;AA0BA,MAAa,iBAAiB;AAC9B,MAAa,wBAAsC;CAAE,MAAM;CAAa,SAAS;AAAI;AACrF,MAAa,uBAAqC;CAAE,MAAM;CAAgB,SAAS;AAAI;;AAGvF,MAAa,mBAAmB;;AAEhC,MAAa,yBAAyB;AACtC,MAAa,2BAA2B;;;AA8BxC,SAAgB,yBAAyB,MAAsC;CAC7E,OAAO,KAAK,MAAM,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC;AACvD;AAuDA,MAAa,oCAAqD,aAAa;CAC7E,IAAI,EAAA,GAACA,0BAAAA,mBAAAA,CAAmB,SAAS,MAAM,qBAAqB,GAAG,OAAO,CAAC;CACvE,OAAO,SAAS;AAClB;AAEA,MAAa,2BAAmCC,0BAAAA,WAAW,eACzD,sBAAsB,IACxB;AAEA,eAAsB,mBACpB,IACA,KACA,KACkD;CAClD,MAAM,OAAO,MAAMC,wBAAAA,sBAAsB,KACvC,IACA,KACA;EACE,GAAG,IAAI,IAAI;EACX,SAAS;EACT,aAAa;CACf,GACA,IAAI,MACN;CAQA,OAAO;EAAE,YANCC,2BAAAA,WAAW,MAAM,QAAQ;GACjC,MAAM,OAAO,IAAI,SAAS,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK;GAC7C,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;GAC/B,OAAO,oBAAoB,IAAI;EACjC,CAAC,CAAC,CAAC,eAEkB;EAAG;CAAK;AAC/B;;;;;;;;;;;;;;;AA8BA,SAAgB,oBAAoB,MAA8C;CAChF,MAAM,SAA8B,CAAC;CAGrC,KAAK,MAAM,SAAS,KAAK,kBAAkB,GAAG;EAC5C,MAAM,MAAM,KAAK,SAAS,KAAK;EAC/B,IAAI,QAAQ,KAAA,GAAW;EACvB,IAAI,IAAI,aAAa,SAAS,qBAAqB,MAAM;EAEzD,MAAM,OAAO,IAAI,cAAkC;EACnD,MAAM,QAAQ,IAAI,kBAA0B,gBAAgB;EAC5D,MAAM,UAAU,IAAI,kBAA0B,wBAAwB;EACtE,IAAI,SAAS,KAAA,KAAa,UAAU,KAAA,KAAa,YAAY,KAAA,GAAW;EAGxE,MAAM,eAAA,GAAcC,sCAAAA,qBAAAA,CAAqB,IAAI,kBAA0B,sBAAsB,CAAC;EAC9F,OAAO,KAAK;GACV,KAAA,GAAIC,gCAAAA,aAAAA,EAAAA,GAAaC,0BAAAA,mBAAAA,CAAmB,IAAI,EAAE,CAAC;GAC3C;GACA,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;GACnD,SAAS,IAAI,KAAK,OAAO;GACzB,YAAY,KAAK,SAAS,OAAO;GACjC,GAAI,KAAK,uBAAuB,KAAA,IAC5B,EAAE,oBAAoB,KAAK,mBAAmB,IAC9C,CAAC;GACL,GAAI,KAAK,WAAW,KAAA,IAAY,EAAE,QAAQ,KAAK,OAAO,IAAI,CAAC;EAC7D,CAAC;CACH;CACA,OAAO,MAAM,GAAG,MAAM,EAAE,QAAQ,QAAQ,IAAI,EAAE,QAAQ,QAAQ,CAAC;CAC/D,OAAO;AACT"}
@@ -6,7 +6,6 @@ import "./middle_layer.js";
6
6
  import { ProjectTemplateV1, TemplateId as TemplateId$1 } from "@milaboratories/pl-model-common";
7
7
  import { PlClient, PlTransaction, ResourceType, SignedResourceId } from "@milaboratories/pl-client";
8
8
  import "@milaboratories/pl-tree";
9
- //#region src/middle_layer/template_list.d.ts
10
9
  export declare const TemplatesField = "templates";
11
10
  /**
12
11
  * Immutable `data` on a UserTemplate: the document plus what was true when it was taken.
@@ -59,6 +58,5 @@ export type CreateProjectFromTemplateOutcome = {
59
58
  readonly ok: false;
60
59
  readonly problems: readonly TemplateApplyProblem[];
61
60
  };
62
- //#endregion
63
61
  export type { TemplateId$1 as TemplateId };
64
62
  //# sourceMappingURL=template_list.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"template_list.d.ts","names":[],"sources":["../../src/middle_layer/template_list.ts"],"mappings":";;;;;;;;;qBA0Ba;;;;;;;;iBA2BI;EACf;EACA,UAAU;;EAEV;;EAEA;;;iBAUe;;EAEf,IAAI;;EAEJ;;EAEA;EACA,SAAS;;EAET;EACA;EACA;;;YAIU;WACG;WAAmB,YAAY;;WAC/B;WAAoB,mBAAmB;;;;;;;;YAQ1C;WAEG;WACA,WAAW;WACX,gBAAgB;;WAEhB;WAAoB,mBAAmB"}
1
+ {"version":3,"file":"template_list.d.ts","names":["TemplateId","ProjectId"],"sources":["../../src/middle_layer/template_list.ts"],"mappings":";;;;;;;;qBA0Ba;;;;;;;;iBA2BI;EACf;EACA,UAAU;;EAEV;;EAEA;;;iBAUe;;EAEf,IAAIA;;EAEJ;;EAEA;EACA,SAAS;;EAET;EACA;EACA;;;YAIU;WACG;WAAmB,YAAYA;;WAC/B;WAAoB,mBAAmB;;;;;;;;YAQ1C;WAEG;WACA,WAAWC;WACX,gBAAgB;;WAEhB;WAAoB,mBAAmB"}
@@ -3,7 +3,7 @@ import { asTemplateId } from "@milaboratories/pl-model-common";
3
3
  import { resourceIdToString, resourceTypesEqual, treeFilter } from "@milaboratories/pl-client";
4
4
  import { SynchronizedTreeState } from "@milaboratories/pl-tree";
5
5
  import { Computable } from "@milaboratories/computable";
6
- //#region src/middle_layer/template_list.ts
6
+ // src/middle_layer/template_list.ts
7
7
  const TemplatesField = "templates";
8
8
  const TemplatesResourceType = {
9
9
  name: "Templates",
@@ -81,7 +81,6 @@ function templateListEntries(node) {
81
81
  result.sort((a, b) => b.created.valueOf() - a.created.valueOf());
82
82
  return result;
83
83
  }
84
- //#endregion
85
84
  export { TemplateCreatedTimestamp, TemplateDescriptionKey, TemplateLabelKey, TemplateResourceType, TemplatesField, TemplatesListTreePruningFunction, TemplatesResourceType, createTemplateList, decodeStoredTemplateData, templateListEntries, templatesListFieldFilter };
86
85
 
87
86
  //# sourceMappingURL=template_list.js.map
@@ -8,7 +8,7 @@ node_fs_promises = require_runtime.__toESM(node_fs_promises, 1);
8
8
  let _milaboratories_pl_client = require("@milaboratories/pl-client");
9
9
  let _milaboratories_pl_tree = require("@milaboratories/pl-tree");
10
10
  let node_crypto = require("node:crypto");
11
- //#region src/middle_layer/tree_snapshot_store.ts
11
+ // src/middle_layer/tree_snapshot_store.ts
12
12
  function initialStat() {
13
13
  return {
14
14
  reads: 0,
@@ -92,7 +92,8 @@ var TreeSnapshotStore = class TreeSnapshotStore {
92
92
  pl.serverInfo.instanceId ?? "",
93
93
  pl.authUser ?? ""
94
94
  ].join("\0")).digest("hex").slice(0, 16);
95
- return new TreeSnapshotStore(ops, `${_milaboratories_pl_tree.PERSISTED_TREE_SCHEMA_VERSION}.${safe(require_build_stamp.ML_BUILD_STAMP)}.${identity}`);
95
+ const scope = `${_milaboratories_pl_tree.PERSISTED_TREE_SCHEMA_VERSION}.${safe(require_build_stamp.ML_BUILD_STAMP)}.${identity}`;
96
+ return new TreeSnapshotStore(ops, scope);
96
97
  }
97
98
  /**
98
99
  * Removes this class's files from the snapshot directory. Run when snapshots are switched
@@ -277,7 +278,6 @@ var TreeSnapshotStore = class TreeSnapshotStore {
277
278
  }
278
279
  }
279
280
  };
280
- //#endregion
281
281
  exports.TreeSnapshotStore = TreeSnapshotStore;
282
282
 
283
283
  //# sourceMappingURL=tree_snapshot_store.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"tree_snapshot_store.cjs","names":["PERSISTED_TREE_SCHEMA_VERSION","ML_BUILD_STAMP","fsp","path"],"sources":["../../src/middle_layer/tree_snapshot_store.ts"],"sourcesContent":["import type { PersistedTree, PersistedTreeReadFailure } from \"@milaboratories/pl-tree\";\nimport {\n decodePersistedTree,\n encodePersistedTree,\n PERSISTED_TREE_SCHEMA_VERSION,\n readPersistedTreeHeader,\n} from \"@milaboratories/pl-tree\";\nimport type { PlClient, SignedResourceId } from \"@milaboratories/pl-client\";\nimport { parseSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\nimport { createPathAtomically, ensureDirExists } from \"@milaboratories/ts-helpers\";\nimport { createHash } from \"node:crypto\";\nimport fsp from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { ML_BUILD_STAMP } from \"./build_stamp\";\n\n/** Why a read did not produce a tree to restore from. Counted rather than inferred, because\n * \"no snapshot\" and \"a snapshot we refused\" are very different things when a warm reopen\n * fails to be warm and someone has to work out why. */\nexport type TreeSnapshotMiss =\n /** No file for this key: a first open, or the key moved (new build, new backend, new user). */\n | \"absent\"\n /** A file is there but could not be opened at all: permissions, a bad mount, an I/O error.\n * Distinct from `absent` because a first open and a broken cache directory need different\n * answers from whoever reads the counters. */\n | \"unreadable\"\n /** File exists, but its signatures belong to a session that has ended. Kept, not deleted. */\n | \"session-rotated\"\n /** File exists and could not be read. Carries the codec's reason. */\n | PersistedTreeReadFailure;\n\nexport type TreeSnapshotStat = {\n reads: number;\n /** Snapshots read successfully. A hit is not yet a warm open: the tree can still refuse to\n * apply it, which is what {@link restores} counts. */\n hits: number;\n /** Snapshots actually applied as a tree's initial state. This is the number that says a\n * reopen was warm. */\n restores: number;\n /** Miss counts by reason. */\n misses: Record<TreeSnapshotMiss, number>;\n writes: number;\n writeFailures: number;\n bytesWritten: number;\n /** Snapshots deleted by the fail-safe after a restored tree failed its first refresh. */\n discarded: number;\n /** Files removed at startup, and how many of those were dropped for being over the ceiling\n * rather than for belonging to another build, backend or user. */\n evicted: number;\n evictedForSize: number;\n bytesEvicted: number;\n millisReading: number;\n millisWriting: number;\n};\n\nfunction initialStat(): TreeSnapshotStat {\n return {\n reads: 0,\n hits: 0,\n restores: 0,\n misses: {\n absent: 0,\n unreadable: 0,\n \"session-rotated\": 0,\n \"not-a-snapshot\": 0,\n \"unknown-schema\": 0,\n truncated: 0,\n checksum: 0,\n malformed: 0,\n },\n writes: 0,\n writeFailures: 0,\n bytesWritten: 0,\n discarded: 0,\n evicted: 0,\n evictedForSize: 0,\n bytesEvicted: 0,\n millisReading: 0,\n millisWriting: 0,\n };\n}\n\nexport type TreeSnapshotStoreOps = {\n /** Directory holding the snapshots. One file per project. */\n readonly dir: string;\n /** Total bytes the directory may occupy after startup eviction. */\n readonly maxSizeBytes: number;\n readonly logger: MiLogger;\n};\n\nconst FILE_PREFIX = \"tree.\";\nconst FILE_SUFFIX = \".plts\";\n\n/** Keeps a filename to characters every filesystem we target accepts. */\nfunction safe(part: string): string {\n return part.replace(/[^A-Za-z0-9_-]/g, \"_\");\n}\n\n/** Names this class writes: a finished snapshot, or the staging file of a write killed before\n * its rename. The directory is caller-supplied and only defaults to one of ours, so nothing\n * failing this is ever deleted, by the purge or by the startup eviction. */\nfunction isOurFile(name: string): boolean {\n if (!name.startsWith(FILE_PREFIX)) return false;\n return name.endsWith(FILE_SUFFIX) || name.includes(`${FILE_SUFFIX}.tmp.`);\n}\n\n/**\n * Snapshots of project tree mirrors on the local filesystem.\n *\n * A snapshot is addressed by backend instance, authenticated user, root resource, build stamp\n * and snapshot schema version. Everything except the root goes into the *scope*, which is\n * fixed for the lifetime of a client; the root distinguishes one project from another, so\n * there is one file per project per user per backend, rewritten in place.\n *\n * The session is deliberately not part of the key. It is witnessed inside the file and\n * compared on read: a snapshot from an ended session is a miss, but the file is kept, because\n * its bodies remain valid indefinitely and only its signatures have died. Deleting it would\n * destroy the evidence a future signature refresh would repair.\n */\nexport class TreeSnapshotStore {\n private readonly stat = initialStat();\n\n private constructor(\n private readonly ops: TreeSnapshotStoreOps,\n /** Identifies backend, user, build and schema. Same for every project in this session. */\n private readonly scope: string,\n ) {}\n\n /**\n * Builds a store for the given client, or returns undefined when nothing should be\n * persisted for it.\n *\n * Returns undefined for an impersonated client: reading and writing under `asUser` would\n * leave another user's mirror at rest under the admin's identity, usable only if the admin\n * returned to that exact root. One condition removes both the hygiene question and the\n * orphan one.\n */\n public static create(\n pl: PlClient,\n ops: TreeSnapshotStoreOps & { readonly enabled: boolean },\n ): TreeSnapshotStore | undefined {\n if (!ops.enabled) {\n ops.logger.info(\"tree snapshots are disabled by configuration\");\n return undefined;\n }\n if (pl.conf.asUser !== undefined) {\n ops.logger.info(\"tree snapshots are disabled while the client is opened as another user\");\n return undefined;\n }\n\n // The backend *instance*, not merely its address: instanceId rotates whenever the backend\n // resets its database, which is exactly the case where the same address starts serving a\n // different state under reused global ids. Without it, a reset at a fixed address (a local\n // backend, whose working directory does not move) would be a key hit, and the only thing\n // left to catch it would be the witness, which is empty on both sides on a backend that\n // predates resource signatures.\n //\n // Hashed rather than spelled out: a login can be an email and an address can carry\n // characters a filename cannot. The hash only has to be stable and to differ when any part\n // differs, both of which it does. The NUL separator keeps the parts unambiguous.\n const identity = createHash(\"sha256\")\n .update([pl.conf.hostAndPort, pl.serverInfo.instanceId ?? \"\", pl.authUser ?? \"\"].join(\"\\0\"))\n .digest(\"hex\")\n .slice(0, 16);\n\n const scope = `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`;\n return new TreeSnapshotStore(ops, scope);\n }\n\n /**\n * Removes this class's files from the snapshot directory. Run when snapshots are switched\n * off, so a user who turns the kill switch off because the disk is full or unwritable\n * actually gets the space back, rather than leaving up to the size ceiling stranded there\n * indefinitely.\n *\n * Deliberately keyed on the setting rather than on \"there is no store\": a store is also\n * absent for an impersonated client, and deleting there would destroy the operator's own\n * snapshots from their ordinary sessions. Never throws.\n */\n public static async purge(dir: string, logger: MiLogger): Promise<void> {\n try {\n // Deliberately NOT a recursive delete of `dir`. The path is caller-supplied and only\n // defaults to a directory of ours, so removing it wholesale would let a misconfigured\n // `treeSnapshotPath` take an unrelated directory with it, at the exact moment the user\n // reached for a switch labelled \"my disk is troublesome\". Only files this class writes\n // are removed, then the directory itself if that emptied it.\n for (const name of await fsp.readdir(dir)) {\n if (!isOurFile(name)) continue;\n await fsp.rm(path.join(dir, name), { force: true }).catch(() => {});\n }\n await fsp.rmdir(dir).catch(() => {\n // Still holds something that is not ours; leaving it is the point.\n });\n } catch (e: unknown) {\n if ((e as NodeJS.ErrnoException | null)?.code === \"ENOENT\") return;\n logger.warn(\n `failed to clear the tree snapshot directory: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n public getStats(): Readonly<TreeSnapshotStat> {\n return this.stat;\n }\n\n private fileFor(root: SignedResourceId): string {\n // The root's global id, not the signed form: the signature changes every session while\n // the file must not.\n const { globalId } = parseSignedResourceId(root);\n return path.join(this.ops.dir, `${FILE_PREFIX}${this.scope}.${globalId}${FILE_SUFFIX}`);\n }\n\n /**\n * Reads the snapshot for a project root, or reports why there is nothing to restore.\n *\n * `root` is the id as resolved in the current session. Its signature is what the stored\n * witness is compared against, so a rotated session is detected without inflating the\n * payload, and no separate session lookup is needed anywhere.\n */\n public async read(\n root: SignedResourceId,\n ): Promise<{ ok: true; tree: PersistedTree } | { ok: false; miss: TreeSnapshotMiss }> {\n const started = Date.now();\n this.stat.reads++;\n try {\n const file = this.fileFor(root);\n let bytes: Buffer;\n try {\n bytes = await fsp.readFile(file);\n } catch (e: unknown) {\n // A missing file and an unreadable one both mean a cold open, but they are different\n // problems: one is an ordinary first open, the other is a cache directory that needs\n // attention, and the counters are the only place that difference is visible.\n const absent = (e as NodeJS.ErrnoException | null)?.code === \"ENOENT\";\n if (!absent)\n this.ops.logger.warn(\n `tree snapshot exists but could not be read: ${e instanceof Error ? e.message : String(e)}`,\n );\n return this.miss(absent ? \"absent\" : \"unreadable\");\n }\n\n const header = readPersistedTreeHeader(bytes);\n if (!header.ok) return this.miss(header.reason);\n\n // On a backend predating resource signatures both sides are empty and always match,\n // which is right: without signatures the ids are not session-bound in the first place.\n const { signature } = parseSignedResourceId(root);\n if (!Buffer.from(header.value.witness).equals(Buffer.from(signature)))\n // Kept, not deleted. See the class comment.\n return this.miss(\"session-rotated\");\n\n const decoded = await decodePersistedTree(bytes);\n if (!decoded.ok) return this.miss(decoded.reason);\n\n // Touched on a hit so the modification time tracks last *use*, which is what the size\n // trim is supposed to order by. Without this, a project reopened every day but never\n // changed is never rewritten, and so ages out ahead of one touched once and abandoned.\n const now = new Date();\n await fsp.utimes(file, now, now).catch(() => {\n // Ordering the trim is not worth failing a hit over.\n });\n\n this.stat.hits++;\n return { ok: true, tree: decoded.value };\n } finally {\n this.stat.millisReading += Date.now() - started;\n }\n }\n\n private miss(miss: TreeSnapshotMiss): { ok: false; miss: TreeSnapshotMiss } {\n this.stat.misses[miss]++;\n return { ok: false, miss };\n }\n\n /** Recorded by the caller once it knows the tree accepted the snapshot. The store cannot\n * tell on its own: it hands over bytes, and whether they become a tree is the tree's call. */\n public noteRestored(): void {\n this.stat.restores++;\n }\n\n /**\n * Writes a snapshot, replacing any previous one for the same project.\n *\n * Never throws and never rejects: a write is an optimisation, and a full disk or a\n * permissions problem must not fail the operation that triggered it. Staged and renamed\n * into place, so a process killed mid-write leaves the previous snapshot rather than a torn\n * one.\n */\n public async write(\n root: SignedResourceId,\n snapshot: PersistedTree,\n ops: { compress?: boolean } = {},\n ): Promise<boolean> {\n const started = Date.now();\n try {\n const bytes = await encodePersistedTree(snapshot, { compress: ops.compress });\n await ensureDirExists(this.ops.dir);\n\n const file = this.fileFor(root);\n await createPathAtomically(this.ops.logger, file, async (tempPath) => {\n // \"wx\" so a colliding temp name fails instead of overwriting another writer's file.\n await fsp.writeFile(tempPath, bytes, { flag: \"wx\" });\n });\n\n this.stat.writes++;\n this.stat.bytesWritten += bytes.length;\n return true;\n } catch (e: unknown) {\n this.stat.writeFailures++;\n this.ops.logger.warn(\n `failed to write tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n return false;\n } finally {\n this.stat.millisWriting += Date.now() - started;\n }\n }\n\n /** Deletes the snapshot for a project. Used by the fail-safe, when a restored tree turns\n * out not to match what the backend will serve. Never throws. */\n public async discard(root: SignedResourceId): Promise<void> {\n try {\n await fsp.rm(this.fileFor(root), { force: true });\n this.stat.discarded++;\n } catch (e: unknown) {\n this.ops.logger.warn(\n `failed to discard tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n /**\n * Startup housekeeping. Drops every snapshot outside the current scope (another build,\n * backend, user or schema version), then trims what is left to the size ceiling, least\n * recently written first.\n *\n * Modification time stands in for recency of use: an open project is rewritten\n * periodically, so the file's age tracks how recently the project was worked on. Read times\n * would be a truer signal but atime is unreliable across platforms and mount options.\n *\n * Runs at startup only, so the ceiling bounds what a session starts with rather than capping\n * it throughout: a long session opening many projects can exceed it until the next launch.\n *\n * Never throws: an unusable cache directory should cost the cache, not the startup.\n */\n public async evict(): Promise<void> {\n try {\n await ensureDirExists(this.ops.dir);\n const names = await fsp.readdir(this.ops.dir);\n\n const current: { file: string; size: number; mtimeMs: number }[] = [];\n\n for (const name of names) {\n const file = path.join(this.ops.dir, name);\n\n // Anything of ours not addressed to the current scope goes: another build, backend,\n // user or schema version, and also the staging files of a write that was killed\n // before its rename, which end in `.tmp.<hex>` rather than the suffix.\n const inScope =\n name.startsWith(`${FILE_PREFIX}${this.scope}.`) && name.endsWith(FILE_SUFFIX);\n\n // Out of scope is not the same as ours to delete: a `treeSnapshotPath` pointed at an\n // existing or shared directory would otherwise have every file in it removed at\n // startup. Same rule as `purge`, for the same reason.\n if (!inScope && !isOurFile(name)) continue;\n\n let size = 0;\n let mtimeMs = 0;\n try {\n const stat = await fsp.stat(file);\n if (!stat.isFile()) continue;\n size = stat.size;\n mtimeMs = stat.mtimeMs;\n } catch {\n continue; // vanished under us, or unreadable: nothing to account for\n }\n\n if (inScope) {\n current.push({ file, size, mtimeMs });\n continue;\n }\n\n await this.remove(file, size, false);\n }\n\n const ceiling = this.ops.maxSizeBytes;\n let total = current.reduce((sum, e) => sum + e.size, 0);\n if (total <= ceiling) return;\n\n // Oldest first, so the projects a user is actually working on are the ones that survive.\n current.sort((a, b) => a.mtimeMs - b.mtimeMs);\n for (const entry of current) {\n if (total <= ceiling) break;\n // Only a file that actually went stops counting against the ceiling. Subtracting\n // regardless would let one undeletable file end the trim early and leave the directory\n // over its limit.\n if (await this.remove(entry.file, entry.size, true)) total -= entry.size;\n }\n } catch (e: unknown) {\n this.ops.logger.warn(\n `tree snapshot eviction failed: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n private async remove(file: string, size: number, forSize: boolean): Promise<boolean> {\n try {\n await fsp.rm(file, { force: true });\n this.stat.evicted++;\n this.stat.bytesEvicted += size;\n if (forSize) this.stat.evictedForSize++;\n return true;\n } catch {\n // A file we cannot delete is not worth failing startup over; it will be reconsidered\n // on the next one.\n return false;\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tree_snapshot_store.cjs","names":["createHash","PERSISTED_TREE_SCHEMA_VERSION","ML_BUILD_STAMP","fsp","path","parseSignedResourceId","readPersistedTreeHeader","decodePersistedTree","encodePersistedTree","ensureDirExists","createPathAtomically"],"sources":["../../src/middle_layer/tree_snapshot_store.ts"],"sourcesContent":["import type { PersistedTree, PersistedTreeReadFailure } from \"@milaboratories/pl-tree\";\nimport {\n decodePersistedTree,\n encodePersistedTree,\n PERSISTED_TREE_SCHEMA_VERSION,\n readPersistedTreeHeader,\n} from \"@milaboratories/pl-tree\";\nimport type { PlClient, SignedResourceId } from \"@milaboratories/pl-client\";\nimport { parseSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\nimport { createPathAtomically, ensureDirExists } from \"@milaboratories/ts-helpers\";\nimport { createHash } from \"node:crypto\";\nimport fsp from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { ML_BUILD_STAMP } from \"./build_stamp\";\n\n/** Why a read did not produce a tree to restore from. Counted rather than inferred, because\n * \"no snapshot\" and \"a snapshot we refused\" are very different things when a warm reopen\n * fails to be warm and someone has to work out why. */\nexport type TreeSnapshotMiss =\n /** No file for this key: a first open, or the key moved (new build, new backend, new user). */\n | \"absent\"\n /** A file is there but could not be opened at all: permissions, a bad mount, an I/O error.\n * Distinct from `absent` because a first open and a broken cache directory need different\n * answers from whoever reads the counters. */\n | \"unreadable\"\n /** File exists, but its signatures belong to a session that has ended. Kept, not deleted. */\n | \"session-rotated\"\n /** File exists and could not be read. Carries the codec's reason. */\n | PersistedTreeReadFailure;\n\nexport type TreeSnapshotStat = {\n reads: number;\n /** Snapshots read successfully. A hit is not yet a warm open: the tree can still refuse to\n * apply it, which is what {@link restores} counts. */\n hits: number;\n /** Snapshots actually applied as a tree's initial state. This is the number that says a\n * reopen was warm. */\n restores: number;\n /** Miss counts by reason. */\n misses: Record<TreeSnapshotMiss, number>;\n writes: number;\n writeFailures: number;\n bytesWritten: number;\n /** Snapshots deleted by the fail-safe after a restored tree failed its first refresh. */\n discarded: number;\n /** Files removed at startup, and how many of those were dropped for being over the ceiling\n * rather than for belonging to another build, backend or user. */\n evicted: number;\n evictedForSize: number;\n bytesEvicted: number;\n millisReading: number;\n millisWriting: number;\n};\n\nfunction initialStat(): TreeSnapshotStat {\n return {\n reads: 0,\n hits: 0,\n restores: 0,\n misses: {\n absent: 0,\n unreadable: 0,\n \"session-rotated\": 0,\n \"not-a-snapshot\": 0,\n \"unknown-schema\": 0,\n truncated: 0,\n checksum: 0,\n malformed: 0,\n },\n writes: 0,\n writeFailures: 0,\n bytesWritten: 0,\n discarded: 0,\n evicted: 0,\n evictedForSize: 0,\n bytesEvicted: 0,\n millisReading: 0,\n millisWriting: 0,\n };\n}\n\nexport type TreeSnapshotStoreOps = {\n /** Directory holding the snapshots. One file per project. */\n readonly dir: string;\n /** Total bytes the directory may occupy after startup eviction. */\n readonly maxSizeBytes: number;\n readonly logger: MiLogger;\n};\n\nconst FILE_PREFIX = \"tree.\";\nconst FILE_SUFFIX = \".plts\";\n\n/** Keeps a filename to characters every filesystem we target accepts. */\nfunction safe(part: string): string {\n return part.replace(/[^A-Za-z0-9_-]/g, \"_\");\n}\n\n/** Names this class writes: a finished snapshot, or the staging file of a write killed before\n * its rename. The directory is caller-supplied and only defaults to one of ours, so nothing\n * failing this is ever deleted, by the purge or by the startup eviction. */\nfunction isOurFile(name: string): boolean {\n if (!name.startsWith(FILE_PREFIX)) return false;\n return name.endsWith(FILE_SUFFIX) || name.includes(`${FILE_SUFFIX}.tmp.`);\n}\n\n/**\n * Snapshots of project tree mirrors on the local filesystem.\n *\n * A snapshot is addressed by backend instance, authenticated user, root resource, build stamp\n * and snapshot schema version. Everything except the root goes into the *scope*, which is\n * fixed for the lifetime of a client; the root distinguishes one project from another, so\n * there is one file per project per user per backend, rewritten in place.\n *\n * The session is deliberately not part of the key. It is witnessed inside the file and\n * compared on read: a snapshot from an ended session is a miss, but the file is kept, because\n * its bodies remain valid indefinitely and only its signatures have died. Deleting it would\n * destroy the evidence a future signature refresh would repair.\n */\nexport class TreeSnapshotStore {\n private readonly stat = initialStat();\n\n private constructor(\n private readonly ops: TreeSnapshotStoreOps,\n /** Identifies backend, user, build and schema. Same for every project in this session. */\n private readonly scope: string,\n ) {}\n\n /**\n * Builds a store for the given client, or returns undefined when nothing should be\n * persisted for it.\n *\n * Returns undefined for an impersonated client: reading and writing under `asUser` would\n * leave another user's mirror at rest under the admin's identity, usable only if the admin\n * returned to that exact root. One condition removes both the hygiene question and the\n * orphan one.\n */\n public static create(\n pl: PlClient,\n ops: TreeSnapshotStoreOps & { readonly enabled: boolean },\n ): TreeSnapshotStore | undefined {\n if (!ops.enabled) {\n ops.logger.info(\"tree snapshots are disabled by configuration\");\n return undefined;\n }\n if (pl.conf.asUser !== undefined) {\n ops.logger.info(\"tree snapshots are disabled while the client is opened as another user\");\n return undefined;\n }\n\n // The backend *instance*, not merely its address: instanceId rotates whenever the backend\n // resets its database, which is exactly the case where the same address starts serving a\n // different state under reused global ids. Without it, a reset at a fixed address (a local\n // backend, whose working directory does not move) would be a key hit, and the only thing\n // left to catch it would be the witness, which is empty on both sides on a backend that\n // predates resource signatures.\n //\n // Hashed rather than spelled out: a login can be an email and an address can carry\n // characters a filename cannot. The hash only has to be stable and to differ when any part\n // differs, both of which it does. The NUL separator keeps the parts unambiguous.\n const identity = createHash(\"sha256\")\n .update([pl.conf.hostAndPort, pl.serverInfo.instanceId ?? \"\", pl.authUser ?? \"\"].join(\"\\0\"))\n .digest(\"hex\")\n .slice(0, 16);\n\n const scope = `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`;\n return new TreeSnapshotStore(ops, scope);\n }\n\n /**\n * Removes this class's files from the snapshot directory. Run when snapshots are switched\n * off, so a user who turns the kill switch off because the disk is full or unwritable\n * actually gets the space back, rather than leaving up to the size ceiling stranded there\n * indefinitely.\n *\n * Deliberately keyed on the setting rather than on \"there is no store\": a store is also\n * absent for an impersonated client, and deleting there would destroy the operator's own\n * snapshots from their ordinary sessions. Never throws.\n */\n public static async purge(dir: string, logger: MiLogger): Promise<void> {\n try {\n // Deliberately NOT a recursive delete of `dir`. The path is caller-supplied and only\n // defaults to a directory of ours, so removing it wholesale would let a misconfigured\n // `treeSnapshotPath` take an unrelated directory with it, at the exact moment the user\n // reached for a switch labelled \"my disk is troublesome\". Only files this class writes\n // are removed, then the directory itself if that emptied it.\n for (const name of await fsp.readdir(dir)) {\n if (!isOurFile(name)) continue;\n await fsp.rm(path.join(dir, name), { force: true }).catch(() => {});\n }\n await fsp.rmdir(dir).catch(() => {\n // Still holds something that is not ours; leaving it is the point.\n });\n } catch (e: unknown) {\n if ((e as NodeJS.ErrnoException | null)?.code === \"ENOENT\") return;\n logger.warn(\n `failed to clear the tree snapshot directory: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n public getStats(): Readonly<TreeSnapshotStat> {\n return this.stat;\n }\n\n private fileFor(root: SignedResourceId): string {\n // The root's global id, not the signed form: the signature changes every session while\n // the file must not.\n const { globalId } = parseSignedResourceId(root);\n return path.join(this.ops.dir, `${FILE_PREFIX}${this.scope}.${globalId}${FILE_SUFFIX}`);\n }\n\n /**\n * Reads the snapshot for a project root, or reports why there is nothing to restore.\n *\n * `root` is the id as resolved in the current session. Its signature is what the stored\n * witness is compared against, so a rotated session is detected without inflating the\n * payload, and no separate session lookup is needed anywhere.\n */\n public async read(\n root: SignedResourceId,\n ): Promise<{ ok: true; tree: PersistedTree } | { ok: false; miss: TreeSnapshotMiss }> {\n const started = Date.now();\n this.stat.reads++;\n try {\n const file = this.fileFor(root);\n let bytes: Buffer;\n try {\n bytes = await fsp.readFile(file);\n } catch (e: unknown) {\n // A missing file and an unreadable one both mean a cold open, but they are different\n // problems: one is an ordinary first open, the other is a cache directory that needs\n // attention, and the counters are the only place that difference is visible.\n const absent = (e as NodeJS.ErrnoException | null)?.code === \"ENOENT\";\n if (!absent)\n this.ops.logger.warn(\n `tree snapshot exists but could not be read: ${e instanceof Error ? e.message : String(e)}`,\n );\n return this.miss(absent ? \"absent\" : \"unreadable\");\n }\n\n const header = readPersistedTreeHeader(bytes);\n if (!header.ok) return this.miss(header.reason);\n\n // On a backend predating resource signatures both sides are empty and always match,\n // which is right: without signatures the ids are not session-bound in the first place.\n const { signature } = parseSignedResourceId(root);\n if (!Buffer.from(header.value.witness).equals(Buffer.from(signature)))\n // Kept, not deleted. See the class comment.\n return this.miss(\"session-rotated\");\n\n const decoded = await decodePersistedTree(bytes);\n if (!decoded.ok) return this.miss(decoded.reason);\n\n // Touched on a hit so the modification time tracks last *use*, which is what the size\n // trim is supposed to order by. Without this, a project reopened every day but never\n // changed is never rewritten, and so ages out ahead of one touched once and abandoned.\n const now = new Date();\n await fsp.utimes(file, now, now).catch(() => {\n // Ordering the trim is not worth failing a hit over.\n });\n\n this.stat.hits++;\n return { ok: true, tree: decoded.value };\n } finally {\n this.stat.millisReading += Date.now() - started;\n }\n }\n\n private miss(miss: TreeSnapshotMiss): { ok: false; miss: TreeSnapshotMiss } {\n this.stat.misses[miss]++;\n return { ok: false, miss };\n }\n\n /** Recorded by the caller once it knows the tree accepted the snapshot. The store cannot\n * tell on its own: it hands over bytes, and whether they become a tree is the tree's call. */\n public noteRestored(): void {\n this.stat.restores++;\n }\n\n /**\n * Writes a snapshot, replacing any previous one for the same project.\n *\n * Never throws and never rejects: a write is an optimisation, and a full disk or a\n * permissions problem must not fail the operation that triggered it. Staged and renamed\n * into place, so a process killed mid-write leaves the previous snapshot rather than a torn\n * one.\n */\n public async write(\n root: SignedResourceId,\n snapshot: PersistedTree,\n ops: { compress?: boolean } = {},\n ): Promise<boolean> {\n const started = Date.now();\n try {\n const bytes = await encodePersistedTree(snapshot, { compress: ops.compress });\n await ensureDirExists(this.ops.dir);\n\n const file = this.fileFor(root);\n await createPathAtomically(this.ops.logger, file, async (tempPath) => {\n // \"wx\" so a colliding temp name fails instead of overwriting another writer's file.\n await fsp.writeFile(tempPath, bytes, { flag: \"wx\" });\n });\n\n this.stat.writes++;\n this.stat.bytesWritten += bytes.length;\n return true;\n } catch (e: unknown) {\n this.stat.writeFailures++;\n this.ops.logger.warn(\n `failed to write tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n return false;\n } finally {\n this.stat.millisWriting += Date.now() - started;\n }\n }\n\n /** Deletes the snapshot for a project. Used by the fail-safe, when a restored tree turns\n * out not to match what the backend will serve. Never throws. */\n public async discard(root: SignedResourceId): Promise<void> {\n try {\n await fsp.rm(this.fileFor(root), { force: true });\n this.stat.discarded++;\n } catch (e: unknown) {\n this.ops.logger.warn(\n `failed to discard tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n /**\n * Startup housekeeping. Drops every snapshot outside the current scope (another build,\n * backend, user or schema version), then trims what is left to the size ceiling, least\n * recently written first.\n *\n * Modification time stands in for recency of use: an open project is rewritten\n * periodically, so the file's age tracks how recently the project was worked on. Read times\n * would be a truer signal but atime is unreliable across platforms and mount options.\n *\n * Runs at startup only, so the ceiling bounds what a session starts with rather than capping\n * it throughout: a long session opening many projects can exceed it until the next launch.\n *\n * Never throws: an unusable cache directory should cost the cache, not the startup.\n */\n public async evict(): Promise<void> {\n try {\n await ensureDirExists(this.ops.dir);\n const names = await fsp.readdir(this.ops.dir);\n\n const current: { file: string; size: number; mtimeMs: number }[] = [];\n\n for (const name of names) {\n const file = path.join(this.ops.dir, name);\n\n // Anything of ours not addressed to the current scope goes: another build, backend,\n // user or schema version, and also the staging files of a write that was killed\n // before its rename, which end in `.tmp.<hex>` rather than the suffix.\n const inScope =\n name.startsWith(`${FILE_PREFIX}${this.scope}.`) && name.endsWith(FILE_SUFFIX);\n\n // Out of scope is not the same as ours to delete: a `treeSnapshotPath` pointed at an\n // existing or shared directory would otherwise have every file in it removed at\n // startup. Same rule as `purge`, for the same reason.\n if (!inScope && !isOurFile(name)) continue;\n\n let size = 0;\n let mtimeMs = 0;\n try {\n const stat = await fsp.stat(file);\n if (!stat.isFile()) continue;\n size = stat.size;\n mtimeMs = stat.mtimeMs;\n } catch {\n continue; // vanished under us, or unreadable: nothing to account for\n }\n\n if (inScope) {\n current.push({ file, size, mtimeMs });\n continue;\n }\n\n await this.remove(file, size, false);\n }\n\n const ceiling = this.ops.maxSizeBytes;\n let total = current.reduce((sum, e) => sum + e.size, 0);\n if (total <= ceiling) return;\n\n // Oldest first, so the projects a user is actually working on are the ones that survive.\n current.sort((a, b) => a.mtimeMs - b.mtimeMs);\n for (const entry of current) {\n if (total <= ceiling) break;\n // Only a file that actually went stops counting against the ceiling. Subtracting\n // regardless would let one undeletable file end the trim early and leave the directory\n // over its limit.\n if (await this.remove(entry.file, entry.size, true)) total -= entry.size;\n }\n } catch (e: unknown) {\n this.ops.logger.warn(\n `tree snapshot eviction failed: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n private async remove(file: string, size: number, forSize: boolean): Promise<boolean> {\n try {\n await fsp.rm(file, { force: true });\n this.stat.evicted++;\n this.stat.bytesEvicted += size;\n if (forSize) this.stat.evictedForSize++;\n return true;\n } catch {\n // A file we cannot delete is not worth failing startup over; it will be reconsidered\n // on the next one.\n return false;\n }\n }\n}\n"],"mappings":"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"}
@@ -1,7 +1,6 @@
1
1
  import { MiLogger } from "@milaboratories/ts-helpers";
2
2
  import { PlClient, SignedResourceId } from "@milaboratories/pl-client";
3
3
  import { PersistedTree, PersistedTreeReadFailure } from "@milaboratories/pl-tree";
4
- //#region src/middle_layer/tree_snapshot_store.d.ts
5
4
  /** Why a read did not produce a tree to restore from. Counted rather than inferred, because
6
5
  * "no snapshot" and "a snapshot we refused" are very different things when a warm reopen
7
6
  * fails to be warm and someone has to work out why. */
@@ -139,5 +138,4 @@ export declare class TreeSnapshotStore {
139
138
  evict(): Promise<void>;
140
139
  private remove;
141
140
  }
142
- //#endregion
143
141
  //# sourceMappingURL=tree_snapshot_store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tree_snapshot_store.d.ts","names":[],"sources":["../../src/middle_layer/tree_snapshot_store.ts"],"mappings":";;;;;;;YAmBY;;;;;;;;;;AAUR;YAEQ;EACV;;;EAGA;;;EAGA;;EAEA,QAAQ,OAAO;EACf;EACA;EACA;;EAEA;;;EAGA;EACA;EACA;EACA;EACA;;YA8BU;;WAED;;WAEA;WACA,QAAQ;;;;;;;;;;;;;;;qBAgCN;mBAIQ;;mBAEA;mBALF;UAEV;;;;;;;;;;SAeO,OACZ,IAAI,UACJ,KAAK;aAAkC;MACtC;;;;;;;;;;;SAuCiB,MAAM,aAAa,QAAQ,WAAW;EAsBnD,YAAY,SAAS;UAIpB;;;;;;;;EAcK,KACX,MAAM,mBACL;IAAU;IAAU,MAAM;;IAAoB;IAAW,MAAM;;UAgD1D;;;EAOD;;;;;;;;;EAYM,MACX,MAAM,kBACN,UAAU,eACV;IAAO;MACN;;;EA4BU,QAAQ,MAAM,mBAAmB;;;;;;;;;;;;;;;EAyBjC,SAAS;UA4DR"}
1
+ {"version":3,"file":"tree_snapshot_store.d.ts","names":[],"sources":["../../src/middle_layer/tree_snapshot_store.ts"],"mappings":";;;;;;YAmBY;;;;;;;;;;AAUR;YAEQ;EACV;;;EAGA;;;EAGA;;EAEA,QAAQ,OAAO;EACf;EACA;EACA;;EAEA;;;EAGA;EACA;EACA;EACA;EACA;;YA8BU;;WAED;;WAEA;WACA,QAAQ;;;;;;;;;;;;;;;qBAgCN;mBAIQ;;mBAEA;mBALF;UAEV;;;;;;;;;;SAeO,OACZ,IAAI,UACJ,KAAK;aAAkC;MACtC;;;;;;;;;;;SAuCiB,MAAM,aAAa,QAAQ,WAAW;EAsBnD,YAAY,SAAS;UAIpB;;;;;;;;EAcK,KACX,MAAM,mBACL;IAAU;IAAU,MAAM;;IAAoB;IAAW,MAAM;;UAgD1D;;;EAOD;;;;;;;;;EAYM,MACX,MAAM,kBACN,UAAU,eACV;IAAO;MACN;;;EA4BU,QAAQ,MAAM,mBAAmB;;;;;;;;;;;;;;;EAyBjC,SAAS;UA4DR"}
@@ -5,7 +5,7 @@ import fsp from "node:fs/promises";
5
5
  import { parseSignedResourceId } from "@milaboratories/pl-client";
6
6
  import { PERSISTED_TREE_SCHEMA_VERSION, decodePersistedTree, encodePersistedTree, readPersistedTreeHeader } from "@milaboratories/pl-tree";
7
7
  import { createHash } from "node:crypto";
8
- //#region src/middle_layer/tree_snapshot_store.ts
8
+ // src/middle_layer/tree_snapshot_store.ts
9
9
  function initialStat() {
10
10
  return {
11
11
  reads: 0,
@@ -89,7 +89,8 @@ var TreeSnapshotStore = class TreeSnapshotStore {
89
89
  pl.serverInfo.instanceId ?? "",
90
90
  pl.authUser ?? ""
91
91
  ].join("\0")).digest("hex").slice(0, 16);
92
- return new TreeSnapshotStore(ops, `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`);
92
+ const scope = `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`;
93
+ return new TreeSnapshotStore(ops, scope);
93
94
  }
94
95
  /**
95
96
  * Removes this class's files from the snapshot directory. Run when snapshots are switched
@@ -274,7 +275,6 @@ var TreeSnapshotStore = class TreeSnapshotStore {
274
275
  }
275
276
  }
276
277
  };
277
- //#endregion
278
278
  export { TreeSnapshotStore };
279
279
 
280
280
  //# sourceMappingURL=tree_snapshot_store.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"tree_snapshot_store.js","names":[],"sources":["../../src/middle_layer/tree_snapshot_store.ts"],"sourcesContent":["import type { PersistedTree, PersistedTreeReadFailure } from \"@milaboratories/pl-tree\";\nimport {\n decodePersistedTree,\n encodePersistedTree,\n PERSISTED_TREE_SCHEMA_VERSION,\n readPersistedTreeHeader,\n} from \"@milaboratories/pl-tree\";\nimport type { PlClient, SignedResourceId } from \"@milaboratories/pl-client\";\nimport { parseSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\nimport { createPathAtomically, ensureDirExists } from \"@milaboratories/ts-helpers\";\nimport { createHash } from \"node:crypto\";\nimport fsp from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { ML_BUILD_STAMP } from \"./build_stamp\";\n\n/** Why a read did not produce a tree to restore from. Counted rather than inferred, because\n * \"no snapshot\" and \"a snapshot we refused\" are very different things when a warm reopen\n * fails to be warm and someone has to work out why. */\nexport type TreeSnapshotMiss =\n /** No file for this key: a first open, or the key moved (new build, new backend, new user). */\n | \"absent\"\n /** A file is there but could not be opened at all: permissions, a bad mount, an I/O error.\n * Distinct from `absent` because a first open and a broken cache directory need different\n * answers from whoever reads the counters. */\n | \"unreadable\"\n /** File exists, but its signatures belong to a session that has ended. Kept, not deleted. */\n | \"session-rotated\"\n /** File exists and could not be read. Carries the codec's reason. */\n | PersistedTreeReadFailure;\n\nexport type TreeSnapshotStat = {\n reads: number;\n /** Snapshots read successfully. A hit is not yet a warm open: the tree can still refuse to\n * apply it, which is what {@link restores} counts. */\n hits: number;\n /** Snapshots actually applied as a tree's initial state. This is the number that says a\n * reopen was warm. */\n restores: number;\n /** Miss counts by reason. */\n misses: Record<TreeSnapshotMiss, number>;\n writes: number;\n writeFailures: number;\n bytesWritten: number;\n /** Snapshots deleted by the fail-safe after a restored tree failed its first refresh. */\n discarded: number;\n /** Files removed at startup, and how many of those were dropped for being over the ceiling\n * rather than for belonging to another build, backend or user. */\n evicted: number;\n evictedForSize: number;\n bytesEvicted: number;\n millisReading: number;\n millisWriting: number;\n};\n\nfunction initialStat(): TreeSnapshotStat {\n return {\n reads: 0,\n hits: 0,\n restores: 0,\n misses: {\n absent: 0,\n unreadable: 0,\n \"session-rotated\": 0,\n \"not-a-snapshot\": 0,\n \"unknown-schema\": 0,\n truncated: 0,\n checksum: 0,\n malformed: 0,\n },\n writes: 0,\n writeFailures: 0,\n bytesWritten: 0,\n discarded: 0,\n evicted: 0,\n evictedForSize: 0,\n bytesEvicted: 0,\n millisReading: 0,\n millisWriting: 0,\n };\n}\n\nexport type TreeSnapshotStoreOps = {\n /** Directory holding the snapshots. One file per project. */\n readonly dir: string;\n /** Total bytes the directory may occupy after startup eviction. */\n readonly maxSizeBytes: number;\n readonly logger: MiLogger;\n};\n\nconst FILE_PREFIX = \"tree.\";\nconst FILE_SUFFIX = \".plts\";\n\n/** Keeps a filename to characters every filesystem we target accepts. */\nfunction safe(part: string): string {\n return part.replace(/[^A-Za-z0-9_-]/g, \"_\");\n}\n\n/** Names this class writes: a finished snapshot, or the staging file of a write killed before\n * its rename. The directory is caller-supplied and only defaults to one of ours, so nothing\n * failing this is ever deleted, by the purge or by the startup eviction. */\nfunction isOurFile(name: string): boolean {\n if (!name.startsWith(FILE_PREFIX)) return false;\n return name.endsWith(FILE_SUFFIX) || name.includes(`${FILE_SUFFIX}.tmp.`);\n}\n\n/**\n * Snapshots of project tree mirrors on the local filesystem.\n *\n * A snapshot is addressed by backend instance, authenticated user, root resource, build stamp\n * and snapshot schema version. Everything except the root goes into the *scope*, which is\n * fixed for the lifetime of a client; the root distinguishes one project from another, so\n * there is one file per project per user per backend, rewritten in place.\n *\n * The session is deliberately not part of the key. It is witnessed inside the file and\n * compared on read: a snapshot from an ended session is a miss, but the file is kept, because\n * its bodies remain valid indefinitely and only its signatures have died. Deleting it would\n * destroy the evidence a future signature refresh would repair.\n */\nexport class TreeSnapshotStore {\n private readonly stat = initialStat();\n\n private constructor(\n private readonly ops: TreeSnapshotStoreOps,\n /** Identifies backend, user, build and schema. Same for every project in this session. */\n private readonly scope: string,\n ) {}\n\n /**\n * Builds a store for the given client, or returns undefined when nothing should be\n * persisted for it.\n *\n * Returns undefined for an impersonated client: reading and writing under `asUser` would\n * leave another user's mirror at rest under the admin's identity, usable only if the admin\n * returned to that exact root. One condition removes both the hygiene question and the\n * orphan one.\n */\n public static create(\n pl: PlClient,\n ops: TreeSnapshotStoreOps & { readonly enabled: boolean },\n ): TreeSnapshotStore | undefined {\n if (!ops.enabled) {\n ops.logger.info(\"tree snapshots are disabled by configuration\");\n return undefined;\n }\n if (pl.conf.asUser !== undefined) {\n ops.logger.info(\"tree snapshots are disabled while the client is opened as another user\");\n return undefined;\n }\n\n // The backend *instance*, not merely its address: instanceId rotates whenever the backend\n // resets its database, which is exactly the case where the same address starts serving a\n // different state under reused global ids. Without it, a reset at a fixed address (a local\n // backend, whose working directory does not move) would be a key hit, and the only thing\n // left to catch it would be the witness, which is empty on both sides on a backend that\n // predates resource signatures.\n //\n // Hashed rather than spelled out: a login can be an email and an address can carry\n // characters a filename cannot. The hash only has to be stable and to differ when any part\n // differs, both of which it does. The NUL separator keeps the parts unambiguous.\n const identity = createHash(\"sha256\")\n .update([pl.conf.hostAndPort, pl.serverInfo.instanceId ?? \"\", pl.authUser ?? \"\"].join(\"\\0\"))\n .digest(\"hex\")\n .slice(0, 16);\n\n const scope = `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`;\n return new TreeSnapshotStore(ops, scope);\n }\n\n /**\n * Removes this class's files from the snapshot directory. Run when snapshots are switched\n * off, so a user who turns the kill switch off because the disk is full or unwritable\n * actually gets the space back, rather than leaving up to the size ceiling stranded there\n * indefinitely.\n *\n * Deliberately keyed on the setting rather than on \"there is no store\": a store is also\n * absent for an impersonated client, and deleting there would destroy the operator's own\n * snapshots from their ordinary sessions. Never throws.\n */\n public static async purge(dir: string, logger: MiLogger): Promise<void> {\n try {\n // Deliberately NOT a recursive delete of `dir`. The path is caller-supplied and only\n // defaults to a directory of ours, so removing it wholesale would let a misconfigured\n // `treeSnapshotPath` take an unrelated directory with it, at the exact moment the user\n // reached for a switch labelled \"my disk is troublesome\". Only files this class writes\n // are removed, then the directory itself if that emptied it.\n for (const name of await fsp.readdir(dir)) {\n if (!isOurFile(name)) continue;\n await fsp.rm(path.join(dir, name), { force: true }).catch(() => {});\n }\n await fsp.rmdir(dir).catch(() => {\n // Still holds something that is not ours; leaving it is the point.\n });\n } catch (e: unknown) {\n if ((e as NodeJS.ErrnoException | null)?.code === \"ENOENT\") return;\n logger.warn(\n `failed to clear the tree snapshot directory: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n public getStats(): Readonly<TreeSnapshotStat> {\n return this.stat;\n }\n\n private fileFor(root: SignedResourceId): string {\n // The root's global id, not the signed form: the signature changes every session while\n // the file must not.\n const { globalId } = parseSignedResourceId(root);\n return path.join(this.ops.dir, `${FILE_PREFIX}${this.scope}.${globalId}${FILE_SUFFIX}`);\n }\n\n /**\n * Reads the snapshot for a project root, or reports why there is nothing to restore.\n *\n * `root` is the id as resolved in the current session. Its signature is what the stored\n * witness is compared against, so a rotated session is detected without inflating the\n * payload, and no separate session lookup is needed anywhere.\n */\n public async read(\n root: SignedResourceId,\n ): Promise<{ ok: true; tree: PersistedTree } | { ok: false; miss: TreeSnapshotMiss }> {\n const started = Date.now();\n this.stat.reads++;\n try {\n const file = this.fileFor(root);\n let bytes: Buffer;\n try {\n bytes = await fsp.readFile(file);\n } catch (e: unknown) {\n // A missing file and an unreadable one both mean a cold open, but they are different\n // problems: one is an ordinary first open, the other is a cache directory that needs\n // attention, and the counters are the only place that difference is visible.\n const absent = (e as NodeJS.ErrnoException | null)?.code === \"ENOENT\";\n if (!absent)\n this.ops.logger.warn(\n `tree snapshot exists but could not be read: ${e instanceof Error ? e.message : String(e)}`,\n );\n return this.miss(absent ? \"absent\" : \"unreadable\");\n }\n\n const header = readPersistedTreeHeader(bytes);\n if (!header.ok) return this.miss(header.reason);\n\n // On a backend predating resource signatures both sides are empty and always match,\n // which is right: without signatures the ids are not session-bound in the first place.\n const { signature } = parseSignedResourceId(root);\n if (!Buffer.from(header.value.witness).equals(Buffer.from(signature)))\n // Kept, not deleted. See the class comment.\n return this.miss(\"session-rotated\");\n\n const decoded = await decodePersistedTree(bytes);\n if (!decoded.ok) return this.miss(decoded.reason);\n\n // Touched on a hit so the modification time tracks last *use*, which is what the size\n // trim is supposed to order by. Without this, a project reopened every day but never\n // changed is never rewritten, and so ages out ahead of one touched once and abandoned.\n const now = new Date();\n await fsp.utimes(file, now, now).catch(() => {\n // Ordering the trim is not worth failing a hit over.\n });\n\n this.stat.hits++;\n return { ok: true, tree: decoded.value };\n } finally {\n this.stat.millisReading += Date.now() - started;\n }\n }\n\n private miss(miss: TreeSnapshotMiss): { ok: false; miss: TreeSnapshotMiss } {\n this.stat.misses[miss]++;\n return { ok: false, miss };\n }\n\n /** Recorded by the caller once it knows the tree accepted the snapshot. The store cannot\n * tell on its own: it hands over bytes, and whether they become a tree is the tree's call. */\n public noteRestored(): void {\n this.stat.restores++;\n }\n\n /**\n * Writes a snapshot, replacing any previous one for the same project.\n *\n * Never throws and never rejects: a write is an optimisation, and a full disk or a\n * permissions problem must not fail the operation that triggered it. Staged and renamed\n * into place, so a process killed mid-write leaves the previous snapshot rather than a torn\n * one.\n */\n public async write(\n root: SignedResourceId,\n snapshot: PersistedTree,\n ops: { compress?: boolean } = {},\n ): Promise<boolean> {\n const started = Date.now();\n try {\n const bytes = await encodePersistedTree(snapshot, { compress: ops.compress });\n await ensureDirExists(this.ops.dir);\n\n const file = this.fileFor(root);\n await createPathAtomically(this.ops.logger, file, async (tempPath) => {\n // \"wx\" so a colliding temp name fails instead of overwriting another writer's file.\n await fsp.writeFile(tempPath, bytes, { flag: \"wx\" });\n });\n\n this.stat.writes++;\n this.stat.bytesWritten += bytes.length;\n return true;\n } catch (e: unknown) {\n this.stat.writeFailures++;\n this.ops.logger.warn(\n `failed to write tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n return false;\n } finally {\n this.stat.millisWriting += Date.now() - started;\n }\n }\n\n /** Deletes the snapshot for a project. Used by the fail-safe, when a restored tree turns\n * out not to match what the backend will serve. Never throws. */\n public async discard(root: SignedResourceId): Promise<void> {\n try {\n await fsp.rm(this.fileFor(root), { force: true });\n this.stat.discarded++;\n } catch (e: unknown) {\n this.ops.logger.warn(\n `failed to discard tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n /**\n * Startup housekeeping. Drops every snapshot outside the current scope (another build,\n * backend, user or schema version), then trims what is left to the size ceiling, least\n * recently written first.\n *\n * Modification time stands in for recency of use: an open project is rewritten\n * periodically, so the file's age tracks how recently the project was worked on. Read times\n * would be a truer signal but atime is unreliable across platforms and mount options.\n *\n * Runs at startup only, so the ceiling bounds what a session starts with rather than capping\n * it throughout: a long session opening many projects can exceed it until the next launch.\n *\n * Never throws: an unusable cache directory should cost the cache, not the startup.\n */\n public async evict(): Promise<void> {\n try {\n await ensureDirExists(this.ops.dir);\n const names = await fsp.readdir(this.ops.dir);\n\n const current: { file: string; size: number; mtimeMs: number }[] = [];\n\n for (const name of names) {\n const file = path.join(this.ops.dir, name);\n\n // Anything of ours not addressed to the current scope goes: another build, backend,\n // user or schema version, and also the staging files of a write that was killed\n // before its rename, which end in `.tmp.<hex>` rather than the suffix.\n const inScope =\n name.startsWith(`${FILE_PREFIX}${this.scope}.`) && name.endsWith(FILE_SUFFIX);\n\n // Out of scope is not the same as ours to delete: a `treeSnapshotPath` pointed at an\n // existing or shared directory would otherwise have every file in it removed at\n // startup. Same rule as `purge`, for the same reason.\n if (!inScope && !isOurFile(name)) continue;\n\n let size = 0;\n let mtimeMs = 0;\n try {\n const stat = await fsp.stat(file);\n if (!stat.isFile()) continue;\n size = stat.size;\n mtimeMs = stat.mtimeMs;\n } catch {\n continue; // vanished under us, or unreadable: nothing to account for\n }\n\n if (inScope) {\n current.push({ file, size, mtimeMs });\n continue;\n }\n\n await this.remove(file, size, false);\n }\n\n const ceiling = this.ops.maxSizeBytes;\n let total = current.reduce((sum, e) => sum + e.size, 0);\n if (total <= ceiling) return;\n\n // Oldest first, so the projects a user is actually working on are the ones that survive.\n current.sort((a, b) => a.mtimeMs - b.mtimeMs);\n for (const entry of current) {\n if (total <= ceiling) break;\n // Only a file that actually went stops counting against the ceiling. Subtracting\n // regardless would let one undeletable file end the trim early and leave the directory\n // over its limit.\n if (await this.remove(entry.file, entry.size, true)) total -= entry.size;\n }\n } catch (e: unknown) {\n this.ops.logger.warn(\n `tree snapshot eviction failed: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n private async remove(file: string, size: number, forSize: boolean): Promise<boolean> {\n try {\n await fsp.rm(file, { force: true });\n this.stat.evicted++;\n this.stat.bytesEvicted += size;\n if (forSize) this.stat.evictedForSize++;\n return true;\n } catch {\n // A file we cannot delete is not worth failing startup over; it will be reconsidered\n // on the next one.\n return false;\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tree_snapshot_store.js","names":[],"sources":["../../src/middle_layer/tree_snapshot_store.ts"],"sourcesContent":["import type { PersistedTree, PersistedTreeReadFailure } from \"@milaboratories/pl-tree\";\nimport {\n decodePersistedTree,\n encodePersistedTree,\n PERSISTED_TREE_SCHEMA_VERSION,\n readPersistedTreeHeader,\n} from \"@milaboratories/pl-tree\";\nimport type { PlClient, SignedResourceId } from \"@milaboratories/pl-client\";\nimport { parseSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\nimport { createPathAtomically, ensureDirExists } from \"@milaboratories/ts-helpers\";\nimport { createHash } from \"node:crypto\";\nimport fsp from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { ML_BUILD_STAMP } from \"./build_stamp\";\n\n/** Why a read did not produce a tree to restore from. Counted rather than inferred, because\n * \"no snapshot\" and \"a snapshot we refused\" are very different things when a warm reopen\n * fails to be warm and someone has to work out why. */\nexport type TreeSnapshotMiss =\n /** No file for this key: a first open, or the key moved (new build, new backend, new user). */\n | \"absent\"\n /** A file is there but could not be opened at all: permissions, a bad mount, an I/O error.\n * Distinct from `absent` because a first open and a broken cache directory need different\n * answers from whoever reads the counters. */\n | \"unreadable\"\n /** File exists, but its signatures belong to a session that has ended. Kept, not deleted. */\n | \"session-rotated\"\n /** File exists and could not be read. Carries the codec's reason. */\n | PersistedTreeReadFailure;\n\nexport type TreeSnapshotStat = {\n reads: number;\n /** Snapshots read successfully. A hit is not yet a warm open: the tree can still refuse to\n * apply it, which is what {@link restores} counts. */\n hits: number;\n /** Snapshots actually applied as a tree's initial state. This is the number that says a\n * reopen was warm. */\n restores: number;\n /** Miss counts by reason. */\n misses: Record<TreeSnapshotMiss, number>;\n writes: number;\n writeFailures: number;\n bytesWritten: number;\n /** Snapshots deleted by the fail-safe after a restored tree failed its first refresh. */\n discarded: number;\n /** Files removed at startup, and how many of those were dropped for being over the ceiling\n * rather than for belonging to another build, backend or user. */\n evicted: number;\n evictedForSize: number;\n bytesEvicted: number;\n millisReading: number;\n millisWriting: number;\n};\n\nfunction initialStat(): TreeSnapshotStat {\n return {\n reads: 0,\n hits: 0,\n restores: 0,\n misses: {\n absent: 0,\n unreadable: 0,\n \"session-rotated\": 0,\n \"not-a-snapshot\": 0,\n \"unknown-schema\": 0,\n truncated: 0,\n checksum: 0,\n malformed: 0,\n },\n writes: 0,\n writeFailures: 0,\n bytesWritten: 0,\n discarded: 0,\n evicted: 0,\n evictedForSize: 0,\n bytesEvicted: 0,\n millisReading: 0,\n millisWriting: 0,\n };\n}\n\nexport type TreeSnapshotStoreOps = {\n /** Directory holding the snapshots. One file per project. */\n readonly dir: string;\n /** Total bytes the directory may occupy after startup eviction. */\n readonly maxSizeBytes: number;\n readonly logger: MiLogger;\n};\n\nconst FILE_PREFIX = \"tree.\";\nconst FILE_SUFFIX = \".plts\";\n\n/** Keeps a filename to characters every filesystem we target accepts. */\nfunction safe(part: string): string {\n return part.replace(/[^A-Za-z0-9_-]/g, \"_\");\n}\n\n/** Names this class writes: a finished snapshot, or the staging file of a write killed before\n * its rename. The directory is caller-supplied and only defaults to one of ours, so nothing\n * failing this is ever deleted, by the purge or by the startup eviction. */\nfunction isOurFile(name: string): boolean {\n if (!name.startsWith(FILE_PREFIX)) return false;\n return name.endsWith(FILE_SUFFIX) || name.includes(`${FILE_SUFFIX}.tmp.`);\n}\n\n/**\n * Snapshots of project tree mirrors on the local filesystem.\n *\n * A snapshot is addressed by backend instance, authenticated user, root resource, build stamp\n * and snapshot schema version. Everything except the root goes into the *scope*, which is\n * fixed for the lifetime of a client; the root distinguishes one project from another, so\n * there is one file per project per user per backend, rewritten in place.\n *\n * The session is deliberately not part of the key. It is witnessed inside the file and\n * compared on read: a snapshot from an ended session is a miss, but the file is kept, because\n * its bodies remain valid indefinitely and only its signatures have died. Deleting it would\n * destroy the evidence a future signature refresh would repair.\n */\nexport class TreeSnapshotStore {\n private readonly stat = initialStat();\n\n private constructor(\n private readonly ops: TreeSnapshotStoreOps,\n /** Identifies backend, user, build and schema. Same for every project in this session. */\n private readonly scope: string,\n ) {}\n\n /**\n * Builds a store for the given client, or returns undefined when nothing should be\n * persisted for it.\n *\n * Returns undefined for an impersonated client: reading and writing under `asUser` would\n * leave another user's mirror at rest under the admin's identity, usable only if the admin\n * returned to that exact root. One condition removes both the hygiene question and the\n * orphan one.\n */\n public static create(\n pl: PlClient,\n ops: TreeSnapshotStoreOps & { readonly enabled: boolean },\n ): TreeSnapshotStore | undefined {\n if (!ops.enabled) {\n ops.logger.info(\"tree snapshots are disabled by configuration\");\n return undefined;\n }\n if (pl.conf.asUser !== undefined) {\n ops.logger.info(\"tree snapshots are disabled while the client is opened as another user\");\n return undefined;\n }\n\n // The backend *instance*, not merely its address: instanceId rotates whenever the backend\n // resets its database, which is exactly the case where the same address starts serving a\n // different state under reused global ids. Without it, a reset at a fixed address (a local\n // backend, whose working directory does not move) would be a key hit, and the only thing\n // left to catch it would be the witness, which is empty on both sides on a backend that\n // predates resource signatures.\n //\n // Hashed rather than spelled out: a login can be an email and an address can carry\n // characters a filename cannot. The hash only has to be stable and to differ when any part\n // differs, both of which it does. The NUL separator keeps the parts unambiguous.\n const identity = createHash(\"sha256\")\n .update([pl.conf.hostAndPort, pl.serverInfo.instanceId ?? \"\", pl.authUser ?? \"\"].join(\"\\0\"))\n .digest(\"hex\")\n .slice(0, 16);\n\n const scope = `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`;\n return new TreeSnapshotStore(ops, scope);\n }\n\n /**\n * Removes this class's files from the snapshot directory. Run when snapshots are switched\n * off, so a user who turns the kill switch off because the disk is full or unwritable\n * actually gets the space back, rather than leaving up to the size ceiling stranded there\n * indefinitely.\n *\n * Deliberately keyed on the setting rather than on \"there is no store\": a store is also\n * absent for an impersonated client, and deleting there would destroy the operator's own\n * snapshots from their ordinary sessions. Never throws.\n */\n public static async purge(dir: string, logger: MiLogger): Promise<void> {\n try {\n // Deliberately NOT a recursive delete of `dir`. The path is caller-supplied and only\n // defaults to a directory of ours, so removing it wholesale would let a misconfigured\n // `treeSnapshotPath` take an unrelated directory with it, at the exact moment the user\n // reached for a switch labelled \"my disk is troublesome\". Only files this class writes\n // are removed, then the directory itself if that emptied it.\n for (const name of await fsp.readdir(dir)) {\n if (!isOurFile(name)) continue;\n await fsp.rm(path.join(dir, name), { force: true }).catch(() => {});\n }\n await fsp.rmdir(dir).catch(() => {\n // Still holds something that is not ours; leaving it is the point.\n });\n } catch (e: unknown) {\n if ((e as NodeJS.ErrnoException | null)?.code === \"ENOENT\") return;\n logger.warn(\n `failed to clear the tree snapshot directory: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n public getStats(): Readonly<TreeSnapshotStat> {\n return this.stat;\n }\n\n private fileFor(root: SignedResourceId): string {\n // The root's global id, not the signed form: the signature changes every session while\n // the file must not.\n const { globalId } = parseSignedResourceId(root);\n return path.join(this.ops.dir, `${FILE_PREFIX}${this.scope}.${globalId}${FILE_SUFFIX}`);\n }\n\n /**\n * Reads the snapshot for a project root, or reports why there is nothing to restore.\n *\n * `root` is the id as resolved in the current session. Its signature is what the stored\n * witness is compared against, so a rotated session is detected without inflating the\n * payload, and no separate session lookup is needed anywhere.\n */\n public async read(\n root: SignedResourceId,\n ): Promise<{ ok: true; tree: PersistedTree } | { ok: false; miss: TreeSnapshotMiss }> {\n const started = Date.now();\n this.stat.reads++;\n try {\n const file = this.fileFor(root);\n let bytes: Buffer;\n try {\n bytes = await fsp.readFile(file);\n } catch (e: unknown) {\n // A missing file and an unreadable one both mean a cold open, but they are different\n // problems: one is an ordinary first open, the other is a cache directory that needs\n // attention, and the counters are the only place that difference is visible.\n const absent = (e as NodeJS.ErrnoException | null)?.code === \"ENOENT\";\n if (!absent)\n this.ops.logger.warn(\n `tree snapshot exists but could not be read: ${e instanceof Error ? e.message : String(e)}`,\n );\n return this.miss(absent ? \"absent\" : \"unreadable\");\n }\n\n const header = readPersistedTreeHeader(bytes);\n if (!header.ok) return this.miss(header.reason);\n\n // On a backend predating resource signatures both sides are empty and always match,\n // which is right: without signatures the ids are not session-bound in the first place.\n const { signature } = parseSignedResourceId(root);\n if (!Buffer.from(header.value.witness).equals(Buffer.from(signature)))\n // Kept, not deleted. See the class comment.\n return this.miss(\"session-rotated\");\n\n const decoded = await decodePersistedTree(bytes);\n if (!decoded.ok) return this.miss(decoded.reason);\n\n // Touched on a hit so the modification time tracks last *use*, which is what the size\n // trim is supposed to order by. Without this, a project reopened every day but never\n // changed is never rewritten, and so ages out ahead of one touched once and abandoned.\n const now = new Date();\n await fsp.utimes(file, now, now).catch(() => {\n // Ordering the trim is not worth failing a hit over.\n });\n\n this.stat.hits++;\n return { ok: true, tree: decoded.value };\n } finally {\n this.stat.millisReading += Date.now() - started;\n }\n }\n\n private miss(miss: TreeSnapshotMiss): { ok: false; miss: TreeSnapshotMiss } {\n this.stat.misses[miss]++;\n return { ok: false, miss };\n }\n\n /** Recorded by the caller once it knows the tree accepted the snapshot. The store cannot\n * tell on its own: it hands over bytes, and whether they become a tree is the tree's call. */\n public noteRestored(): void {\n this.stat.restores++;\n }\n\n /**\n * Writes a snapshot, replacing any previous one for the same project.\n *\n * Never throws and never rejects: a write is an optimisation, and a full disk or a\n * permissions problem must not fail the operation that triggered it. Staged and renamed\n * into place, so a process killed mid-write leaves the previous snapshot rather than a torn\n * one.\n */\n public async write(\n root: SignedResourceId,\n snapshot: PersistedTree,\n ops: { compress?: boolean } = {},\n ): Promise<boolean> {\n const started = Date.now();\n try {\n const bytes = await encodePersistedTree(snapshot, { compress: ops.compress });\n await ensureDirExists(this.ops.dir);\n\n const file = this.fileFor(root);\n await createPathAtomically(this.ops.logger, file, async (tempPath) => {\n // \"wx\" so a colliding temp name fails instead of overwriting another writer's file.\n await fsp.writeFile(tempPath, bytes, { flag: \"wx\" });\n });\n\n this.stat.writes++;\n this.stat.bytesWritten += bytes.length;\n return true;\n } catch (e: unknown) {\n this.stat.writeFailures++;\n this.ops.logger.warn(\n `failed to write tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n return false;\n } finally {\n this.stat.millisWriting += Date.now() - started;\n }\n }\n\n /** Deletes the snapshot for a project. Used by the fail-safe, when a restored tree turns\n * out not to match what the backend will serve. Never throws. */\n public async discard(root: SignedResourceId): Promise<void> {\n try {\n await fsp.rm(this.fileFor(root), { force: true });\n this.stat.discarded++;\n } catch (e: unknown) {\n this.ops.logger.warn(\n `failed to discard tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n /**\n * Startup housekeeping. Drops every snapshot outside the current scope (another build,\n * backend, user or schema version), then trims what is left to the size ceiling, least\n * recently written first.\n *\n * Modification time stands in for recency of use: an open project is rewritten\n * periodically, so the file's age tracks how recently the project was worked on. Read times\n * would be a truer signal but atime is unreliable across platforms and mount options.\n *\n * Runs at startup only, so the ceiling bounds what a session starts with rather than capping\n * it throughout: a long session opening many projects can exceed it until the next launch.\n *\n * Never throws: an unusable cache directory should cost the cache, not the startup.\n */\n public async evict(): Promise<void> {\n try {\n await ensureDirExists(this.ops.dir);\n const names = await fsp.readdir(this.ops.dir);\n\n const current: { file: string; size: number; mtimeMs: number }[] = [];\n\n for (const name of names) {\n const file = path.join(this.ops.dir, name);\n\n // Anything of ours not addressed to the current scope goes: another build, backend,\n // user or schema version, and also the staging files of a write that was killed\n // before its rename, which end in `.tmp.<hex>` rather than the suffix.\n const inScope =\n name.startsWith(`${FILE_PREFIX}${this.scope}.`) && name.endsWith(FILE_SUFFIX);\n\n // Out of scope is not the same as ours to delete: a `treeSnapshotPath` pointed at an\n // existing or shared directory would otherwise have every file in it removed at\n // startup. Same rule as `purge`, for the same reason.\n if (!inScope && !isOurFile(name)) continue;\n\n let size = 0;\n let mtimeMs = 0;\n try {\n const stat = await fsp.stat(file);\n if (!stat.isFile()) continue;\n size = stat.size;\n mtimeMs = stat.mtimeMs;\n } catch {\n continue; // vanished under us, or unreadable: nothing to account for\n }\n\n if (inScope) {\n current.push({ file, size, mtimeMs });\n continue;\n }\n\n await this.remove(file, size, false);\n }\n\n const ceiling = this.ops.maxSizeBytes;\n let total = current.reduce((sum, e) => sum + e.size, 0);\n if (total <= ceiling) return;\n\n // Oldest first, so the projects a user is actually working on are the ones that survive.\n current.sort((a, b) => a.mtimeMs - b.mtimeMs);\n for (const entry of current) {\n if (total <= ceiling) break;\n // Only a file that actually went stops counting against the ceiling. Subtracting\n // regardless would let one undeletable file end the trim early and leave the directory\n // over its limit.\n if (await this.remove(entry.file, entry.size, true)) total -= entry.size;\n }\n } catch (e: unknown) {\n this.ops.logger.warn(\n `tree snapshot eviction failed: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n private async remove(file: string, size: number, forSize: boolean): Promise<boolean> {\n try {\n await fsp.rm(file, { force: true });\n this.stat.evicted++;\n this.stat.bytesEvicted += size;\n if (forSize) this.stat.evictedForSize++;\n return true;\n } catch {\n // A file we cannot delete is not worth failing startup over; it will be reconsidered\n // on the next one.\n return false;\n }\n }\n}\n"],"mappings":"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"}
@@ -1,6 +1,4 @@
1
1
  import { SynchronizedTreeOps, SynchronizedTreeState } from "@milaboratories/pl-tree";
2
2
  import { Computable } from "@milaboratories/computable";
3
- //#region src/middle_layer/types.d.ts
4
3
  export type TemporalSynchronizedTreeOps = Pick<SynchronizedTreeOps, "pollingInterval" | "stopPollingDelay" | "logStat" | "initialTreeLoadingTimeout" | "traversalMode">;
5
- //#endregion
6
4
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../src/middle_layer/types.ts"],"mappings":";;;YAGY,8BAA8B,KACxC"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../src/middle_layer/types.ts"],"mappings":";;YAGY,8BAA8B,KACxC"}
@@ -2,7 +2,7 @@ const require_project_model = require("../model/project_model.cjs");
2
2
  const require_util = require("../cfg_render/util.cjs");
3
3
  let _platforma_sdk_model = require("@platforma-sdk/model");
4
4
  let _milaboratories_pl_client = require("@milaboratories/pl-client");
5
- //#region src/middle_layer/util.ts
5
+ // src/middle_layer/util.ts
6
6
  /** Returns block pack info along with string representation of block-pack resource id */
7
7
  function getBlockPackInfo(prj, blockId) {
8
8
  return require_util.ifNotUndef(prj.traverse({
@@ -24,7 +24,6 @@ function getBlockPackInfo(prj, blockId) {
24
24
  };
25
25
  });
26
26
  }
27
- //#endregion
28
27
  exports.getBlockPackInfo = getBlockPackInfo;
29
28
 
30
29
  //# sourceMappingURL=util.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"util.cjs","names":["ifNotUndef","projectFieldName","Pl"],"sources":["../../src/middle_layer/util.ts"],"sourcesContent":["import type { PlTreeNodeAccessor } from \"@milaboratories/pl-tree\";\nimport { projectFieldName } from \"../model/project_model\";\nimport type { SignedResourceId } from \"@milaboratories/pl-client\";\nimport { Pl } from \"@milaboratories/pl-client\";\nimport { ifNotUndef } from \"../cfg_render/util\";\nimport type { BlockPackInfo } from \"../model/block_pack\";\nimport type { BlockConfig } from \"@platforma-sdk/model\";\nimport { extractConfig } from \"@platforma-sdk/model\";\n\nexport type BlockPackInfoAndId = {\n readonly bpResourceId: SignedResourceId;\n /** To be added to computable keys, to force reload on config change */\n readonly bpId: string;\n /** Full block-pack info */\n readonly info: BlockPackInfo;\n /** Config extracted from the info */\n readonly cfg: BlockConfig;\n};\n\n/** Returns block pack info along with string representation of block-pack resource id */\nexport function getBlockPackInfo(\n prj: PlTreeNodeAccessor,\n blockId: string,\n): BlockPackInfoAndId | undefined {\n return ifNotUndef(\n prj.traverse(\n {\n field: projectFieldName(blockId, \"blockPack\"),\n assertFieldType: \"Dynamic\",\n errorIfFieldNotSet: true,\n },\n { field: Pl.HolderRefField, assertFieldType: \"Input\", errorIfFieldNotFound: true },\n ),\n (bpAcc) => {\n const info = bpAcc.getDataAsJson<BlockPackInfo>()!;\n const cfg = extractConfig(info.config);\n return {\n bpResourceId: bpAcc.resourceInfo.id,\n bpId: bpAcc.resourceInfo.id.toString(),\n info,\n cfg,\n };\n },\n );\n}\n"],"mappings":";;;;;;AAoBA,SAAgB,iBACd,KACA,SACgC;CAChC,OAAOA,aAAAA,WACL,IAAI,SACF;EACE,OAAOC,sBAAAA,iBAAiB,SAAS,WAAW;EAC5C,iBAAiB;EACjB,oBAAoB;CACtB,GACA;EAAE,OAAOC,0BAAAA,GAAG;EAAgB,iBAAiB;EAAS,sBAAsB;CAAK,CACnF,IACC,UAAU;EACT,MAAM,OAAO,MAAM,cAA6B;EAChD,MAAM,OAAA,GAAA,qBAAA,cAAA,CAAoB,KAAK,MAAM;EACrC,OAAO;GACL,cAAc,MAAM,aAAa;GACjC,MAAM,MAAM,aAAa,GAAG,SAAS;GACrC;GACA;EACF;CACF,CACF;AACF"}
1
+ {"version":3,"file":"util.cjs","names":["ifNotUndef","projectFieldName","Pl","extractConfig"],"sources":["../../src/middle_layer/util.ts"],"sourcesContent":["import type { PlTreeNodeAccessor } from \"@milaboratories/pl-tree\";\nimport { projectFieldName } from \"../model/project_model\";\nimport type { SignedResourceId } from \"@milaboratories/pl-client\";\nimport { Pl } from \"@milaboratories/pl-client\";\nimport { ifNotUndef } from \"../cfg_render/util\";\nimport type { BlockPackInfo } from \"../model/block_pack\";\nimport type { BlockConfig } from \"@platforma-sdk/model\";\nimport { extractConfig } from \"@platforma-sdk/model\";\n\nexport type BlockPackInfoAndId = {\n readonly bpResourceId: SignedResourceId;\n /** To be added to computable keys, to force reload on config change */\n readonly bpId: string;\n /** Full block-pack info */\n readonly info: BlockPackInfo;\n /** Config extracted from the info */\n readonly cfg: BlockConfig;\n};\n\n/** Returns block pack info along with string representation of block-pack resource id */\nexport function getBlockPackInfo(\n prj: PlTreeNodeAccessor,\n blockId: string,\n): BlockPackInfoAndId | undefined {\n return ifNotUndef(\n prj.traverse(\n {\n field: projectFieldName(blockId, \"blockPack\"),\n assertFieldType: \"Dynamic\",\n errorIfFieldNotSet: true,\n },\n { field: Pl.HolderRefField, assertFieldType: \"Input\", errorIfFieldNotFound: true },\n ),\n (bpAcc) => {\n const info = bpAcc.getDataAsJson<BlockPackInfo>()!;\n const cfg = extractConfig(info.config);\n return {\n bpResourceId: bpAcc.resourceInfo.id,\n bpId: bpAcc.resourceInfo.id.toString(),\n info,\n cfg,\n };\n },\n );\n}\n"],"mappings":";;;;;;AAoBA,SAAgB,iBACd,KACA,SACgC;CAChC,OAAOA,aAAAA,WACL,IAAI,SACF;EACE,OAAOC,sBAAAA,iBAAiB,SAAS,WAAW;EAC5C,iBAAiB;EACjB,oBAAoB;CACtB,GACA;EAAE,OAAOC,0BAAAA,GAAG;EAAgB,iBAAiB;EAAS,sBAAsB;CAAK,CACnF,IACC,UAAU;EACT,MAAM,OAAO,MAAM,cAA6B;EAChD,MAAM,OAAA,GAAMC,qBAAAA,cAAAA,CAAc,KAAK,MAAM;EACrC,OAAO;GACL,cAAc,MAAM,aAAa;GACjC,MAAM,MAAM,aAAa,GAAG,SAAS;GACrC;GACA;EACF;CACF,CACF;AACF"}
@@ -2,7 +2,7 @@ import { projectFieldName } from "../model/project_model.js";
2
2
  import { ifNotUndef } from "../cfg_render/util.js";
3
3
  import { extractConfig } from "@platforma-sdk/model";
4
4
  import { Pl } from "@milaboratories/pl-client";
5
- //#region src/middle_layer/util.ts
5
+ // src/middle_layer/util.ts
6
6
  /** Returns block pack info along with string representation of block-pack resource id */
7
7
  function getBlockPackInfo(prj, blockId) {
8
8
  return ifNotUndef(prj.traverse({
@@ -24,7 +24,6 @@ function getBlockPackInfo(prj, blockId) {
24
24
  };
25
25
  });
26
26
  }
27
- //#endregion
28
27
  export { getBlockPackInfo };
29
28
 
30
29
  //# sourceMappingURL=util.js.map
@@ -1,6 +1,6 @@
1
1
  let _milaboratories_ts_helpers = require("@milaboratories/ts-helpers");
2
2
  let _milaboratories_pl_model_common = require("@milaboratories/pl-model-common");
3
- //#region src/model/args.ts
3
+ // src/model/args.ts
4
4
  function outputRef(blockId, name, requireEnrichments) {
5
5
  if (requireEnrichments) return {
6
6
  __isRef: true,
@@ -59,7 +59,6 @@ function recordRef(result, blockId, requireEnrichments, allowed) {
59
59
  if (requireEnrichments) result.upstreamsRequiringEnrichments.add(blockId);
60
60
  } else result.missingReferences = true;
61
61
  }
62
- //#endregion
63
62
  exports.inferAllReferencedBlocks = inferAllReferencedBlocks;
64
63
  exports.isBlockOutputReference = isBlockOutputReference;
65
64
  exports.outputRef = outputRef;
@@ -1 +1 @@
1
- {"version":3,"file":"args.cjs","names":[],"sources":["../../src/model/args.ts"],"sourcesContent":["import { assertNever } from \"@milaboratories/ts-helpers\";\nimport type { PlRef } from \"@platforma-sdk/model\";\nimport { peelJsonLayers } from \"@milaboratories/pl-model-common\";\n\nexport function outputRef(blockId: string, name: string, requireEnrichments?: boolean): PlRef {\n if (requireEnrichments) return { __isRef: true, blockId, name, requireEnrichments };\n else return { __isRef: true, blockId, name };\n}\n\nexport function isBlockOutputReference(obj: unknown): obj is PlRef {\n return (\n typeof obj === \"object\" &&\n obj !== null &&\n \"__isRef\" in obj &&\n obj.__isRef === true &&\n \"blockId\" in obj &&\n \"name\" in obj\n );\n}\n\nexport interface BlockUpstreams {\n /** All direct block dependencies */\n upstreams: Set<string>;\n /** Direct block dependencies which enrichments are also required by current block */\n upstreamsRequiringEnrichments: Set<string>;\n /** True if not-allowed references was encountered */\n missingReferences: boolean;\n}\n\n/** Extracts all resource ids referenced by args object. */\nexport function inferAllReferencedBlocks(args: unknown, allowed?: Set<string>): BlockUpstreams {\n const result = {\n upstreams: new Set<string>(),\n upstreamsRequiringEnrichments: new Set<string>(),\n missingReferences: false,\n };\n addAllReferencedBlocks(result, args, allowed);\n return result;\n}\n\nfunction addAllReferencedBlocks(result: BlockUpstreams, node: unknown, allowed?: Set<string>) {\n const type = typeof node;\n switch (type) {\n case \"function\":\n case \"bigint\":\n case \"number\":\n case \"boolean\":\n case \"symbol\":\n case \"undefined\":\n return;\n case \"string\": {\n // A reference can be hiding inside a string under any number of `JSON.stringify`\n // passes, and this walk would otherwise never reach it. Peeling is shared with the\n // column-id remapper so that \"how a value hides inside a string\" has one\n // definition; what happens at the bottom is not shared, and here it is deliberately\n // broad — anything the peel produced is walked, including a foreign-schema document\n // that merely contains a reference. That breadth is what makes this detector usable\n // as a guard over carriers the template codec declines to touch.\n const peeled = peelJsonLayers(node as string);\n if (peeled !== undefined) addAllReferencedBlocks(result, peeled.value, allowed);\n return;\n }\n case \"object\": {\n if (node === null) return;\n if (isBlockOutputReference(node)) {\n recordRef(result, node.blockId, node.requireEnrichments === true, allowed);\n } else if (Array.isArray(node)) {\n for (const child of node) addAllReferencedBlocks(result, child, allowed);\n } else {\n // Keys as well as values: a discovered column's `queriesQualifications` is keyed BY\n // column id, so a map key can carry a block id — and dropping the key loses that\n // upstream edge entirely.\n for (const [key, child] of Object.entries(node as object)) {\n addAllReferencedBlocks(result, key, allowed);\n addAllReferencedBlocks(result, child, allowed);\n }\n }\n\n return;\n }\n default:\n assertNever(type);\n }\n}\n\nfunction recordRef(\n result: BlockUpstreams,\n blockId: string,\n requireEnrichments: boolean,\n allowed?: Set<string>,\n) {\n if (allowed === undefined || allowed.has(blockId)) {\n result.upstreams.add(blockId);\n if (requireEnrichments) result.upstreamsRequiringEnrichments.add(blockId);\n } else result.missingReferences = true;\n}\n"],"mappings":";;;AAIA,SAAgB,UAAU,SAAiB,MAAc,oBAAqC;CAC5F,IAAI,oBAAoB,OAAO;EAAE,SAAS;EAAM;EAAS;EAAM;CAAmB;MAC7E,OAAO;EAAE,SAAS;EAAM;EAAS;CAAK;AAC7C;AAEA,SAAgB,uBAAuB,KAA4B;CACjE,OACE,OAAO,QAAQ,YACf,QAAQ,QACR,aAAa,OACb,IAAI,YAAY,QAChB,aAAa,OACb,UAAU;AAEd;;AAYA,SAAgB,yBAAyB,MAAe,SAAuC;CAC7F,MAAM,SAAS;EACb,2BAAW,IAAI,IAAY;EAC3B,+CAA+B,IAAI,IAAY;EAC/C,mBAAmB;CACrB;CACA,uBAAuB,QAAQ,MAAM,OAAO;CAC5C,OAAO;AACT;AAEA,SAAS,uBAAuB,QAAwB,MAAe,SAAuB;CAC5F,MAAM,OAAO,OAAO;CACpB,QAAQ,MAAR;EACE,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,aACH;EACF,KAAK,UAAU;GAQb,MAAM,UAAA,GAAA,gCAAA,eAAA,CAAwB,IAAc;GAC5C,IAAI,WAAW,KAAA,GAAW,uBAAuB,QAAQ,OAAO,OAAO,OAAO;GAC9E;EACF;EACA,KAAK;GACH,IAAI,SAAS,MAAM;GACnB,IAAI,uBAAuB,IAAI,GAC7B,UAAU,QAAQ,KAAK,SAAS,KAAK,uBAAuB,MAAM,OAAO;QACpE,IAAI,MAAM,QAAQ,IAAI,GAC3B,KAAK,MAAM,SAAS,MAAM,uBAAuB,QAAQ,OAAO,OAAO;QAKvE,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAc,GAAG;IACzD,uBAAuB,QAAQ,KAAK,OAAO;IAC3C,uBAAuB,QAAQ,OAAO,OAAO;GAC/C;GAGF;EAEF,SACE,CAAA,GAAA,2BAAA,YAAA,CAAY,IAAI;CACpB;AACF;AAEA,SAAS,UACP,QACA,SACA,oBACA,SACA;CACA,IAAI,YAAY,KAAA,KAAa,QAAQ,IAAI,OAAO,GAAG;EACjD,OAAO,UAAU,IAAI,OAAO;EAC5B,IAAI,oBAAoB,OAAO,8BAA8B,IAAI,OAAO;CAC1E,OAAO,OAAO,oBAAoB;AACpC"}
1
+ {"version":3,"file":"args.cjs","names":["peelJsonLayers"],"sources":["../../src/model/args.ts"],"sourcesContent":["import { assertNever } from \"@milaboratories/ts-helpers\";\nimport type { PlRef } from \"@platforma-sdk/model\";\nimport { peelJsonLayers } from \"@milaboratories/pl-model-common\";\n\nexport function outputRef(blockId: string, name: string, requireEnrichments?: boolean): PlRef {\n if (requireEnrichments) return { __isRef: true, blockId, name, requireEnrichments };\n else return { __isRef: true, blockId, name };\n}\n\nexport function isBlockOutputReference(obj: unknown): obj is PlRef {\n return (\n typeof obj === \"object\" &&\n obj !== null &&\n \"__isRef\" in obj &&\n obj.__isRef === true &&\n \"blockId\" in obj &&\n \"name\" in obj\n );\n}\n\nexport interface BlockUpstreams {\n /** All direct block dependencies */\n upstreams: Set<string>;\n /** Direct block dependencies which enrichments are also required by current block */\n upstreamsRequiringEnrichments: Set<string>;\n /** True if not-allowed references was encountered */\n missingReferences: boolean;\n}\n\n/** Extracts all resource ids referenced by args object. */\nexport function inferAllReferencedBlocks(args: unknown, allowed?: Set<string>): BlockUpstreams {\n const result = {\n upstreams: new Set<string>(),\n upstreamsRequiringEnrichments: new Set<string>(),\n missingReferences: false,\n };\n addAllReferencedBlocks(result, args, allowed);\n return result;\n}\n\nfunction addAllReferencedBlocks(result: BlockUpstreams, node: unknown, allowed?: Set<string>) {\n const type = typeof node;\n switch (type) {\n case \"function\":\n case \"bigint\":\n case \"number\":\n case \"boolean\":\n case \"symbol\":\n case \"undefined\":\n return;\n case \"string\": {\n // A reference can be hiding inside a string under any number of `JSON.stringify`\n // passes, and this walk would otherwise never reach it. Peeling is shared with the\n // column-id remapper so that \"how a value hides inside a string\" has one\n // definition; what happens at the bottom is not shared, and here it is deliberately\n // broad — anything the peel produced is walked, including a foreign-schema document\n // that merely contains a reference. That breadth is what makes this detector usable\n // as a guard over carriers the template codec declines to touch.\n const peeled = peelJsonLayers(node as string);\n if (peeled !== undefined) addAllReferencedBlocks(result, peeled.value, allowed);\n return;\n }\n case \"object\": {\n if (node === null) return;\n if (isBlockOutputReference(node)) {\n recordRef(result, node.blockId, node.requireEnrichments === true, allowed);\n } else if (Array.isArray(node)) {\n for (const child of node) addAllReferencedBlocks(result, child, allowed);\n } else {\n // Keys as well as values: a discovered column's `queriesQualifications` is keyed BY\n // column id, so a map key can carry a block id — and dropping the key loses that\n // upstream edge entirely.\n for (const [key, child] of Object.entries(node as object)) {\n addAllReferencedBlocks(result, key, allowed);\n addAllReferencedBlocks(result, child, allowed);\n }\n }\n\n return;\n }\n default:\n assertNever(type);\n }\n}\n\nfunction recordRef(\n result: BlockUpstreams,\n blockId: string,\n requireEnrichments: boolean,\n allowed?: Set<string>,\n) {\n if (allowed === undefined || allowed.has(blockId)) {\n result.upstreams.add(blockId);\n if (requireEnrichments) result.upstreamsRequiringEnrichments.add(blockId);\n } else result.missingReferences = true;\n}\n"],"mappings":";;;AAIA,SAAgB,UAAU,SAAiB,MAAc,oBAAqC;CAC5F,IAAI,oBAAoB,OAAO;EAAE,SAAS;EAAM;EAAS;EAAM;CAAmB;MAC7E,OAAO;EAAE,SAAS;EAAM;EAAS;CAAK;AAC7C;AAEA,SAAgB,uBAAuB,KAA4B;CACjE,OACE,OAAO,QAAQ,YACf,QAAQ,QACR,aAAa,OACb,IAAI,YAAY,QAChB,aAAa,OACb,UAAU;AAEd;;AAYA,SAAgB,yBAAyB,MAAe,SAAuC;CAC7F,MAAM,SAAS;EACb,2BAAW,IAAI,IAAY;EAC3B,+CAA+B,IAAI,IAAY;EAC/C,mBAAmB;CACrB;CACA,uBAAuB,QAAQ,MAAM,OAAO;CAC5C,OAAO;AACT;AAEA,SAAS,uBAAuB,QAAwB,MAAe,SAAuB;CAC5F,MAAM,OAAO,OAAO;CACpB,QAAQ,MAAR;EACE,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,aACH;EACF,KAAK,UAAU;GAQb,MAAM,UAAA,GAASA,gCAAAA,eAAAA,CAAe,IAAc;GAC5C,IAAI,WAAW,KAAA,GAAW,uBAAuB,QAAQ,OAAO,OAAO,OAAO;GAC9E;EACF;EACA,KAAK;GACH,IAAI,SAAS,MAAM;GACnB,IAAI,uBAAuB,IAAI,GAC7B,UAAU,QAAQ,KAAK,SAAS,KAAK,uBAAuB,MAAM,OAAO;QACpE,IAAI,MAAM,QAAQ,IAAI,GAC3B,KAAK,MAAM,SAAS,MAAM,uBAAuB,QAAQ,OAAO,OAAO;QAKvE,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAc,GAAG;IACzD,uBAAuB,QAAQ,KAAK,OAAO;IAC3C,uBAAuB,QAAQ,OAAO,OAAO;GAC/C;GAGF;EAEF,SACE,CAAA,GAAA,2BAAA,YAAA,CAAY,IAAI;CACpB;AACF;AAEA,SAAS,UACP,QACA,SACA,oBACA,SACA;CACA,IAAI,YAAY,KAAA,KAAa,QAAQ,IAAI,OAAO,GAAG;EACjD,OAAO,UAAU,IAAI,OAAO;EAC5B,IAAI,oBAAoB,OAAO,8BAA8B,IAAI,OAAO;CAC1E,OAAO,OAAO,oBAAoB;AACpC"}
@@ -1,6 +1,6 @@
1
1
  import { assertNever } from "@milaboratories/ts-helpers";
2
2
  import { peelJsonLayers } from "@milaboratories/pl-model-common";
3
- //#region src/model/args.ts
3
+ // src/model/args.ts
4
4
  function outputRef(blockId, name, requireEnrichments) {
5
5
  if (requireEnrichments) return {
6
6
  __isRef: true,
@@ -59,7 +59,6 @@ function recordRef(result, blockId, requireEnrichments, allowed) {
59
59
  if (requireEnrichments) result.upstreamsRequiringEnrichments.add(blockId);
60
60
  } else result.missingReferences = true;
61
61
  }
62
- //#endregion
63
62
  export { inferAllReferencedBlocks, isBlockOutputReference, outputRef };
64
63
 
65
64
  //# sourceMappingURL=args.js.map
@@ -1,4 +1,4 @@
1
- //#region src/model/block_pack_spec.ts
1
+ // src/model/block_pack_spec.ts
2
2
  const FrontendFromUrlResourceType = {
3
3
  name: "Frontend/FromUrl",
4
4
  version: "1"
@@ -11,7 +11,6 @@ const FrontendFromLocalTgzResourceType = {
11
11
  name: "Frontend/FromLocalTgz",
12
12
  version: "1"
13
13
  };
14
- //#endregion
15
14
  exports.FrontendFromFolderResourceType = FrontendFromFolderResourceType;
16
15
  exports.FrontendFromLocalTgzResourceType = FrontendFromLocalTgzResourceType;
17
16
  exports.FrontendFromUrlResourceType = FrontendFromUrlResourceType;
@@ -2,7 +2,6 @@ import { CachedTemplate, ExplicitTemplate, PreparedTemplate } from "./template_s
2
2
  import { BlockConfigContainer } from "@platforma-sdk/model";
3
3
  import { BlockPackSpec } from "@milaboratories/pl-model-middle-layer";
4
4
  import { ResourceType } from "@milaboratories/pl-client";
5
- //#region src/model/block_pack_spec.d.ts
6
5
  export type FrontendSpec = FrontendFromUrl | FrontendFromFolder | FrontendFromLocalTgz;
7
6
  export interface FrontendFromUrlData {
8
7
  url: string;
@@ -67,5 +66,4 @@ export type BlockPackSpecPrepared = {
67
66
  };
68
67
  /** All block-pack specs. */
69
68
  export type BlockPackSpecAny = BlockPackSpecPrepared | BlockPackExplicit | BlockPackSpec;
70
- //#endregion
71
69
  //# sourceMappingURL=block_pack_spec.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"block_pack_spec.d.ts","names":[],"sources":["../../src/model/block_pack_spec.ts"],"mappings":";;;;;YAKY,eAAe,kBAAkB,qBAAqB;iBAEjD;EACf;;qBAGW,6BAA6B;;;iBAIzB,wBAAwB;EACvC;;iBAGe;EACf;;EAEA;;qBAGW,gCAAgC;;;;iBAQ5B,2BAA2B;EAC1C;;iBAGe;;EAEf;;;EAGA;;EAEA;;qBAGW,kCAAkC;;;;;iBAS9B,6BAA6B;EAC5C;;;;iBAKe;EACf;EACA,UAAU;EACV,QAAQ;EACR,UAAU;EACV,QAAQ;;;YAIE;EACV;EACA,UAAU,mBAAmB;EAC7B,QAAQ;EACR,UAAU;EACV,QAAQ;;;;;;EAMR;;;YAIU,mBAAmB,wBAAwB,oBAAoB"}
1
+ {"version":3,"file":"block_pack_spec.d.ts","names":[],"sources":["../../src/model/block_pack_spec.ts"],"mappings":";;;;YAKY,eAAe,kBAAkB,qBAAqB;iBAEjD;EACf;;qBAGW,6BAA6B;;;iBAIzB,wBAAwB;EACvC;;iBAGe;EACf;;EAEA;;qBAGW,gCAAgC;;;;iBAQ5B,2BAA2B;EAC1C;;iBAGe;;EAEf;;;EAGA;;EAEA;;qBAGW,kCAAkC;;;;;iBAS9B,6BAA6B;EAC5C;;;;iBAKe;EACf;EACA,UAAU;EACV,QAAQ;EACR,UAAU;EACV,QAAQ;;;YAIE;EACV;EACA,UAAU,mBAAmB;EAC7B,QAAQ;EACR,UAAU;EACV,QAAQ;;;;;;EAMR;;;YAIU,mBAAmB,wBAAwB,oBAAoB"}
@@ -1,4 +1,4 @@
1
- //#region src/model/block_pack_spec.ts
1
+ // src/model/block_pack_spec.ts
2
2
  const FrontendFromUrlResourceType = {
3
3
  name: "Frontend/FromUrl",
4
4
  version: "1"
@@ -11,7 +11,6 @@ const FrontendFromLocalTgzResourceType = {
11
11
  name: "Frontend/FromLocalTgz",
12
12
  version: "1"
13
13
  };
14
- //#endregion
15
14
  export { FrontendFromFolderResourceType, FrontendFromLocalTgzResourceType, FrontendFromUrlResourceType };
16
15
 
17
16
  //# sourceMappingURL=block_pack_spec.js.map
@@ -1,4 +1,3 @@
1
- //#region src/model/frontend.d.ts
2
1
  /** Middle layer provides information about where frontend for a specific block
3
2
  * was unpacked and basic meta information about the enclosed UI
4
3
  * (i.e. SDK version). */
@@ -8,5 +7,4 @@ export interface FrontendData {
8
7
  /** SDK version used by the UI */
9
8
  readonly sdkVersion: string;
10
9
  }
11
- //#endregion
12
10
  //# sourceMappingURL=frontend.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"frontend.d.ts","names":[],"sources":["../../src/model/frontend.ts"],"mappings":";;;;iBAGiB;;WAEN;;WAGA"}
1
+ {"version":3,"file":"frontend.d.ts","names":[],"sources":["../../src/model/frontend.ts"],"mappings":";;;iBAGiB;;WAEN;;WAGA"}
@@ -3,7 +3,7 @@ const require_index$1 = require("../js_render/index.cjs");
3
3
  let _platforma_sdk_model = require("@platforma-sdk/model");
4
4
  let _milaboratories_ts_helpers = require("@milaboratories/ts-helpers");
5
5
  let lru_cache = require("lru-cache");
6
- //#region src/model/project_helper.ts
6
+ // src/model/project_helper.ts
7
7
  var ProjectHelper = class {
8
8
  quickJs;
9
9
  logger;
@@ -250,7 +250,6 @@ var ProjectHelper = class {
250
250
  }
251
251
  }
252
252
  };
253
- //#endregion
254
253
  exports.ProjectHelper = ProjectHelper;
255
254
 
256
255
  //# sourceMappingURL=project_helper.cjs.map