@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -0,0 +1,66 @@
1
+ import { ExtensionError } from './contracts.mjs'
2
+ import { assertVerifiedScope, digest } from './server.mjs'
3
+
4
+ import { assertApplicationRole, boundedSqlResult, assertSqlOperationId } from './sql-scope.mjs'
5
+
6
+ // The callback receives the provider's native transaction, retaining its query APIs.
7
+ export async function withPostgresScope(database, scope, run) {
8
+ assertVerifiedScope(scope)
9
+ return database.begin(async sql => {
10
+ assertVerifiedScope(scope)
11
+ await assertApplicationRole(sql)
12
+ await sql`SELECT set_config('extension.installation_id', ${scope.installationId}, true),
13
+ set_config('extension.tenant_ref', ${scope.tenantRef}, true), set_config('extension.subject_ref', ${scope.subjectRef}, true),
14
+ set_config('statement_timeout', '10000', true), set_config('lock_timeout', '10000', true),
15
+ set_config('idle_in_transaction_session_timeout', '10000', true)`
16
+ assertVerifiedScope(scope)
17
+ const result = await run(sql)
18
+ // An expired handler cannot commit delayed work under a captured effect scope.
19
+ assertVerifiedScope(scope)
20
+ return result
21
+ })
22
+ }
23
+ export async function oncePostgresOperation(database, scope, input, run) {
24
+ assertVerifiedScope(scope)
25
+ if (digest(input) !== scope.payloadDigest) throw new ExtensionError('invalid-input')
26
+ return withPostgresScope(database, scope, async sql => {
27
+ await sql`INSERT INTO extension_data.operations
28
+ (installation_id,tenant_ref,subject_ref,operation_id,action_id,generation,release_digest,payload_digest)
29
+ VALUES (${scope.installationId},${scope.tenantRef},${scope.subjectRef},${scope.operationId},${scope.actionId},
30
+ ${scope.generation},${scope.releaseDigest},${scope.payloadDigest}) ON CONFLICT DO NOTHING`
31
+ const [row] = await sql`SELECT action_id,generation::text,release_digest,payload_digest,completed,result
32
+ FROM extension_data.operations WHERE installation_id=${scope.installationId} AND tenant_ref=${scope.tenantRef}
33
+ AND subject_ref=${scope.subjectRef} AND operation_id=${scope.operationId} FOR UPDATE`
34
+ if (!row || row.action_id !== scope.actionId || row.generation !== scope.generation ||
35
+ row.release_digest !== scope.releaseDigest || row.payload_digest !== scope.payloadDigest) throw new ExtensionError('idempotency-conflict', 409)
36
+ if (row.completed) return row.result
37
+ const result = boundedSqlResult(await run(sql))
38
+ await sql`UPDATE extension_data.operations SET completed=true,result=${sql.json(result)}
39
+ WHERE installation_id=${scope.installationId} AND tenant_ref=${scope.tenantRef}
40
+ AND subject_ref=${scope.subjectRef} AND operation_id=${scope.operationId}`
41
+ return result
42
+ })
43
+ }
44
+ export async function lookupPostgresOperation(database, scope, operationId) {
45
+ assertVerifiedScope(scope)
46
+ assertSqlOperationId(operationId)
47
+ return withPostgresScope(database, scope, async sql => {
48
+ const [row] = await sql`SELECT operation_id,action_id,generation::text,release_digest,payload_digest,result
49
+ FROM extension_data.operations WHERE installation_id=${scope.installationId} AND tenant_ref=${scope.tenantRef}
50
+ AND subject_ref=${scope.subjectRef} AND operation_id=${operationId} AND completed=true`
51
+ return row ? { schema: 'mnstry.extension-provider-operation/v1', installationId: scope.installationId,
52
+ operationId: row.operation_id, actionId: row.action_id, generation: row.generation, releaseDigest: row.release_digest,
53
+ payloadDigest: row.payload_digest, result: row.result, resultDigest: digest(row.result) } : null
54
+ })
55
+ }
56
+ export async function postgresHealth(database) {
57
+ return database.begin(async sql => {
58
+ await sql`SELECT set_config('statement_timeout','10000',true),set_config('lock_timeout','10000',true)`
59
+ await assertApplicationRole(sql)
60
+ const [version] = await sql`SHOW server_version_num`
61
+ if (Math.floor(Number(version?.server_version_num) / 10000) !== 17) throw new ExtensionError('unavailable', 503)
62
+ await sql`SELECT installation_id,tenant_ref,subject_ref,operation_id,action_id,generation,
63
+ release_digest,payload_digest,completed,result FROM extension_data.operations LIMIT 0`
64
+ return { ready: true, profile: 'postgres-sql', postgresMajor: 17 }
65
+ })
66
+ }
@@ -0,0 +1,13 @@
1
+ import type { Json, ProviderOperationProof } from './contracts.mjs'
2
+ import type { VerifiedScope } from './server.mjs'
3
+ export interface PrismaTransaction {
4
+ $queryRaw<T=unknown>(query: TemplateStringsArray, ...values: unknown[]): Promise<T>
5
+ $executeRaw(query: TemplateStringsArray, ...values: unknown[]): Promise<number>
6
+ }
7
+ export interface PrismaDatabase<Transaction extends PrismaTransaction> {
8
+ $transaction<T>(run:(transaction:Transaction)=>Promise<T>, options?: { maxWait?:number;timeout?:number }): Promise<T>
9
+ }
10
+ export function withPrismaScope<Transaction extends PrismaTransaction,T>(database:PrismaDatabase<Transaction>,scope:VerifiedScope,run:(transaction:Transaction)=>Promise<T>):Promise<T>
11
+ export function oncePrismaOperation<Transaction extends PrismaTransaction>(database:PrismaDatabase<Transaction>,scope:VerifiedScope,input:Json,run:(transaction:Transaction)=>Promise<Json>):Promise<Json>
12
+ export function lookupPrismaOperation<Transaction extends PrismaTransaction>(database:PrismaDatabase<Transaction>,scope:VerifiedScope,operationId:string):Promise<ProviderOperationProof|null>
13
+ export function prismaHealth<Transaction extends PrismaTransaction>(database:PrismaDatabase<Transaction>):Promise<{ready:true;profile:'prisma-7-postgres';postgresMajor:17}>
@@ -0,0 +1,63 @@
1
+ import { ExtensionError } from './contracts.mjs'
2
+ import { assertVerifiedScope, digest } from './server.mjs'
3
+ import { assertApplicationRole, boundedSqlResult, assertSqlOperationId } from './sql-scope.mjs'
4
+
5
+ export async function withPrismaScope(database, scope, run) {
6
+ assertVerifiedScope(scope)
7
+ return database.$transaction(async tx => {
8
+ assertVerifiedScope(scope)
9
+ await assertApplicationRole(tx.$queryRaw.bind(tx))
10
+ await tx.$queryRaw`SELECT set_config('extension.installation_id',${scope.installationId},true),
11
+ set_config('extension.tenant_ref',${scope.tenantRef},true),set_config('extension.subject_ref',${scope.subjectRef},true),
12
+ set_config('statement_timeout','10000',true),set_config('lock_timeout','10000',true),
13
+ set_config('idle_in_transaction_session_timeout','10000',true)`
14
+ assertVerifiedScope(scope)
15
+ const result = await run(tx)
16
+ assertVerifiedScope(scope)
17
+ return result
18
+ }, { maxWait: 10000, timeout: 12000 })
19
+ }
20
+ export async function oncePrismaOperation(database, scope, input, run) {
21
+ assertVerifiedScope(scope)
22
+ if (digest(input) !== scope.payloadDigest) throw new ExtensionError('invalid-input')
23
+ return withPrismaScope(database, scope, async tx => {
24
+ await tx.$executeRaw`INSERT INTO extension_data.operations
25
+ (installation_id,tenant_ref,subject_ref,operation_id,action_id,generation,release_digest,payload_digest)
26
+ VALUES (${scope.installationId},${scope.tenantRef},${scope.subjectRef},${scope.operationId},${scope.actionId},
27
+ ${BigInt(scope.generation)},${scope.releaseDigest},${scope.payloadDigest}) ON CONFLICT DO NOTHING`
28
+ const [row] = await tx.$queryRaw`SELECT action_id,generation::text,release_digest,payload_digest,completed,result
29
+ FROM extension_data.operations WHERE installation_id=${scope.installationId} AND tenant_ref=${scope.tenantRef}
30
+ AND subject_ref=${scope.subjectRef} AND operation_id=${scope.operationId} FOR UPDATE`
31
+ if (!row || row.action_id !== scope.actionId || row.generation !== scope.generation ||
32
+ row.release_digest !== scope.releaseDigest || row.payload_digest !== scope.payloadDigest) throw new ExtensionError('idempotency-conflict',409)
33
+ if (row.completed) return row.result
34
+ const result = boundedSqlResult(await run(tx))
35
+ await tx.$executeRaw`UPDATE extension_data.operations SET completed=true,result=${JSON.stringify(result)}::jsonb
36
+ WHERE installation_id=${scope.installationId} AND tenant_ref=${scope.tenantRef}
37
+ AND subject_ref=${scope.subjectRef} AND operation_id=${scope.operationId}`
38
+ return result
39
+ })
40
+ }
41
+ export async function lookupPrismaOperation(database, scope, operationId) {
42
+ assertVerifiedScope(scope)
43
+ assertSqlOperationId(operationId)
44
+ return withPrismaScope(database, scope, async tx => {
45
+ const [row] = await tx.$queryRaw`SELECT operation_id,action_id,generation::text,release_digest,payload_digest,result
46
+ FROM extension_data.operations WHERE installation_id=${scope.installationId} AND tenant_ref=${scope.tenantRef}
47
+ AND subject_ref=${scope.subjectRef} AND operation_id=${operationId} AND completed=true`
48
+ return row ? { schema:'mnstry.extension-provider-operation/v1',installationId:scope.installationId,
49
+ operationId:row.operation_id,actionId:row.action_id,generation:row.generation,releaseDigest:row.release_digest,
50
+ payloadDigest:row.payload_digest,result:row.result,resultDigest:digest(row.result) } : null
51
+ })
52
+ }
53
+ export async function prismaHealth(database) {
54
+ return database.$transaction(async tx => {
55
+ await tx.$queryRaw`SELECT set_config('statement_timeout','10000',true),set_config('lock_timeout','10000',true)`
56
+ await assertApplicationRole(tx.$queryRaw.bind(tx))
57
+ const [row] = await tx.$queryRaw`SHOW server_version_num`
58
+ if (Math.floor(Number(row?.server_version_num)/10000)!==17) throw new ExtensionError('unavailable',503)
59
+ await tx.$queryRaw`SELECT installation_id,tenant_ref,subject_ref,operation_id,action_id,generation,
60
+ release_digest,payload_digest,completed,result FROM extension_data.operations LIMIT 0`
61
+ return { ready:true,profile:'prisma-7-postgres',postgresMajor:17 }
62
+ }, { maxWait:10000,timeout:12000 })
63
+ }
@@ -0,0 +1,432 @@
1
+ import { constants as fsConstants } from 'node:fs'
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { lstat, open, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { canonicalJson, parseManifest, parseReleaseBundle } from './contracts.mjs'
7
+ import { sqlProjectFiles } from './scaffold-sql.mjs'
8
+ import { convexProjectFiles } from './scaffold-convex.mjs'
9
+ import { releaseDigest } from './server.mjs'
10
+ import { loadExtensionPacks, RESERVED_NAMESPACE_PATTERN } from '../extension-packs/loader.mjs'
11
+
12
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex')
13
+ const manifestName = 'extension.manifest.json'
14
+ const packName = 'atelier.extension-pack.json'
15
+ const backend = `import type { Sql } from 'postgres'
16
+ import { exact, ExtensionError, parseProviderOperationProof, type ExtensionManifest, type Json } from '@mnstry/atelier/extensions'
17
+ import { createExtensionHandler, createRootAdmissionConsumer, defineAction, defineExtension, type VerifierOptions } from '@mnstry/atelier/extensions/server'
18
+ import { lookupPostgresOperation, oncePostgresOperation, postgresHealth, withPostgresScope } from '@mnstry/atelier/extensions/postgres'
19
+
20
+ function counter(value: unknown): { value: number } {
21
+ const input = exact(value, ['value'])
22
+ if (!Number.isSafeInteger(input.value) || Number(input.value) < 0 || Number(input.value) > 1000000) throw new ExtensionError('invalid-input')
23
+ return { value: Number(input.value) }
24
+ }
25
+ const empty = (value: unknown) => { exact(value, []); return {} }
26
+ const json = (value: unknown) => value as Json
27
+ /** The consuming host owns process startup, verified public keys, deployment and secrets. */
28
+ export function createBackend(options: { database: Sql; manifest: ExtensionManifest; rootOrigin: string;
29
+ verifier: Omit<VerifierOptions, 'consume'>; allowLocalTesting?: boolean }) {
30
+ const database = options.database
31
+ const extension = defineExtension({ manifest: options.manifest,
32
+ verifier: { ...options.verifier, consume: createRootAdmissionConsumer({ origin: options.rootOrigin, allowLocalTesting: options.allowLocalTesting }) },
33
+ actions: {
34
+ 'counter.set': defineAction({ input: counter, output: counter, run: ({ scope }, input) => oncePostgresOperation(database, scope, input, async sql => {
35
+ await sql\`INSERT INTO extension_data.counters (installation_id,tenant_ref,subject_ref,value)
36
+ VALUES (\${scope.installationId},\${scope.tenantRef},\${scope.subjectRef},\${input.value})
37
+ ON CONFLICT (installation_id,tenant_ref,subject_ref) DO UPDATE SET value=excluded.value\`
38
+ return input
39
+ }) }),
40
+ 'counter.get': defineAction({ input: empty, output: counter, run: ({ scope }) => withPostgresScope(database, scope, async sql => {
41
+ const [row] = await sql\`SELECT value FROM extension_data.counters WHERE installation_id=\${scope.installationId}
42
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef}\`
43
+ return { value: row?.value ?? 0 }
44
+ }) }),
45
+ 'counter.export': defineAction({ input: empty, output: json, run: ({ scope }) => withPostgresScope(database, scope, async sql => {
46
+ const [row] = await sql\`SELECT value FROM extension_data.counters WHERE installation_id=\${scope.installationId}
47
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef}\`
48
+ return { schema: 'sample.counter-export/v1', value: row?.value ?? 0 }
49
+ }) }),
50
+ 'counter.delete': defineAction({ input: empty, output: json, run: ({ scope }, input) => oncePostgresOperation(database, scope, input, async sql => {
51
+ const rows = await sql\`DELETE FROM extension_data.counters WHERE installation_id=\${scope.installationId}
52
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef} RETURNING value\`
53
+ await sql\`DELETE FROM extension_data.operations WHERE installation_id=\${scope.installationId}
54
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef} AND operation_id<>\${scope.operationId}\`
55
+ return { deleted: rows.length, retention: 'provider-live-rows-deleted', backups: 'operator-policy' }
56
+ }) }),
57
+ 'counter.lookup': defineAction({ input: value => { const input = exact(value, ['operationId']);
58
+ if (typeof input.operationId !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(input.operationId)) throw new ExtensionError('invalid-input')
59
+ return { operationId: input.operationId } }, output: value => value === null ? null : parseProviderOperationProof(value),
60
+ run: ({ scope }, input) => lookupPostgresOperation(database, scope, input.operationId) }),
61
+ },
62
+ })
63
+ return { handler: createExtensionHandler(extension), health: () => postgresHealth(database) }
64
+ }
65
+ `
66
+ const migration = `-- Apply only to the customer-owned extension database with a separate migration role.
67
+ -- Create extension_app LOGIN and its secret outside this file. Never use a root runtime DSN.
68
+ CREATE SCHEMA extension_data;
69
+ REVOKE ALL ON SCHEMA extension_data FROM PUBLIC;
70
+ CREATE TABLE extension_data.counters (
71
+ installation_id text NOT NULL, tenant_ref text NOT NULL, subject_ref text NOT NULL,
72
+ value integer NOT NULL CHECK(value BETWEEN 0 AND 1000000),
73
+ PRIMARY KEY(installation_id,tenant_ref,subject_ref)
74
+ );
75
+ CREATE TABLE extension_data.operations (
76
+ installation_id text NOT NULL, tenant_ref text NOT NULL, subject_ref text NOT NULL,
77
+ operation_id text NOT NULL, action_id text NOT NULL, generation bigint NOT NULL CHECK(generation>0),
78
+ release_digest text NOT NULL CHECK(release_digest ~ '^[a-f0-9]{64}$'),
79
+ payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'),
80
+ completed boolean NOT NULL DEFAULT false, result jsonb,
81
+ PRIMARY KEY(installation_id,tenant_ref,subject_ref,operation_id)
82
+ );
83
+ ALTER TABLE extension_data.counters ENABLE ROW LEVEL SECURITY;
84
+ ALTER TABLE extension_data.counters FORCE ROW LEVEL SECURITY;
85
+ ALTER TABLE extension_data.operations ENABLE ROW LEVEL SECURITY;
86
+ ALTER TABLE extension_data.operations FORCE ROW LEVEL SECURITY;
87
+ CREATE POLICY counter_scope ON extension_data.counters TO extension_app
88
+ USING(installation_id=current_setting('extension.installation_id',true) AND tenant_ref=current_setting('extension.tenant_ref',true)
89
+ AND subject_ref=current_setting('extension.subject_ref',true))
90
+ WITH CHECK(installation_id=current_setting('extension.installation_id',true) AND tenant_ref=current_setting('extension.tenant_ref',true)
91
+ AND subject_ref=current_setting('extension.subject_ref',true));
92
+ CREATE POLICY operation_scope ON extension_data.operations TO extension_app
93
+ USING(installation_id=current_setting('extension.installation_id',true) AND tenant_ref=current_setting('extension.tenant_ref',true)
94
+ AND subject_ref=current_setting('extension.subject_ref',true))
95
+ WITH CHECK(installation_id=current_setting('extension.installation_id',true) AND tenant_ref=current_setting('extension.tenant_ref',true)
96
+ AND subject_ref=current_setting('extension.subject_ref',true));
97
+ REVOKE ALL ON ALL TABLES IN SCHEMA extension_data FROM PUBLIC;
98
+ GRANT USAGE ON SCHEMA extension_data TO extension_app;
99
+ GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA extension_data TO extension_app;
100
+ `
101
+ const schema = (properties, required = Object.keys(properties)) => ({ type: 'object', additionalProperties: false, properties, required })
102
+ const counterSchema = schema({ value: { type: 'integer', minimum: 0, maximum: 1000000 } })
103
+ const readme = `# Custom counter extension
104
+
105
+ This project owns its custom data. Atelier generated files only; no account, database,
106
+ process, listener, deployment or root installation was created.
107
+
108
+ 1. Install the SDK and run npm run typecheck. Keep the generated lockfile.
109
+ The candidate SDK 0.2.0-alpha.15 is unpublished. Until its release, run
110
+ npm pack --ignore-scripts in the reviewed Atelier checkout and install its
111
+ resulting tarball here with npm install /absolute/path/to/mnstry-atelier-0.2.0-alpha.15.tgz.
112
+ After reviewing that dependency edit, run atelier extension seal --directory .
113
+ Once the pinned SDK version is published, ordinary npm install can resolve it.
114
+ 2. Select a customer-owned PostgreSQL 17 database. Create a restricted extension_app
115
+ login and apply migrations/001-initial.sql with a separate migration role.
116
+ Keep its DSN in your backend secret store. Do not use the root runtime database.
117
+ 3. Call createBackend from src/backend.ts in your own host. Supply the pinned manifest,
118
+ verified root public keys and installation binding. Mount handler and health using
119
+ your host's lifecycle. This generated module never starts a server.
120
+ 4. Register the endpoint through your studio's supported connection workflow. Use the
121
+ authenticated admin SDK to configure, verify and enable its exact release. Installation
122
+ does not grant member consent or paid-product access.
123
+ 5. Supply your supported host's authenticated fetch to createHttpHostTransport. Invoke
124
+ counter.set/get/export/delete with the installation generation and release digest.
125
+ Record a result only after the member permits that separate root action.
126
+ 6. When a reply is lost, use lookupOperation/recoverOperation. Never automatically
127
+ replay the write or refresh a stale generation. Disable before upgrading; verify
128
+ the new release before enabling it. Retain-data removal and explicit deletion differ.
129
+
130
+ Atelier keeps this component under the existing non-executing extension-pack system.
131
+ Declare atelier.extension-pack.json in your project's existing
132
+ ext["mnstry.atelier"].extensionPacks, then use the normal pack validation and lock
133
+ adoption workflow. Its pack identity matches the extension ID. The pack's runtime
134
+ manifest reference is a digest-bound projection; loading it never runs the backend
135
+ or grants root access. Init does not edit another project's configuration or lock.
136
+ Run atelier extension validate --directory . after edits. Run atelier extension seal
137
+ --directory . to regenerate source/schema/release digests after reviewing those edits.
138
+ Sealing records bytes; it does not establish trust, deployment, health or qualification.
139
+ The sample deletes its live rows and earlier provider receipts; backup retention remains
140
+ the operator's responsibility. Provider qualification and actual Web/native acceptance
141
+ are separate from scaffolding. Drizzle and Prisma have native project profiles. The native Convex starter has separate provider and host qualification requirements.
142
+ `
143
+ async function scaffoldProject({ directory, extensionId = 'sample.counter', profile = 'postgres-sql' }) {
144
+ if(!['postgres-sql','drizzle-db-first','drizzle-code-first','prisma-7-postgres','convex-native'].includes(profile))throw new Error('unsupported profile')
145
+ if (extensionId.length > 128 || !/^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/.test(extensionId) || RESERVED_NAMESPACE_PATTERN.test(extensionId.split('.')[0])) throw new Error('invalid extension id')
146
+ const target = path.resolve(directory)
147
+ await mkdir(target) // Exclusive reservation: an existing directory is never adopted or overwritten.
148
+ const identity = await lstat(target)
149
+ try {
150
+ const packageRoot = fileURLToPath(new URL('../..', import.meta.url))
151
+ const kit = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'))
152
+ const files = {
153
+ 'package.json': JSON.stringify({ name: extensionId, version: '1.0.0', private: true, type: 'module',
154
+ files: ['src','tsconfig.json'], scripts: { typecheck: 'tsc --noEmit' }, dependencies: { '@mnstry/atelier': kit.version, postgres: '3.4.7' },
155
+ devDependencies: { typescript: '5.8.3', '@types/node': '22.18.0' }, engines: { node: '>=22.18.0 <23' } }, null, 2) + '\n',
156
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { strict: true, target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', noEmit: true, skipLibCheck: true }, include: ['src/**/*.ts'] }, null, 2) + '\n',
157
+ 'src/backend.ts': backend, 'migrations/001-initial.sql': migration, 'README.md': readme,
158
+ '.gitignore': 'node_modules/\n.env\n.env.*\n!env.example\n', 'env.example': '# Backend secret reference only; do not commit credentials.\nEXTENSION_DATABASE_URL=\n',
159
+ }
160
+ if(profile!=='postgres-sql' && profile!=='convex-native') {
161
+ const provider=sqlProjectFiles(profile,backend,migration)
162
+ Object.assign(files,provider.files)
163
+ const pkg=JSON.parse(files['package.json'])
164
+ delete pkg.dependencies.postgres
165
+ Object.assign(pkg.dependencies,provider.dependencies)
166
+ Object.assign(pkg.devDependencies,provider.devDependencies)
167
+ Object.assign(pkg.scripts,provider.scripts)
168
+ files['package.json']=JSON.stringify(pkg,null,2)+'\n'
169
+ files['README.md']=provider.instructions+'\n\n'+readme.replace('login and apply migrations/001-initial.sql with a separate migration role.','login and apply the reviewed native-profile migrations described above.')
170
+ }
171
+ if(profile==='convex-native') {
172
+ const provider=convexProjectFiles()
173
+ delete files['src/backend.ts']
174
+ delete files['migrations/001-initial.sql']
175
+ Object.assign(files,provider.files)
176
+ const pkg=JSON.parse(files['package.json'])
177
+ delete pkg.dependencies.postgres
178
+ Object.assign(pkg.dependencies,provider.dependencies)
179
+ pkg.files.push('convex','convex.json','migrations')
180
+ pkg.scripts.typecheck='tsc --noEmit && tsc --noEmit -p convex/tsconfig.json'
181
+ files['package.json']=JSON.stringify(pkg,null,2)+'\n'
182
+ files['.gitignore']+='convex/_generated/\n.convex/\n'
183
+ files['migrations/001-initial.json']=JSON.stringify({schemaVersion:'1',profile:'convex-native',strategy:'native-declarative-schema',backfill:'none'},null,2)+'\n'
184
+ }
185
+ const resultSchemas = {
186
+ set: counterSchema, get: counterSchema,
187
+ export: schema({ schema: { const: 'sample.counter-export/v1' }, value: counterSchema.properties.value }),
188
+ delete: schema({ deleted: { type: 'integer', minimum: 0, maximum: 1 }, retention: { const: 'provider-live-rows-deleted' }, backups: { const: 'operator-policy' } }),
189
+ lookup: { anyOf: [{ type: 'null' }, schema({ schema: { const: 'mnstry.extension-provider-operation/v1' },
190
+ installationId: { type: 'string', pattern: '^[A-Za-z0-9_-]{16,128}$' }, operationId: { type: 'string', pattern: '^[A-Za-z0-9_-]{16,128}$' },
191
+ actionId: { type: 'string' }, generation: { type: 'string', pattern: '^[1-9][0-9]{0,18}$' },
192
+ releaseDigest: { type: 'string', pattern: '^[a-f0-9]{64}$' }, payloadDigest: { type: 'string', pattern: '^[a-f0-9]{64}$' },
193
+ resultDigest: { type: 'string', pattern: '^[a-f0-9]{64}$' }, result: {} })] },
194
+ }
195
+ for (const action of ['set', 'get', 'export', 'delete', 'lookup']) {
196
+ const input = action === 'set' ? counterSchema : action === 'lookup' ? schema({ operationId: { type: 'string', pattern: '^[A-Za-z0-9_-]{16,128}$' } }) : schema({})
197
+ files[`schemas/counter-${action}-input.json`] = JSON.stringify(input, null, 2) + '\n'
198
+ files[`schemas/counter-${action}-output.json`] = JSON.stringify(resultSchemas[action], null, 2) + '\n'
199
+ }
200
+ for (const [relative, contents] of Object.entries(files)) {
201
+ const file = path.join(target, relative); await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, contents, { flag: 'wx' })
202
+ }
203
+ const manifest = { schema: 'mnstry.extension-manifest/v1', extensionId, releaseVersion: '1.0.0', releaseDigest: '0'.repeat(64), protocol: 'mnstry.extension/v1',
204
+ compatibility: { sdkMajor: 1, rootContracts: ['extension.result.record/v1'], hosts: ['web'] },
205
+ deployment: { profile, bundleDigest: '0'.repeat(64), schemaDigest: '0'.repeat(64) },
206
+ actions: ['set', 'get', 'export', 'delete', 'lookup'].map(action => ({ id: `counter.${action}`,
207
+ effect: ['set', 'delete'].includes(action) ? 'write' : 'read', scope: 'member',
208
+ inputSchemaRef: `schemas/counter-${action}-input.json`, outputSchemaRef: `schemas/counter-${action}-output.json`, timeoutMs: 10000 })),
209
+ recovery: { lookupActionId: 'counter.lookup' }, permissions: { rootReads: [], rootWrites: ['extension.result.record/v1'], egress: [] }, surfaces: [],
210
+ data: { classes: ['sample.counter'], retention: 'until-user-delete', exportVersion: 1, schemaVersion: '1', readableSchemaVersions: ['1'] } }
211
+ const result = await sealProject(target, manifest)
212
+ return { directory: target, profile, releaseDigest: result.releaseDigest, qualified: false }
213
+ } catch (error) {
214
+ const current = await lstat(target).catch(() => null)
215
+ if (current?.isDirectory() && current.dev === identity.dev && current.ino === identity.ino) await rm(target, { recursive: true })
216
+ throw error
217
+ }
218
+ }
219
+ async function boundedSourceBytes(file,expected) {
220
+ if(!expected.isFile()||expected.size>1024*1024)throw new Error('extension source file exceeds 1 MiB or is not regular')
221
+ const handle=await open(file,fsConstants.O_RDONLY|(fsConstants.O_NOFOLLOW??0)|(fsConstants.O_NONBLOCK??0))
222
+ try {
223
+ const actual=await handle.stat({bigint:true})
224
+ if(!actual.isFile()||actual.dev!==expected.dev||actual.ino!==expected.ino)throw new Error('extension source changed while opening')
225
+ const chunks=[];let length=0
226
+ for(;;) {
227
+ const buffer=Buffer.alloc(64*1024)
228
+ const {bytesRead}=await handle.read(buffer,0,buffer.length,null)
229
+ if(bytesRead===0)break
230
+ length+=bytesRead
231
+ if(length>1024*1024)throw new Error('extension source file exceeds 1 MiB')
232
+ chunks.push(buffer.subarray(0,bytesRead))
233
+ }
234
+ return Buffer.concat(chunks,length)
235
+ } finally { await handle.close() }
236
+ }
237
+ async function projectFiles(root, subpath, collectBytes) {
238
+ const result = new Map()
239
+ const base = await realpath(root)
240
+ const identity = stat => `${stat.dev}:${stat.ino}`
241
+ const metadata = new Set()
242
+ for(const name of [manifestName,packName]) {
243
+ try { const stat=await lstat(path.join(base,name),{bigint:true}); if(stat.isFile())metadata.add(identity(stat)) }
244
+ catch(error) { if(error.code!=='ENOENT')throw error }
245
+ }
246
+ async function checked(parts) {
247
+ let file=base, stat
248
+ for(const part of parts) {
249
+ const folded=part.toLowerCase()
250
+ if(!part||/[\\/\u0000-\u001f\u007f]/u.test(part)||part==='.'||part==='..'||folded==='node_modules'||
251
+ (part.startsWith('.')&&!['.gitignore','.gitattributes'].includes(folded)))
252
+ throw new Error('extension source contains an unsupported hidden or dependency path or filename')
253
+ file=path.join(file,part);stat=await lstat(file,{bigint:true})
254
+ if(stat.isSymbolicLink())throw new Error('extension project symlinks are unsupported')
255
+ }
256
+ if(await realpath(file)!==file)throw new Error('extension source path is outside its literal project path or has different casing')
257
+ if(stat.isFile()&&metadata.has(identity(stat)))throw new Error('extension source cannot include generated identity files')
258
+ return {file,stat,key:parts.join('/')}
259
+ }
260
+ async function walk(parts) {
261
+ const {file,stat,key}=await checked(parts)
262
+ if(stat.isDirectory()) {
263
+ for(const entry of (await readdir(file)).sort())await walk([...parts,entry])
264
+ } else if(stat.isFile()) {
265
+ if(result.has(key))throw new Error('extension source inputs contain a duplicate path')
266
+ const bytes=await boundedSourceBytes(file,stat)
267
+ if(identity((await checked(parts)).stat)!==identity(stat))throw new Error('extension source changed while reading')
268
+ result.set(key,hash(bytes))
269
+ collectBytes?.(key,bytes)
270
+ } else throw new Error('extension project requires regular files')
271
+ }
272
+ for(const relative of subpath)await walk(relative.split('/'))
273
+ return Object.fromEntries(result)
274
+ }
275
+ async function bundleFiles(target) {
276
+ const paths=['package.json','tsconfig.json','src']
277
+ const packageFile=path.join(target,'package.json')
278
+ const packageStat=await lstat(packageFile,{bigint:true})
279
+ if(!packageStat.isFile()||packageStat.size>1024*1024)throw new Error('extension package must be a bounded regular file')
280
+ const pkg=JSON.parse((await boundedSourceBytes(packageFile,packageStat)).toString('utf8'))
281
+ if(pkg.files!==undefined){
282
+ if(!Array.isArray(pkg.files)||pkg.files.length>128)throw new Error('invalid extension source inputs')
283
+ for(const file of pkg.files){
284
+ if(typeof file!=='string'||!/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(file)||
285
+ file.split('/').some(part=>part==='.'||part==='..'||part.startsWith('.'))||
286
+ [manifestName,packName].includes(file.toLowerCase())||file.split('/').some(part=>part.toLowerCase()==='node_modules'))
287
+ throw new Error('extension source inputs require literal project-relative paths')
288
+ paths.push(file)
289
+ }
290
+ }
291
+ for(const file of ['package-lock.json','npm-shrinkwrap.json','drizzle.config.ts','drizzle-introspect.config.ts','prisma.config.ts','prisma']) {
292
+ try { await lstat(path.join(target,file)); paths.push(file) }
293
+ catch(error) { if(error.code!=='ENOENT')throw error }
294
+ }
295
+ return projectFiles(target,[...new Set(paths)])
296
+ }
297
+ async function preflightPackSeal(target,manifest) {
298
+ const file=path.join(target,packName)
299
+ if(!(await lstat(file)).isFile())throw new Error('extension pack must be a regular file')
300
+ const original=await readFile(file,'utf8')
301
+ loadExtensionPacks(packProject(target,manifest),{verifyLock:false})
302
+ const preview=JSON.parse(original)
303
+ preview.ext ??= {}; preview.ext['mnstry.atelier'] ??= {}
304
+ preview.ext['mnstry.atelier'].runtimeManifest={schema:manifest.schema,path:manifestName,releaseDigest:manifest.releaseDigest,fileDigest:'0'.repeat(64)}
305
+ canonicalJson(preview)
306
+ return original
307
+ }
308
+ export async function sealProject(directory, initialManifest) {
309
+ const target = path.resolve(directory)
310
+ const manifestFile = path.join(target, manifestName)
311
+ if (!initialManifest && !(await lstat(manifestFile)).isFile()) throw new Error('extension manifest must be a regular file')
312
+ const originalManifest = initialManifest ? null : await readFile(manifestFile, 'utf8')
313
+ const manifest = parseManifest(initialManifest ?? JSON.parse(originalManifest))
314
+ const bundle = await bundleFiles(target)
315
+ const schemas = await projectFiles(target, ['schemas', 'migrations'])
316
+ manifest.deployment.bundleDigest = hash(canonicalJson(bundle)); manifest.deployment.schemaDigest = hash(canonicalJson(schemas))
317
+ manifest.releaseDigest = releaseDigest(manifest)
318
+ // Re-read before committing the digest; concurrent edits cannot produce a knowingly stale seal.
319
+ if (canonicalJson(bundle) !== canonicalJson(await bundleFiles(target)) ||
320
+ canonicalJson(schemas) !== canonicalJson(await projectFiles(target, ['schemas', 'migrations']))) throw new Error('extension changed during sealing')
321
+ const originalPack = initialManifest ? null : await preflightPackSeal(target,manifest)
322
+ const temporary = path.join(target, `.extension-seal-${randomUUID()}.json`)
323
+ try {
324
+ await writeFile(temporary, JSON.stringify(manifest, null, 2) + '\n', { flag: 'wx' })
325
+ if (initialManifest) {
326
+ // Creation keeps an exclusive manifest reservation as well.
327
+ await writeFile(manifestFile, await readFile(temporary), { flag: 'wx' })
328
+ } else {
329
+ if (!(await lstat(manifestFile)).isFile() || await readFile(manifestFile, 'utf8') !== originalManifest)
330
+ throw new Error('extension manifest changed during sealing')
331
+ if(await preflightPackSeal(target,manifest)!==originalPack)throw new Error('extension pack changed during sealing')
332
+ await rename(temporary, manifestFile)
333
+ }
334
+ } finally { await rm(temporary, { force: true }) }
335
+ await writePackProjection(target, manifest, Boolean(initialManifest))
336
+ return manifest
337
+ }
338
+ export async function validateProject(directory) {
339
+ const target = path.resolve(directory)
340
+ const manifestFile = path.join(target, manifestName)
341
+ if (!(await lstat(manifestFile)).isFile()) throw new Error('extension manifest must be a regular file')
342
+ const manifest = parseManifest(JSON.parse(await readFile(manifestFile, 'utf8')))
343
+ if (manifest.releaseDigest !== releaseDigest(manifest) || manifest.deployment.bundleDigest !== hash(canonicalJson(await bundleFiles(target))) ||
344
+ manifest.deployment.schemaDigest !== hash(canonicalJson(await projectFiles(target, ['schemas', 'migrations'])))) throw new Error('extension seal is stale')
345
+ for (const action of manifest.actions) {
346
+ for (const name of ['inputSchemaRef', 'outputSchemaRef']) JSON.parse(await readFile(path.join(target, action[name]), 'utf8'))
347
+ }
348
+ await validatePackProjection(target, manifest)
349
+ return { valid: true, profile: manifest.deployment.profile, extensionId: manifest.extensionId, releaseDigest: manifest.releaseDigest, qualified: false, packAdoption: 'pending' }
350
+ }
351
+
352
+ function packProject(target, manifest) {
353
+ return { configDir: target, config: { ext: { 'mnstry.atelier': { extensionPacks: [
354
+ { id: manifest.extensionId, version: 'v1', path: packName, enabled: true },
355
+ ] } } } }
356
+ }
357
+ async function runtimeReference(target, manifest) {
358
+ return { schema: manifest.schema, path: manifestName, releaseDigest: manifest.releaseDigest,
359
+ fileDigest: hash(await readFile(path.join(target,manifestName))) }
360
+ }
361
+ async function writePackProjection(target, manifest, initial) {
362
+ const file=path.join(target,packName)
363
+ let previous=null, pack
364
+ if (initial) {
365
+ pack={ schema:'mnstry-atelier-extension-pack@v1',id:manifest.extensionId,namespace:manifest.extensionId.split('.')[0],version:'v1',
366
+ terms:[],migrations:[],fixtures:[manifestName],lenses:[],readinessRules:[],actionRules:[],exportMappings:[] }
367
+ } else {
368
+ if (!(await lstat(file)).isFile()) throw new Error('extension pack must be a regular file')
369
+ previous=await readFile(file,'utf8'); pack=JSON.parse(previous)
370
+ loadExtensionPacks(packProject(target,manifest),{verifyLock:false})
371
+ }
372
+ pack.ext ??= {}; pack.ext['mnstry.atelier'] ??= {}
373
+ pack.ext['mnstry.atelier'].runtimeManifest=await runtimeReference(target,manifest)
374
+ const temporary=path.join(target,`.extension-pack-${randomUUID()}.json`)
375
+ try {
376
+ await writeFile(temporary,JSON.stringify(pack,null,2)+'\n',{flag:'wx'})
377
+ if(initial) await writeFile(file,await readFile(temporary),{flag:'wx'})
378
+ else {
379
+ if(!(await lstat(file)).isFile() || await readFile(file,'utf8')!==previous) throw new Error('extension pack changed during sealing')
380
+ await rename(temporary,file)
381
+ }
382
+ } finally { await rm(temporary,{force:true}) }
383
+ await validatePackProjection(target,manifest)
384
+ }
385
+ async function validatePackProjection(target,manifest) {
386
+ const file=path.join(target,packName)
387
+ if(!(await lstat(file)).isFile()) throw new Error('extension pack must be a regular file')
388
+ const report=loadExtensionPacks(packProject(target,manifest),{verifyLock:false})
389
+ if(report.packs.length!==1) throw new Error('extension pack projection is invalid')
390
+ const pack=JSON.parse(await readFile(file,'utf8'))
391
+ if(canonicalJson(pack.ext?.['mnstry.atelier']?.runtimeManifest)!==canonicalJson(await runtimeReference(target,manifest)))
392
+ throw new Error('extension pack runtime reference is stale')
393
+ }
394
+
395
+ export function scaffoldPostgres(options) { return scaffoldSqlProject({...options,profile:'postgres-sql'}) }
396
+
397
+
398
+ /** Export the exact sealed schema bytes and migration digests through the same
399
+ * checked file walk used by sealing. No new file inventory or host is created. */
400
+ export async function exportReleaseBundle(directory) {
401
+ const target=await realpath(path.resolve(directory))
402
+ await validateProject(target)
403
+ const manifestFile=path.join(target,manifestName), stat=await lstat(manifestFile,{bigint:true})
404
+ const original=(await boundedSourceBytes(manifestFile,stat)).toString('utf8')
405
+ const manifest=parseManifest(JSON.parse(original)), schemaFiles=new Map()
406
+ let schemaBytes=0
407
+ const digests=await projectFiles(target,['schemas','migrations'],(name,bytes)=>{
408
+ if(!name.startsWith('schemas/'))return
409
+ schemaBytes+=bytes.length
410
+ if(bytes.length>65536||schemaBytes>131072)throw new Error('extension registration schema bundle is too large')
411
+ const decoded=new TextDecoder('utf-8',{fatal:true}).decode(bytes)
412
+ // Fatal decoding rejects invalid UTF-8; round-trip equality also refuses
413
+ // normalization such as BOM removal before exact sealed bytes are exported.
414
+ if(!Buffer.from(decoded,'utf8').equals(bytes))throw new Error('extension schema encoding changes sealed bytes')
415
+ schemaFiles.set(name,decoded)
416
+ })
417
+ if(manifest.releaseDigest!==releaseDigest(manifest)||manifest.deployment.schemaDigest!==hash(canonicalJson(digests))||
418
+ manifest.deployment.bundleDigest!==hash(canonicalJson(await bundleFiles(target))))throw new Error('extension seal is stale')
419
+ for(const [name,bytes] of schemaFiles)if(hash(Buffer.from(bytes,'utf8'))!==digests[name])throw new Error('extension schema export differs from sealed bytes')
420
+ const migrationDigests=new Map(Object.entries(digests).filter(([name])=>name.startsWith('migrations/')))
421
+ const result=parseReleaseBundle({manifest,schemaFiles:Object.fromEntries(schemaFiles),migrationDigests:Object.fromEntries(migrationDigests)})
422
+ if((await boundedSourceBytes(manifestFile,await lstat(manifestFile,{bigint:true}))).toString('utf8')!==original||
423
+ canonicalJson(digests)!==canonicalJson(await projectFiles(target,['schemas','migrations'])))throw new Error('extension changed during export')
424
+ await validatePackProjection(target,manifest)
425
+ return result
426
+ }
427
+
428
+ export function scaffoldSqlProject(options) {
429
+ if(options?.profile==='convex-native')return Promise.reject(new Error('unsupported SQL profile'))
430
+ return scaffoldProject(options)
431
+ }
432
+ export function scaffoldConvexProject(options) { return scaffoldProject({...options,profile:'convex-native'}) }