@starci/hfs 4.2.12 → 4.2.14

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 (203) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +7 -3
  3. package/emit/db-types.mjs +2 -1
  4. package/package.json +1 -1
  5. package/runtime/knowledge/hfs/canon-pins.yaml +207 -15
  6. package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
  7. package/runtime/knowledge/hfs/rules.yaml +51 -48
  8. package/runtime/knowledge/hfs/slots.yaml +319 -179
  9. package/runtime/knowledge/patterns/be/event-bus.yaml +15 -0
  10. package/runtime/knowledge/patterns/be/jobs.yaml +10 -0
  11. package/runtime/knowledge/patterns/be/projections.yaml +10 -0
  12. package/runtime/knowledge/patterns/be/queues.yaml +15 -0
  13. package/runtime/knowledge/patterns/be/reactors.yaml +8 -0
  14. package/runtime/knowledge/patterns/be/realtime.yaml +17 -0
  15. package/runtime/knowledge/patterns/be/saga.yaml +13 -0
  16. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  17. package/runtime/knowledge/sonar-gate.yaml +10 -2
  18. package/runtime/modules/kernel/failure-codes.yaml +40 -60
  19. package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
  20. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  21. package/runtime/scripts/hfs/architecture/config.mjs +3 -2
  22. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  23. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  24. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  25. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  26. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  27. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  28. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  29. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  30. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  31. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  32. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  33. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  34. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  35. package/runtime/scripts/hfs/check.mjs +10 -6
  36. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  37. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  38. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  39. package/runtime/scripts/hfs/rules/database-constants.mjs +4 -0
  40. package/runtime/scripts/hfs/rules/database-migrations.mjs +12 -7
  41. package/runtime/scripts/hfs/rules/database.mjs +4 -3
  42. package/runtime/scripts/hfs/rules/edition.mjs +49 -37
  43. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  44. package/runtime/scripts/hfs/rules/fe-stack.mjs +71 -0
  45. package/scaffold/add-app.mjs +24 -18
  46. package/scaffold/add-cli-lite.mjs +33 -3
  47. package/scaffold/add-inbox-lite.mjs +24 -8
  48. package/scaffold/add-table.mjs +222 -74
  49. package/scaffold/add.mjs +85 -35
  50. package/scaffold/app.mjs +146 -38
  51. package/scaffold/edition-gate.mjs +21 -2
  52. package/scaffold/lite-exports.mjs +30 -1
  53. package/scaffold/service.mjs +5 -8
  54. package/src/main.mjs +2 -2
  55. package/sync/index.mjs +19 -15
  56. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +14 -0
  57. package/templates/app/codegen-graphql.mjs.partial +34 -0
  58. package/templates/app/codegen-lite/scripts/codegen.mjs +18 -13
  59. package/templates/app/package-scripts/core.json +1 -0
  60. package/templates/app/package-scripts-lite/package.json +4 -0
  61. package/templates/app/skeleton/README.md +1 -1
  62. package/templates/app/skeleton/scripts/codegen.mjs +4 -1
  63. package/templates/app/skeleton-lite/README.md +7 -4
  64. package/templates/app/skeleton-lite/supabase/config.toml +1 -1
  65. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +13 -14
  66. package/templates/be/inbox/database-client.spec.ts.tpl +47 -0
  67. package/templates/be/inbox/service.spec.ts.tpl +46 -0
  68. package/templates/be/inbox/webhook-door.spec.ts.tpl +76 -0
  69. package/templates/be/patterns/api/graphql.module.ts.tpl +2 -2
  70. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +6 -3
  71. package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +52 -0
  72. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +31 -0
  73. package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +43 -0
  74. package/templates/be/platform/graphql/graphql-depth.policy.ts +21 -0
  75. package/templates/be/platform/graphql/graphql.contracts.ts +12 -0
  76. package/templates/be/platform/graphql/graphql.module-definition.ts +7 -0
  77. package/templates/be/platform/graphql/graphql.module.ts +49 -0
  78. package/templates/be/platform/graphql/graphql.options.ts +2 -0
  79. package/templates/be/platform/graphql/index.ts +1 -0
  80. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +114 -0
  81. package/templates/be/platform/graphql/localize-errors.mapper.ts +51 -0
  82. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +11 -0
  83. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +8 -3
  84. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +13 -1
  85. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +11 -2
  86. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +21 -0
  87. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +94 -0
  88. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +2 -4
  89. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +10 -0
  90. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +2 -2
  91. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +2 -6
  92. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +15 -0
  93. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  94. package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +1 -1
  95. package/templates/be/table/api/create.command.ts.tpl +10 -0
  96. package/templates/be/table/api/create.contracts.ts.tpl +8 -0
  97. package/templates/be/table/api/create.handler.ts.tpl +22 -0
  98. package/templates/be/table/api/create.mapper.ts.tpl +5 -0
  99. package/templates/be/table/api/create.resolver.ts.tpl +21 -0
  100. package/templates/be/table/api/create.type.ts.tpl +9 -0
  101. package/templates/be/table/api/feature.index.ts.tpl +1 -0
  102. package/templates/be/table/api/feature.module.ts.tpl +7 -0
  103. package/templates/be/table/api/get.contracts.ts.tpl +11 -0
  104. package/templates/be/table/api/get.handler.ts.tpl +22 -0
  105. package/templates/be/table/api/get.input.ts.tpl +12 -0
  106. package/templates/be/table/api/get.mapper.ts.tpl +9 -0
  107. package/templates/be/table/api/get.query.ts.tpl +10 -0
  108. package/templates/be/table/api/get.resolver.ts.tpl +27 -0
  109. package/templates/be/table/api/get.type.ts.tpl +9 -0
  110. package/templates/be/table/api/graphql.module.ts.tpl +8 -0
  111. package/templates/be/table/error.api.ts.tpl +19 -0
  112. package/templates/be/table/migration.sql.tpl +13 -21
  113. package/templates/be/table/service.api.ts.tpl +30 -0
  114. package/templates/be/table/sql.api.ts.tpl +7 -0
  115. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/index.tsx +16 -18
  116. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignOutButton/component.tsx +7 -3
  117. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignOutButton/index.tsx +23 -0
  118. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +49 -0
  119. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +4 -0
  120. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -0
  121. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +29 -0
  122. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +27 -0
  123. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/index.ts +1 -0
  124. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/useAuthSessionEffect.ts +14 -0
  125. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/SwrSideEffects.tsx +8 -0
  126. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/index.ts +1 -0
  127. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/index.ts +1 -0
  128. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/types.ts +26 -0
  129. package/templates/fe/skeleton/apps/__app__/src/modules/api/index.ts +1 -0
  130. package/templates/fe/skeleton/apps/__app__/src/modules/auth/client.ts +21 -0
  131. package/templates/fe/skeleton/apps/__app__/src/modules/auth/contracts.ts +27 -0
  132. package/templates/fe/skeleton/apps/__app__/src/modules/auth/index.ts +2 -0
  133. package/templates/fe/skeleton/apps/__app__/src/modules/toast/api.ts +52 -0
  134. package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +33 -0
  135. package/templates/fe/skeleton/apps/__app__/src/modules/toast/index.ts +1 -0
  136. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +27 -0
  137. package/templates/fe/skeleton/apps/__app__/src/modules/types/ambient/styles.d.ts +2 -0
  138. package/templates/fe/skeleton/apps/__app__/src/redux/ReduxProvider.tsx +11 -0
  139. package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +8 -0
  140. package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +3 -0
  141. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +26 -0
  142. package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +14 -0
  143. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +5 -2
  144. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +10 -2
  145. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +1 -1
  146. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +23 -0
  147. package/templates/fe/skeleton/apps/landing/src/modules/types/ambient/styles.d.ts +2 -0
  148. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +51 -0
  149. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/providers.tsx +10 -13
  150. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +8 -5
  151. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -17
  152. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +3 -10
  153. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +0 -6
  154. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +11 -8
  155. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +0 -3
  156. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +9 -25
  157. package/templates/fe/table/graphql/mutation.ts.tpl +25 -0
  158. package/templates/fe/table/graphql/mutation.types.ts.tpl +7 -0
  159. package/templates/fe/table/graphql/query.ts.tpl +25 -0
  160. package/templates/fe/table/graphql/query.types.ts.tpl +7 -0
  161. package/templates/fe/table/graphql/useMutateSwr.ts.tpl +21 -0
  162. package/templates/fe/table/graphql/useQuerySwr.ts.tpl +17 -0
  163. package/templates/fe/transport/graphql/clients/clients/auth.ts +39 -0
  164. package/templates/fe/transport/graphql/clients/clients/index.ts +2 -0
  165. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +33 -0
  166. package/templates/fe/transport/graphql/clients/index.ts +2 -0
  167. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +9 -0
  168. package/templates/fe/transport/graphql/clients/links/error.ts +30 -0
  169. package/templates/fe/transport/graphql/clients/links/http.ts +16 -0
  170. package/templates/fe/transport/graphql/clients/links/index.ts +8 -0
  171. package/templates/fe/transport/graphql/clients/links/locale.ts +13 -0
  172. package/templates/fe/transport/graphql/clients/links/options.ts +17 -0
  173. package/templates/fe/transport/graphql/clients/links/refresh-token.ts +36 -0
  174. package/templates/fe/transport/graphql/clients/links/retry.ts +16 -0
  175. package/templates/fe/transport/graphql/clients/links/timeout.ts +28 -0
  176. package/templates/fe/transport/graphql/clients/response.ts +43 -0
  177. package/upgrade/check.mjs +1 -1
  178. package/upgrade/index.mjs +3 -2
  179. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
  180. package/templates/app/quality-config-lite/sonar-project.properties +0 -5
  181. package/templates/be/patterns/api/feature.index.http.ts.tpl +0 -1
  182. package/templates/be/patterns/api/http.controller.ts.tpl +0 -27
  183. package/templates/be/patterns/api/http.mapper.ts.tpl +0 -9
  184. package/templates/be/patterns/api/http.module.ts.tpl +0 -7
  185. package/templates/be/patterns/api/http.request.ts.tpl +0 -10
  186. package/templates/be/patterns/api/http.response.ts.tpl +0 -5
  187. package/templates/fe/skeleton-lite/apps/web/src/app/auth/callback/route.ts +0 -24
  188. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignOutButton/index.tsx +0 -38
  189. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/read-session.ts +0 -25
  190. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-in.ts +0 -25
  191. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +0 -17
  192. package/templates/fe/skeleton-lite/apps/web/src/modules/db/browser.ts +0 -14
  193. package/templates/fe/skeleton-lite/apps/web/src/modules/db/index.ts +0 -3
  194. package/templates/fe/skeleton-lite/apps/web/src/modules/db/outcome.ts +0 -59
  195. package/templates/fe/skeleton-lite/apps/web/src/modules/db/principal.ts +0 -29
  196. package/templates/fe/skeleton-lite/apps/web/src/modules/db/server.ts +0 -70
  197. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +0 -58
  198. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/safe-next-path.ts +0 -26
  199. package/templates/fe/table/read.ts.tpl +0 -16
  200. package/templates/fe/table/write.ts.tpl +0 -16
  201. /package/templates/be/patterns/api/{application.command.http.ts.tpl → application.command.principal.ts.tpl} +0 -0
  202. /package/templates/be/patterns/api/{application.handler.http.ts.tpl → application.handler.principal.ts.tpl} +0 -0
  203. /package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/component.tsx +0 -0
@@ -7,7 +7,9 @@ import {
7
7
  } from "../runtime/scripts/hfs/slots.mjs";
8
8
  import { render, TEMPLATES_DIR } from "../sync/index.mjs";
9
9
  import { ScaffoldError, pascalOf } from "./service.mjs";
10
- import { registerLiteExports } from "./lite-exports.mjs";
10
+ import { filesBelow, registerLiteDomainService, registerLiteExports, registrationFiles } from "./lite-exports.mjs";
11
+ import { byCodeUnit } from "../report/order.mjs";
12
+ import { coreFeApps } from "./app.mjs";
11
13
 
12
14
  const NAME = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
13
15
  const MIGRATION = /^(\d{14})_[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.sql$/;
@@ -158,34 +160,168 @@ function removeEmptyParents(file, stop) {
158
160
  }
159
161
  }
160
162
 
161
- function wireApiModule(root, app, name) {
162
- const relative = `be/apps/${app}/src/app.module.ts`;
163
- const file = path.join(root, ...relative.split("/"));
164
- if (!fs.existsSync(file))
165
- throw new ScaffoldError("HFS_ADD_WIRE_MISSING", `add table registers ${name} in ${relative}, which does not exist`);
166
- const symbol = `${pascalOf(name)}Module`;
167
- const errorKinds = `${snakeOf(name).toUpperCase()}_ERROR_KINDS`;
168
- const importLine = `import { ${errorKinds}, ${symbol} } from "@modules/domain/${name}"`;
169
- const lines = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n").split("\n");
163
+ /** The app-root relative path of a file under the app (posix). */
164
+ const at = (root, relative) => path.join(root, ...relative.split("/"));
165
+
166
+ /**
167
+ * Registers `entry` in the `imports` array of a Nest module file (one import line after the last import, one array entry right
168
+ * after `imports: [`), unless `symbol` is already imported. Returns the new text.
169
+ */
170
+ function wireImport(text, relative, { importLine, symbol, entry, kinds }) {
171
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
172
+ if (lines.some((line) => line.startsWith("import ") && new RegExp(`\\b${symbol}\\b`).test(line))) return text;
170
173
  const lastImport = lines.reduce((last, line, index) => (line.startsWith("import ") ? index : last), -1);
174
+ if (lastImport < 0 || lines.findIndex((line) => /^\s*imports: \[$/.test(line)) < 0)
175
+ throw new ScaffoldError("HFS_ADD_WIRE_INVALID", `add table could not find the imports array in ${relative}`);
176
+ lines.splice(lastImport + 1, 0, importLine);
171
177
  const imports = lines.findIndex((line) => /^\s*imports: \[$/.test(line));
172
- const kinds = lines.findIndex((line) => /^\s*kinds: \[$/.test(line));
173
- if (lastImport < 0 || imports < 0 || kinds < 0)
174
- throw new ScaffoldError("HFS_ADD_WIRE_INVALID", `add table could not find the imports and error kinds arrays in ${relative}`);
175
- if (!lines.includes(importLine)) {
176
- lines.splice(lastImport + 1, 0, importLine);
177
- const shiftedImports = lines.findIndex((line) => /^\s*imports: \[$/.test(line));
178
- const indent = /^(\s*)/.exec(lines[shiftedImports])?.[1] ?? "";
179
- lines.splice(shiftedImports + 1, 0, `${indent} ${symbol}.register({ isGlobal: true }),`);
180
- const shiftedKinds = lines.findIndex((line) => /^\s*kinds: \[$/.test(line));
181
- const kindsIndent = /^(\s*)/.exec(lines[shiftedKinds])?.[1] ?? "";
182
- lines.splice(shiftedKinds + 1, 0, `${kindsIndent} ${errorKinds},`);
178
+ const indent = /^(\s*)/.exec(lines[imports])?.[1] ?? "";
179
+ lines.splice(imports + 1, 0, `${indent} ${entry},`);
180
+ if (kinds !== undefined) {
181
+ const kindsAt = lines.findIndex((line) => /^\s*kinds: \[$/.test(line));
182
+ if (kindsAt < 0) throw new ScaffoldError("HFS_ADD_WIRE_INVALID", `add table could not find the error kinds array in ${relative}`);
183
+ const kindsIndent = /^(\s*)/.exec(lines[kindsAt])?.[1] ?? "";
184
+ lines.splice(kindsAt + 1, 0, `${kindsIndent} ${kinds},`);
183
185
  }
184
- fs.writeFileSync(file, `${lines.join("\n").replace(/\n+$/, "")}\n`);
186
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
185
187
  }
186
188
 
189
+ /** The api app root after the table's domain module is registered (its module and error kinds). */
190
+ const wireDomainModule = (text, relative, name) =>
191
+ wireImport(text, relative, {
192
+ importLine: `import { ${snakeOf(name).toUpperCase()}_ERROR_KINDS, ${pascalOf(name)}Module } from "@modules/domain/${name}"`,
193
+ symbol: `${pascalOf(name)}Module`,
194
+ entry: `${pascalOf(name)}Module.register({ isGlobal: true })`,
195
+ kinds: `${snakeOf(name).toUpperCase()}_ERROR_KINDS`,
196
+ });
197
+
198
+ /** The api app root after the GraphQL server (platform/graphql) and the table's GraphQL transport are registered. */
199
+ const wireGraphqlFeature = (text, relative, name) =>
200
+ wireImport(
201
+ wireImport(text, relative, {
202
+ importLine: 'import { GraphqlModule } from "@modules/platform/graphql"',
203
+ symbol: "GraphqlModule",
204
+ entry: "GraphqlModule.register({ isGlobal: true })",
205
+ }),
206
+ relative,
207
+ {
208
+ importLine: `import { ${pascalOf(name)}GraphqlModule } from "@features/api/${name}"`,
209
+ symbol: `${pascalOf(name)}GraphqlModule`,
210
+ entry: `${pascalOf(name)}GraphqlModule`,
211
+ },
212
+ );
213
+
214
+ /** The platform GraphQL server capability (templates/be/platform/graphql), whole or absent: written once, by the first GraphQL feature. */
215
+ const GRAPHQL_PLATFORM = "be/src/modules/platform/graphql";
216
+ /** The GraphQL error formatter the errors capability owns (BE_ERROR_MASKED: the registration passes platform/errors' formatter). */
217
+ const ERRORS_PLATFORM = "be/src/modules/platform/errors";
218
+ /** Every file the first GraphQL feature writes: platform/graphql whole and the GraphQL formatter of platform/errors. */
219
+ export const graphqlPlatformFiles = () =>
220
+ [
221
+ ["graphql", GRAPHQL_PLATFORM],
222
+ ["errors-graphql", ERRORS_PLATFORM],
223
+ ].flatMap(([templates, target]) => {
224
+ const dir = path.join(TEMPLATES_DIR, "be", "platform", templates);
225
+ return fs.readdirSync(dir).sort().map((file) => ({
226
+ relative: `${target}/${file}`,
227
+ body: fs.readFileSync(path.join(dir, file), "utf8").replace(/\r\n/g, "\n"),
228
+ }));
229
+ });
230
+
231
+ /**
232
+ * The fe GraphQL transport of one product app (templates/fe/transport/graphql/clients): the auth and no-auth Apollo clients,
233
+ * their link chain and the GraphQLResponse mapping, whole or absent, written once by the first GraphQL operation of the app,
234
+ * as platform/graphql is on the back end. A new app has no operation, so it carries no client.
235
+ */
236
+ const feTransportFiles = (app) => {
237
+ const dir = path.join(TEMPLATES_DIR, "fe", "transport", "graphql", "clients");
238
+ return filesBelow(dir)
239
+ .map((file) => path.relative(dir, file).split(path.sep).join("/"))
240
+ .sort(byCodeUnit)
241
+ .map((rel) => ({
242
+ relative: `fe/apps/${app}/src/modules/api/graphql/clients/${rel}`,
243
+ body: fs.readFileSync(path.join(dir, ...rel.split("/")), "utf8").replace(/\r\n/g, "\n"),
244
+ }));
245
+ };
246
+
247
+ /** The env reader the transport's http link calls (modules/config, the app's one process.env reader), added with the transport. */
248
+ const GRAPHQL_URL_READER = [
249
+ "/** The back end's GraphQL endpoint, the terminal link of modules/api; throws when `NEXT_PUBLIC_GRAPHQL_URL` is not set. */",
250
+ 'export const graphqlUrl = (): string => required("NEXT_PUBLIC_GRAPHQL_URL", process.env.NEXT_PUBLIC_GRAPHQL_URL)',
251
+ ].join("\n");
252
+
253
+ /** An app's modules/config text with graphqlUrl appended once. */
254
+ const withGraphqlUrl = (text) =>
255
+ /^export const graphqlUrl\b/m.test(text) ? text : `${text.replace(/\s*$/, "")}\n${GRAPHQL_URL_READER}\n`;
256
+
257
+ /** The be api feature of a table (owner ruling 2026-10-13, lite = full): get-<singular> and create-<singular> over GraphQL. */
258
+ const apiFeatureFiles = (values) => {
259
+ const base = `be/src/features/api/${values.name}`;
260
+ const get = `get-${values.singular}`;
261
+ const create = `create-${values.singular}`;
262
+ return [
263
+ [`${base}/index.ts`, "feature.index.ts.tpl"],
264
+ [`${base}/${values.name}.module.ts`, "feature.module.ts.tpl"],
265
+ [`${base}/application/${get}.query.ts`, "get.query.ts.tpl"],
266
+ [`${base}/application/${get}.handler.ts`, "get.handler.ts.tpl"],
267
+ [`${base}/application/${get}.contracts.ts`, "get.contracts.ts.tpl"],
268
+ [`${base}/application/${create}.command.ts`, "create.command.ts.tpl"],
269
+ [`${base}/application/${create}.handler.ts`, "create.handler.ts.tpl"],
270
+ [`${base}/application/${create}.contracts.ts`, "create.contracts.ts.tpl"],
271
+ [`${base}/transport/graphql/${get}.resolver.ts`, "get.resolver.ts.tpl"],
272
+ [`${base}/transport/graphql/${get}.mapper.ts`, "get.mapper.ts.tpl"],
273
+ [`${base}/transport/graphql/dto/${get}.input.ts`, "get.input.ts.tpl"],
274
+ [`${base}/transport/graphql/dto/${get}.type.ts`, "get.type.ts.tpl"],
275
+ [`${base}/transport/graphql/${create}.resolver.ts`, "create.resolver.ts.tpl"],
276
+ [`${base}/transport/graphql/${create}.mapper.ts`, "create.mapper.ts.tpl"],
277
+ [`${base}/transport/graphql/dto/${create}.type.ts`, "create.type.ts.tpl"],
278
+ [`${base}/transport/graphql/${values.name}-graphql.module.ts`, "graphql.module.ts.tpl"],
279
+ ].map(([relative, file]) => ({ relative, body: template(`be/table/api/${file}`, values) }));
280
+ };
281
+
282
+ /**
283
+ * The fe data code of a table in one product app: one GraphQL operation file per operation (modules/api/graphql/{queries,
284
+ * mutations}, its request/data types from the generated wire types) and one SWR hook per operation (hooks/swr/api/graphql).
285
+ * Never a Server Action and never a Supabase reader: Supabase on the front end is the Auth client only.
286
+ */
287
+ const feOperationFiles = (app, values) => {
288
+ const api = `fe/apps/${app}/src/modules/api/graphql`;
289
+ const swr = `fe/apps/${app}/src/hooks/swr/api/graphql`;
290
+ return [
291
+ [`${api}/queries/query-${values.singular}.ts`, "query.ts.tpl"],
292
+ [`${api}/queries/types/${values.singular}.ts`, "query.types.ts.tpl"],
293
+ [`${api}/mutations/mutation-create-${values.singular}.ts`, "mutation.ts.tpl"],
294
+ [`${api}/mutations/types/create-${values.singular}.ts`, "mutation.types.ts.tpl"],
295
+ [`${swr}/queries/useQuery${values.Singular}Swr.ts`, "useQuerySwr.ts.tpl"],
296
+ [`${swr}/mutations/useMutateCreate${values.Singular}Swr.ts`, "useMutateSwr.ts.tpl"],
297
+ ].map(([relative, file]) => ({ relative, body: template(`fe/table/graphql/${file}`, values) }));
298
+ };
299
+
300
+ /** The barrel lines that make one table's SWR hooks reachable from hooks/swr (each barrel created when absent). */
301
+ const feBarrels = (app, values) => {
302
+ const swr = `fe/apps/${app}/src/hooks/swr`;
303
+ return [
304
+ [`${swr}/api/graphql/queries/index.ts`, `export { QUERY_${values.singularUpper}_SWR_KEY, useQuery${values.Singular}Swr } from "./useQuery${values.Singular}Swr"`],
305
+ [`${swr}/api/graphql/mutations/index.ts`, `export { MUTATE_CREATE_${values.singularUpper}_SWR_KEY, useMutateCreate${values.Singular}Swr } from "./useMutateCreate${values.Singular}Swr"`],
306
+ [`${swr}/api/graphql/index.ts`, 'export * from "./mutations"'],
307
+ [`${swr}/api/graphql/index.ts`, 'export * from "./queries"'],
308
+ [`${swr}/api/index.ts`, 'export * from "./graphql"'],
309
+ // the owner's public entry names its hooks explicitly (ARCH_OWNER_EXPORT_STAR); the inner barrels may re-export whole
310
+ [`${swr}/index.ts`, `export { QUERY_${values.singularUpper}_SWR_KEY, useQuery${values.Singular}Swr } from "./api"`],
311
+ [`${swr}/index.ts`, `export { MUTATE_CREATE_${values.singularUpper}_SWR_KEY, useMutateCreate${values.Singular}Swr } from "./api"`],
312
+ ];
313
+ };
314
+
315
+ /** A barrel text with `line` added once, kept in code-unit order. */
316
+ const withExport = (text, line) => {
317
+ const lines = text.replace(/\r\n/g, "\n").split("\n").filter(Boolean);
318
+ if (lines.includes(line)) return text;
319
+ return `${[...lines, line].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join("\n")}\n`;
320
+ };
321
+
187
322
  /**
188
- * Adds one Supabase table migration and, with `fe`, one typed reader/writer pair to every declared Next app.
323
+ * Adds one Supabase table migration and its domain capability; with `fe`, the table's api feature over GraphQL (the GraphQL
324
+ * server capability on first use) and, in every product Next app, its operation files and SWR hooks.
189
325
  * `emitTypes` returns the generated database.types.ts text; false is the explicit `--no-types` seam.
190
326
  */
191
327
  export function addTable({
@@ -233,6 +369,7 @@ export function addTable({
233
369
  `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`,
234
370
  );
235
371
 
372
+ const singular = singularOf(feature);
236
373
  const values = {
237
374
  name: feature,
238
375
  table,
@@ -240,57 +377,51 @@ export function addTable({
240
377
  Name: pascalOf(feature),
241
378
  nameCamel: camelOf(feature),
242
379
  upper: table.toUpperCase(),
243
- singular: singularOf(feature),
244
- Singular: pascalOf(singularOf(feature)),
380
+ singular,
381
+ Singular: pascalOf(singular),
382
+ singularCamel: camelOf(singular),
383
+ singularUpper: snakeOf(singular).toUpperCase(),
245
384
  };
385
+ const apps = fe ? coreFeApps(repo.sides?.fe?.apps ?? []) : [];
386
+ if (fe && !apps.length)
387
+ throw new ScaffoldError(
388
+ "HFS_ADD_NO_FRONT_END",
389
+ "add table --fe needs at least one declared product Next app",
390
+ );
391
+ const api = fe ? ".api" : "";
392
+ const domain = `be/src/modules/domain/${feature}`;
246
393
  const planned = [
247
394
  {
248
395
  relative: `supabase/migrations/${stamp}_${feature}.sql`,
249
396
  body: template("be/table/migration.sql.tpl", values),
250
397
  },
251
- { relative: `be/src/modules/domain/${feature}/index.ts`, body: template("be/table/index.ts.tpl", values) },
252
- { relative: `be/src/modules/domain/${feature}/errors/${feature}.error.ts`, body: template("be/table/error.ts.tpl", values) },
253
- { relative: `be/src/modules/domain/${feature}/${feature}.module.ts`, body: template("be/table/module.ts.tpl", values) },
254
- { relative: `be/src/modules/domain/${feature}/${feature}.module-definition.ts`, body: template("be/table/module-definition.ts.tpl", values) },
255
- { relative: `be/src/modules/domain/${feature}/${feature}.options.ts`, body: template("be/table/options.ts.tpl", values) },
256
- { relative: `be/src/modules/domain/${feature}/${feature}.service.ts`, body: template("be/table/service.ts.tpl", values) },
257
- { relative: `be/src/modules/domain/${feature}/persistence/${feature}.rows.ts`, body: template("be/table/rows.ts.tpl", values) },
258
- { relative: `be/src/modules/domain/${feature}/persistence/${feature}.sql.ts`, body: template("be/table/sql.ts.tpl", values) },
398
+ { relative: `${domain}/index.ts`, body: template("be/table/index.ts.tpl", values) },
399
+ { relative: `${domain}/errors/${feature}.error.ts`, body: template(`be/table/error${api}.ts.tpl`, values) },
400
+ { relative: `${domain}/${feature}.module.ts`, body: template("be/table/module.ts.tpl", values) },
401
+ { relative: `${domain}/${feature}.module-definition.ts`, body: template("be/table/module-definition.ts.tpl", values) },
402
+ { relative: `${domain}/${feature}.options.ts`, body: template("be/table/options.ts.tpl", values) },
403
+ { relative: `${domain}/${feature}.service.ts`, body: template(`be/table/service${api}.ts.tpl`, values) },
404
+ { relative: `${domain}/persistence/${feature}.rows.ts`, body: template("be/table/rows.ts.tpl", values) },
405
+ { relative: `${domain}/persistence/${feature}.sql.ts`, body: template(`be/table/sql${api}.ts.tpl`, values) },
259
406
  ];
260
407
  const databaseSql = "be/src/modules/platform/database/database.sql.ts";
261
- if (repo.edition === "lite" && !fs.existsSync(path.join(root, ...databaseSql.split("/")))) {
408
+ if (repo.edition === "lite" && !fs.existsSync(at(root, databaseSql))) {
262
409
  planned.splice(1, 0, {
263
410
  relative: databaseSql,
264
411
  body: render(fs.readFileSync(path.join(TEMPLATES_DIR, "be", "skeleton-lite", "src", "modules", "platform", "database", "database.sql.ts"), "utf8"), {}),
265
412
  });
266
413
  }
267
414
  if (fe) {
268
- const apps =
269
- repo.sides?.fe?.apps?.filter((app) => app.kind === "next") ?? [];
270
- if (!apps.length)
271
- throw new ScaffoldError(
272
- "HFS_ADD_NO_FRONT_END",
273
- "add table --fe needs at least one declared Next app",
274
- );
415
+ planned.push(...apiFeatureFiles(values));
416
+ if (!fs.existsSync(at(root, `${GRAPHQL_PLATFORM}/index.ts`))) planned.push(...graphqlPlatformFiles());
275
417
  for (const app of apps) {
276
- const base = `fe/apps/${app.name}/src/modules/db/${feature}`;
277
- planned.push(
278
- {
279
- relative: `${base}/read-${feature}.ts`,
280
- body: template("fe/table/read.ts.tpl", values),
281
- },
282
- {
283
- relative: `${base}/write-${feature}.ts`,
284
- body: template("fe/table/write.ts.tpl", values),
285
- },
286
- );
418
+ if (!fs.existsSync(at(root, `fe/apps/${app.name}/src/modules/api/graphql/clients/index.ts`))) planned.push(...feTransportFiles(app.name));
419
+ planned.push(...feOperationFiles(app.name, values));
287
420
  }
288
421
  }
289
422
 
290
423
  const clash = planned
291
- .filter((file) =>
292
- fs.existsSync(path.join(root, ...file.relative.split("/"))),
293
- )
424
+ .filter((file) => fs.existsSync(at(root, file.relative)))
294
425
  .map((file) => file.relative);
295
426
  if (clash.length)
296
427
  throw new ScaffoldError(
@@ -300,41 +431,58 @@ export function addTable({
300
431
  const owner = repo.sides.be.apps.find((app) => app.name === connection.owner && app.kind === "api");
301
432
  if (!owner)
302
433
  throw new ScaffoldError("HFS_ADD_NO_API_OWNER", `add table needs the Supabase connection owner ${connection.owner} to be a declared api app`);
303
- const ownerFile = path.join(root, "be", "apps", owner.name, "src", "app.module.ts");
304
- if (!fs.existsSync(ownerFile))
305
- throw new ScaffoldError("HFS_ADD_WIRE_MISSING", `add table registers ${name} in be/apps/${owner.name}/src/app.module.ts, which does not exist`);
306
- const ownerBefore = fs.readFileSync(ownerFile, "utf8");
307
- const databaseIndex = path.join(root, "be", "src", "modules", "platform", "database", "index.ts");
308
- const databaseIndexBefore = repo.edition === "lite" && fs.existsSync(databaseIndex) ? fs.readFileSync(databaseIndex) : null;
309
- const typesFile = path.join(root, ...dbTypesPath.split("/"));
310
- const typesBefore = fs.existsSync(typesFile) ? fs.readFileSync(typesFile) : null;
434
+ const ownerRelative = `be/apps/${owner.name}/src/app.module.ts`;
435
+ if (!fs.existsSync(at(root, ownerRelative)))
436
+ throw new ScaffoldError("HFS_ADD_WIRE_MISSING", `add table registers ${name} in ${ownerRelative}, which does not exist`);
437
+
438
+ // Every existing file this run edits, as it was: restored on any failure, with every created file removed.
439
+ const touched = new Map();
440
+ const edit = (relative, change) => {
441
+ const file = at(root, relative);
442
+ if (!touched.has(file)) touched.set(file, fs.existsSync(file) ? fs.readFileSync(file) : null);
443
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
444
+ const after = change(before);
445
+ if (after === before) return;
446
+ fs.mkdirSync(path.dirname(file), { recursive: true });
447
+ fs.writeFileSync(file, after);
448
+ };
311
449
  const created = [];
312
450
  try {
313
451
  for (const file of planned) {
314
- const target = path.join(root, ...file.relative.split("/"));
452
+ const target = at(root, file.relative);
315
453
  fs.mkdirSync(path.dirname(target), { recursive: true });
316
454
  fs.writeFileSync(target, file.body);
317
455
  created.push(target);
318
456
  }
319
457
  if (typeof emitTypes === "function") {
320
458
  const generated = emitTypes({ root });
321
- if (typeof generated === "string") {
322
- const target = path.join(root, ...dbTypesPath.split("/"));
323
- fs.mkdirSync(path.dirname(target), { recursive: true });
324
- fs.writeFileSync(target, generated);
459
+ if (typeof generated === "string") edit(dbTypesPath, () => generated);
460
+ }
461
+ edit(ownerRelative, (text) => wireDomainModule(text, ownerRelative, feature));
462
+ const registrations = [
463
+ ...(repo.edition === "lite" ? ["table"] : []),
464
+ ...(fe ? ["api", "graphql"] : []),
465
+ ];
466
+ for (const generator of registrations) for (const relative of registrationFiles(generator)) edit(relative, (text) => text);
467
+ for (const generator of registrations) registerLiteExports({ root, generator });
468
+ if (fe) {
469
+ registerLiteDomainService({ root, moduleSpecifier: `@modules/domain/${feature}`, service: `${values.Name}Service` });
470
+ edit(ownerRelative, (text) => wireGraphqlFeature(text, ownerRelative, feature));
471
+ for (const app of apps) {
472
+ for (const [relative, line] of feBarrels(app.name, values)) edit(relative, (text) => withExport(text, line));
473
+ edit(`fe/apps/${app.name}/src/modules/config/index.ts`, withGraphqlUrl);
325
474
  }
326
475
  }
327
- wireApiModule(root, owner.name, feature);
328
- if (repo.edition === "lite") registerLiteExports({ root, generator: "table" });
329
476
  } catch (error) {
330
- fs.writeFileSync(ownerFile, ownerBefore);
331
- if (databaseIndexBefore !== null) fs.writeFileSync(databaseIndex, databaseIndexBefore);
332
- if (typesBefore === null) fs.rmSync(typesFile, { force: true });
333
- else fs.writeFileSync(typesFile, typesBefore);
477
+ for (const [file, before] of touched) {
478
+ if (before === null) fs.rmSync(file, { force: true });
479
+ else fs.writeFileSync(file, before);
480
+ }
334
481
  for (const file of [...created].reverse()) {
335
482
  if (fs.existsSync(file)) fs.rmSync(file);
336
483
  removeEmptyParents(file, root);
337
484
  }
485
+ for (const [file, before] of touched) if (before === null) removeEmptyParents(file, root);
338
486
  throw error;
339
487
  }
340
488
  return {
package/scaffold/add.mjs CHANGED
@@ -20,12 +20,12 @@ import { createSlotResolver, loadSlotManifest, readRepoDeclaration, ruleParams }
20
20
  import { parseYaml } from '../runtime/engine/yaml.mjs';
21
21
  import { TEMPLATES_DIR } from '../sync/index.mjs';
22
22
  import { ScaffoldError, pascalOf } from './service.mjs';
23
- import { refuseInEdition } from './edition-gate.mjs';
24
- import { addTable } from './add-table.mjs';
23
+ import { refuseFullAdapterInLite, refuseInEdition } from './edition-gate.mjs';
24
+ import { addTable, graphqlPlatformFiles } from './add-table.mjs';
25
25
  import { addApp } from './add-app.mjs';
26
- import { ensureLiteCli, selectLiteCliEntries } from './add-cli-lite.mjs';
26
+ import { ensureLiteCli } from './add-cli-lite.mjs';
27
27
  import { addLiteWebhookInbox, selectLiteWebhookEntries } from './add-inbox-lite.mjs';
28
- import { jsonText } from './app.mjs';
28
+ import { bundledPins, jsonText, packageJsonText, peerDependencies } from './app.mjs';
29
29
  import { byCodeUnit } from '../report/order.mjs';
30
30
  import { registerLiteDomainService, registerLiteExports } from './lite-exports.mjs';
31
31
 
@@ -47,21 +47,20 @@ function readTree(topic) {
47
47
  return doc.files;
48
48
  }
49
49
 
50
- const selectLiteApiEntries = entries => [
51
- ...entries.filter(entry => !entry.path.includes('/transport/graphql/')).map(entry => {
52
- if (entry.path === 'src/features/api/<feature>/index.ts') return { ...entry, template: 'api/feature.index.http.ts.tpl' };
53
- if (entry.path === 'src/features/api/<feature>/application/<action>.command.ts') return { ...entry, template: 'api/application.command.http.ts.tpl' };
54
- if (entry.path === 'src/features/api/<feature>/application/<action>.handler.ts') return { ...entry, template: 'api/application.handler.http.ts.tpl' };
55
- return entry;
56
- }),
57
- { path: 'src/features/api/<feature>/transport/http/<action>.controller.ts', slot: 'be.transport.http', template: 'api/http.controller.ts.tpl' },
58
- { path: 'src/features/api/<feature>/transport/http/<action>.mapper.ts', slot: 'be.transport.http', template: 'api/http.mapper.ts.tpl' },
59
- { path: 'src/features/api/<feature>/transport/http/dto/<action>.request.ts', slot: 'be.transport.http', template: 'api/http.request.ts.tpl' },
60
- { path: 'src/features/api/<feature>/transport/http/dto/<action>.response.ts', slot: 'be.transport.http', template: 'api/http.response.ts.tpl' },
61
- { path: 'src/features/api/<feature>/transport/http/<feature>-http.module.ts', slot: 'be.transport.http', template: 'api/http.module.ts.tpl' },
62
- ];
50
+ /** The parsed document of the topic `topic` (knowledge/patterns/be/<topic>.yaml) the package carries. */
51
+ const readTopic = (topic) => ({ id: topic, doc: parseYaml(fs.readFileSync(path.join(TOPICS_DIR, `${topic}.yaml`), 'utf8')) });
63
52
 
64
- /** The templates the lite api tree names beyond the pattern topic's own files tree (the HTTP door replaces the GraphQL one): the template set stays one set with the topics' (tests/packages-hfs/hfs-add.spec.mjs). */
53
+ /**
54
+ * The lite api tree: the full tree, GraphQL door included (owner ruling 2026-10-13: lite and full have one code structure), with
55
+ * the command and handler that hand the verified Supabase principal to the domain service (lite's identity adapter).
56
+ */
57
+ const selectLiteApiEntries = entries => entries.map(entry => {
58
+ if (entry.path === 'src/features/api/<feature>/application/<action>.command.ts') return { ...entry, template: 'api/application.command.principal.ts.tpl' };
59
+ if (entry.path === 'src/features/api/<feature>/application/<action>.handler.ts') return { ...entry, template: 'api/application.handler.principal.ts.tpl' };
60
+ return entry;
61
+ });
62
+
63
+ /** The templates the lite api tree names beyond the pattern topic's own files tree (the principal-aware command and handler): the template set stays one set with the topics' (tests/packages-hfs/hfs-add.spec.mjs). */
65
64
  export const liteApiTemplates = () => selectLiteApiEntries(readTree('api')).map(entry => entry.template).filter(Boolean);
66
65
 
67
66
  /** Fills the `<name>` variables of a path; returns null when a variable has no value. */
@@ -147,8 +146,11 @@ export function addKind({ repoRoot, noun, name, options = {}, now = Date.now })
147
146
  const topics = new Set([...specs.map(([, eachSpec]) => eachSpec.topic), ...topicsOfPatterns(patterns)]);
148
147
  const liteCli = noun === 'cli' && repo.edition === 'lite';
149
148
  const liteApi = noun === 'api' && repo.edition === 'lite';
149
+ // Lite has every pattern (owner ruling 2026-10-12), but a topic with a liteAdapter is generated only from full templates.
150
+ refuseFullAdapterInLite({ edition: repo.edition, topics: [...topics].map(readTopic), command: `add ${noun}` });
150
151
  const trees = new Map([...topics].map((topic) => {
151
- const entries = repo.edition === 'lite' ? selectLiteWebhookEntries(selectLiteCliEntries(readTree(topic))) : readTree(topic);
152
+ // Lite keeps every colocated unit spec of a tree (owner ruling 2026-10-12); only the webhook door and its spec are lite's own.
153
+ const entries = repo.edition === 'lite' ? selectLiteWebhookEntries(readTree(topic)) : readTree(topic);
152
154
  return [topic, liteApi && topic === spec.topic ? selectLiteApiEntries(entries) : entries];
153
155
  }));
154
156
  // The slots the noun's trees name decide whether the edition has the noun (a slot lite does not carry or forbids
@@ -196,34 +198,81 @@ export function addKind({ repoRoot, noun, name, options = {}, now = Date.now })
196
198
  registerLiteDomainService({ root: repoRoot, moduleSpecifier: values.serviceModule, service: values.service });
197
199
  }
198
200
  if (spec.wire) wire({ beRoot, wire: spec.wire, forms, noun, name });
199
- if (repo.edition === 'lite' && (noun === 'api' || noun === 'webhook')) wireLiteFeature({ repoRoot, repo, noun, name });
201
+ const platformCreated = repo.edition === 'lite' && (noun === 'api' || noun === 'webhook') ? wireLiteFeature({ repoRoot, repo, noun, name }) : [];
200
202
  const inboxCreated = addLiteWebhookInbox({ root: repoRoot, repo, noun, provider: name, values, now });
201
- const registered = register({ declarationFile, spec: { patterns, trigger: spec.trigger } });
202
- return { created: [...bootstrapCreated, ...planned.map((file) => `be/${file.target}`), ...inboxCreated], registered };
203
+ const graphqlDoor = noun === 'api' ? ensureGraphqlPeers(repoRoot) : [];
204
+ const registered = register({ declarationFile, spec: { patterns, trigger: spec.trigger, optionalSlots: noun === 'api' ? [GRAPHQL_CONTRACT_SLOT] : [] } });
205
+ return { created: [...bootstrapCreated, ...planned.map((file) => `be/${file.target}`), ...platformCreated, ...inboxCreated, ...graphqlDoor], registered };
206
+ }
207
+
208
+ /** The slot of the emitted GraphQL snapshot (contracts/<app>/schema.graphql), which an app serving a GraphQL api feature opts into. */
209
+ const GRAPHQL_CONTRACT_SLOT = 'be.contract.graphql';
210
+
211
+ /**
212
+ * Declares, in the app root package.json, the runtime peers its GraphQL api needs (R111, knowledge/hfs/peer-integrations.yaml) at
213
+ * their canon pins; returns ['package.json'] when it changed, else []. Both editions.
214
+ */
215
+ function ensureGraphqlPeers(repoRoot) {
216
+ const file = path.join(repoRoot, 'package.json');
217
+ if (!fs.existsSync(file)) throw new ScaffoldError('HFS_ADD_NOT_AN_APP', 'add api needs the app root package.json');
218
+ const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
219
+ const peers = peerDependencies(pkg, bundledPins());
220
+ if (!Object.keys(peers).length) return [];
221
+ pkg.dependencies = Object.fromEntries(Object.entries({ ...pkg.dependencies, ...peers }).sort(([a], [b]) => a.localeCompare(b)));
222
+ fs.writeFileSync(file, packageJsonText(pkg));
223
+ return ['package.json'];
224
+ }
225
+
226
+ /** Adds `importLine` and one `symbol` entry of the `imports` array of a module file's lines, once. */
227
+ function addModuleImport(lines, { importLine, entry, relative, noun }) {
228
+ if (lines.includes(importLine)) return;
229
+ const lastImport = lines.reduce((last, line, index) => line.startsWith('import ') ? index : last, -1);
230
+ if (lastImport < 0 || lines.findIndex(line => /^\s*imports: \[$/.test(line)) < 0) throw new ScaffoldError('HFS_ADD_WIRE_INVALID', `add ${noun} could not find the imports array in ${relative}`);
231
+ lines.splice(lastImport + 1, 0, importLine);
232
+ const imports = lines.findIndex(line => /^\s*imports: \[$/.test(line));
233
+ const indent = /^(\s*)/.exec(lines[imports])?.[1] ?? '';
234
+ lines.splice(imports + 1, 0, `${indent} ${entry},`);
235
+ }
236
+
237
+ /** The GraphQL server capability (be/src/modules/platform/graphql), written whole by the first GraphQL feature of an app that has none. */
238
+ function ensureGraphqlPlatform(repoRoot) {
239
+ if (fs.existsSync(path.join(repoRoot, 'be', 'src', 'modules', 'platform', 'graphql', 'index.ts'))) return [];
240
+ const written = [];
241
+ for (const { relative, body } of graphqlPlatformFiles()) {
242
+ const file = path.join(repoRoot, ...relative.split('/'));
243
+ if (fs.existsSync(file)) continue;
244
+ fs.mkdirSync(path.dirname(file), { recursive: true });
245
+ fs.writeFileSync(file, body);
246
+ written.push(relative);
247
+ }
248
+ registerLiteExports({ root: repoRoot, generator: 'graphql' });
249
+ return written;
203
250
  }
204
251
 
205
- /** Lite has one API app; compose each generated API or webhook transport into that app immediately. */
252
+ /**
253
+ * Lite has one API app; compose each generated transport into that app immediately: an api feature's GraphQL module (with the
254
+ * GraphQL server on first use), a webhook's HTTP door.
255
+ */
206
256
  function wireLiteFeature({ repoRoot, repo, noun, name }) {
207
257
  const app = repo.sides.be.apps.find(entry => entry.kind === 'api');
208
258
  if (!app) throw new ScaffoldError('HFS_ADD_NO_API_OWNER', `add ${noun} needs a declared lite api app`);
209
259
  const relative = `be/apps/${app.name}/src/app.module.ts`;
210
260
  const file = path.join(repoRoot, ...relative.split('/'));
211
261
  if (!fs.existsSync(file)) throw new ScaffoldError('HFS_ADD_WIRE_MISSING', `add ${noun} registers its transport in ${relative}, which does not exist`);
212
- const symbol = `${pascalOf(name)}HttpModule`;
213
- const owner = noun === 'api' ? 'api' : 'webhooks';
214
- const importLine = `import { ${symbol} } from "@features/${owner}/${name}"`;
215
262
  const lines = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n').split('\n');
216
- const lastImport = lines.reduce((last, line, index) => line.startsWith('import ') ? index : last, -1);
217
- const imports = lines.findIndex(line => /^\s*imports: \[$/.test(line));
218
- if (lastImport < 0 || imports < 0) throw new ScaffoldError('HFS_ADD_WIRE_INVALID', `add ${noun} could not find the imports array in ${relative}`);
219
- if (!lines.includes(importLine)) {
220
- lines.splice(lastImport + 1, 0, importLine);
221
- const shiftedImports = imports > lastImport ? imports + 1 : imports;
222
- const indent = /^(\s*)/.exec(lines[shiftedImports])?.[1] ?? '';
223
- lines.splice(shiftedImports + 1, 0, `${indent} ${symbol},`);
263
+ const created = [];
264
+ if (noun === 'api') {
265
+ created.push(...ensureGraphqlPlatform(repoRoot));
266
+ addModuleImport(lines, { importLine: 'import { GraphqlModule } from "@modules/platform/graphql"', entry: 'GraphqlModule.register({ isGlobal: true })', relative, noun });
267
+ const symbol = `${pascalOf(name)}GraphqlModule`;
268
+ addModuleImport(lines, { importLine: `import { ${symbol} } from "@features/api/${name}"`, entry: symbol, relative, noun });
269
+ } else {
270
+ const symbol = `${pascalOf(name)}HttpModule`;
271
+ addModuleImport(lines, { importLine: `import { ${symbol} } from "@features/webhooks/${name}"`, entry: symbol, relative, noun });
224
272
  }
225
273
  fs.writeFileSync(file, `${lines.join('\n').replace(/\n+$/, '')}\n`);
226
274
  if (noun === 'webhook') wireLiteWebhookConfig({ repoRoot, app: app.name, provider: name });
275
+ return created;
227
276
  }
228
277
 
229
278
  /** Adds one generated webhook provider to the lite API's parsed options and enables exact raw-body verification. */
@@ -287,7 +336,7 @@ function topicsOfPatterns(patterns) {
287
336
  return topics;
288
337
  }
289
338
 
290
- /** Adds the patterns and the trigger kind of the noun to sides.be of hfs.json, keeping its other content and key order. */
339
+ /** Adds the patterns, the trigger kind and the opt-in slots of the noun to sides.be of hfs.json, keeping its other content and key order. */
291
340
  function register({ declarationFile, spec }) {
292
341
  const declaration = JSON.parse(fs.readFileSync(declarationFile, 'utf8'));
293
342
  const be = declaration.sides.be;
@@ -295,6 +344,7 @@ function register({ declarationFile, spec }) {
295
344
  const kinds = [...new Set([...(be.kinds ?? []), ...(spec.trigger ? [spec.trigger] : [])])].sort(byCodeUnit);
296
345
  be.patterns = patterns;
297
346
  if (kinds.length) be.kinds = kinds;
347
+ if (spec.optionalSlots.some(slot => !(be.optionalSlots ?? []).includes(slot))) be.optionalSlots = [...new Set([...(be.optionalSlots ?? []), ...spec.optionalSlots])].sort(byCodeUnit);
298
348
  fs.writeFileSync(declarationFile, jsonText(declaration));
299
349
  return { patterns, kinds };
300
350
  }