@ketvietlab/ketjs 0.1.1

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/agent/capabilities.d.ts +62 -0
  4. package/dist/agent/capabilities.d.ts.map +1 -0
  5. package/dist/agent/capabilities.js +89 -0
  6. package/dist/agent/capabilities.js.map +1 -0
  7. package/dist/agent/permissions.d.ts +56 -0
  8. package/dist/agent/permissions.d.ts.map +1 -0
  9. package/dist/agent/permissions.js +209 -0
  10. package/dist/agent/permissions.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +698 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/codegen/dts.d.ts +3 -0
  16. package/dist/codegen/dts.d.ts.map +1 -0
  17. package/dist/codegen/dts.js +94 -0
  18. package/dist/codegen/dts.js.map +1 -0
  19. package/dist/data/adapter.d.ts +4 -0
  20. package/dist/data/adapter.d.ts.map +1 -0
  21. package/dist/data/adapter.js +18 -0
  22. package/dist/data/adapter.js.map +1 -0
  23. package/dist/data/changeset.d.ts +49 -0
  24. package/dist/data/changeset.d.ts.map +1 -0
  25. package/dist/data/changeset.js +206 -0
  26. package/dist/data/changeset.js.map +1 -0
  27. package/dist/data/expr.d.ts +59 -0
  28. package/dist/data/expr.d.ts.map +1 -0
  29. package/dist/data/expr.js +61 -0
  30. package/dist/data/expr.js.map +1 -0
  31. package/dist/data/fleet.d.ts +28 -0
  32. package/dist/data/fleet.d.ts.map +1 -0
  33. package/dist/data/fleet.js +82 -0
  34. package/dist/data/fleet.js.map +1 -0
  35. package/dist/data/list-search.d.ts +93 -0
  36. package/dist/data/list-search.d.ts.map +1 -0
  37. package/dist/data/list-search.js +268 -0
  38. package/dist/data/list-search.js.map +1 -0
  39. package/dist/data/migrate.d.ts +75 -0
  40. package/dist/data/migrate.d.ts.map +1 -0
  41. package/dist/data/migrate.js +136 -0
  42. package/dist/data/migrate.js.map +1 -0
  43. package/dist/data/pool.d.ts +20 -0
  44. package/dist/data/pool.d.ts.map +1 -0
  45. package/dist/data/pool.js +92 -0
  46. package/dist/data/pool.js.map +1 -0
  47. package/dist/data/query.d.ts +111 -0
  48. package/dist/data/query.d.ts.map +1 -0
  49. package/dist/data/query.js +268 -0
  50. package/dist/data/query.js.map +1 -0
  51. package/dist/data/sqlite.d.ts +3 -0
  52. package/dist/data/sqlite.d.ts.map +1 -0
  53. package/dist/data/sqlite.js +107 -0
  54. package/dist/data/sqlite.js.map +1 -0
  55. package/dist/data/time.d.ts +9 -0
  56. package/dist/data/time.d.ts.map +1 -0
  57. package/dist/data/time.js +107 -0
  58. package/dist/data/time.js.map +1 -0
  59. package/dist/index.d.ts +79 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +51 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/kernel/apps.d.ts +43 -0
  64. package/dist/kernel/apps.d.ts.map +1 -0
  65. package/dist/kernel/apps.js +234 -0
  66. package/dist/kernel/apps.js.map +1 -0
  67. package/dist/kernel/compose.d.ts +6 -0
  68. package/dist/kernel/compose.d.ts.map +1 -0
  69. package/dist/kernel/compose.js +976 -0
  70. package/dist/kernel/compose.js.map +1 -0
  71. package/dist/kernel/define.d.ts +9 -0
  72. package/dist/kernel/define.d.ts.map +1 -0
  73. package/dist/kernel/define.js +140 -0
  74. package/dist/kernel/define.js.map +1 -0
  75. package/dist/kernel/diff.d.ts +11 -0
  76. package/dist/kernel/diff.d.ts.map +1 -0
  77. package/dist/kernel/diff.js +106 -0
  78. package/dist/kernel/diff.js.map +1 -0
  79. package/dist/kernel/errors.d.ts +17 -0
  80. package/dist/kernel/errors.d.ts.map +1 -0
  81. package/dist/kernel/errors.js +43 -0
  82. package/dist/kernel/errors.js.map +1 -0
  83. package/dist/kernel/graph.d.ts +3 -0
  84. package/dist/kernel/graph.d.ts.map +1 -0
  85. package/dist/kernel/graph.js +56 -0
  86. package/dist/kernel/graph.js.map +1 -0
  87. package/dist/kernel/i18n.d.ts +43 -0
  88. package/dist/kernel/i18n.d.ts.map +1 -0
  89. package/dist/kernel/i18n.js +79 -0
  90. package/dist/kernel/i18n.js.map +1 -0
  91. package/dist/kernel/index.d.ts +7 -0
  92. package/dist/kernel/index.d.ts.map +1 -0
  93. package/dist/kernel/index.js +7 -0
  94. package/dist/kernel/index.js.map +1 -0
  95. package/dist/kernel/layout.d.ts +17 -0
  96. package/dist/kernel/layout.d.ts.map +1 -0
  97. package/dist/kernel/layout.js +77 -0
  98. package/dist/kernel/layout.js.map +1 -0
  99. package/dist/kernel/menu.d.ts +27 -0
  100. package/dist/kernel/menu.d.ts.map +1 -0
  101. package/dist/kernel/menu.js +65 -0
  102. package/dist/kernel/menu.js.map +1 -0
  103. package/dist/kernel/modules.d.ts +31 -0
  104. package/dist/kernel/modules.d.ts.map +1 -0
  105. package/dist/kernel/modules.js +322 -0
  106. package/dist/kernel/modules.js.map +1 -0
  107. package/dist/kernel/routes.d.ts +24 -0
  108. package/dist/kernel/routes.d.ts.map +1 -0
  109. package/dist/kernel/routes.js +98 -0
  110. package/dist/kernel/routes.js.map +1 -0
  111. package/dist/kernel/types.d.ts +8 -0
  112. package/dist/kernel/types.d.ts.map +1 -0
  113. package/dist/kernel/types.js +67 -0
  114. package/dist/kernel/types.js.map +1 -0
  115. package/dist/kernel/workspace.d.ts +62 -0
  116. package/dist/kernel/workspace.d.ts.map +1 -0
  117. package/dist/kernel/workspace.js +156 -0
  118. package/dist/kernel/workspace.js.map +1 -0
  119. package/dist/scaffold/index.d.ts +2 -0
  120. package/dist/scaffold/index.d.ts.map +1 -0
  121. package/dist/scaffold/index.js +49 -0
  122. package/dist/scaffold/index.js.map +1 -0
  123. package/dist/scaffold/templates/app.test.ts.tmpl +17 -0
  124. package/dist/scaffold/templates/biome.json.tmpl +30 -0
  125. package/dist/scaffold/templates/dev.mjs.tmpl +16 -0
  126. package/dist/scaffold/templates/gitignore.tmpl +24 -0
  127. package/dist/scaffold/templates/ket.workspace.ts.tmpl +18 -0
  128. package/dist/scaffold/templates/module.ts.tmpl +27 -0
  129. package/dist/scaffold/templates/package.json.tmpl +25 -0
  130. package/dist/scaffold/templates/tsconfig.json.tmpl +15 -0
  131. package/dist/server/boot.d.ts +173 -0
  132. package/dist/server/boot.d.ts.map +1 -0
  133. package/dist/server/boot.js +659 -0
  134. package/dist/server/boot.js.map +1 -0
  135. package/dist/server/config.d.ts +48 -0
  136. package/dist/server/config.d.ts.map +1 -0
  137. package/dist/server/config.js +82 -0
  138. package/dist/server/config.js.map +1 -0
  139. package/dist/server/ctx.d.ts +13 -0
  140. package/dist/server/ctx.d.ts.map +1 -0
  141. package/dist/server/ctx.js +496 -0
  142. package/dist/server/ctx.js.map +1 -0
  143. package/dist/server/fn.d.ts +34 -0
  144. package/dist/server/fn.d.ts.map +1 -0
  145. package/dist/server/fn.js +170 -0
  146. package/dist/server/fn.js.map +1 -0
  147. package/dist/server/http.d.ts +79 -0
  148. package/dist/server/http.d.ts.map +1 -0
  149. package/dist/server/http.js +459 -0
  150. package/dist/server/http.js.map +1 -0
  151. package/dist/server/idem.d.ts +31 -0
  152. package/dist/server/idem.d.ts.map +1 -0
  153. package/dist/server/idem.js +68 -0
  154. package/dist/server/idem.js.map +1 -0
  155. package/dist/server/jobs.d.ts +5 -0
  156. package/dist/server/jobs.d.ts.map +1 -0
  157. package/dist/server/jobs.js +24 -0
  158. package/dist/server/jobs.js.map +1 -0
  159. package/dist/server/multipart.d.ts +18 -0
  160. package/dist/server/multipart.d.ts.map +1 -0
  161. package/dist/server/multipart.js +183 -0
  162. package/dist/server/multipart.js.map +1 -0
  163. package/dist/server/project.d.ts +9 -0
  164. package/dist/server/project.d.ts.map +1 -0
  165. package/dist/server/project.js +67 -0
  166. package/dist/server/project.js.map +1 -0
  167. package/dist/server/queue.d.ts +80 -0
  168. package/dist/server/queue.d.ts.map +1 -0
  169. package/dist/server/queue.js +494 -0
  170. package/dist/server/queue.js.map +1 -0
  171. package/dist/server/respond.d.ts +91 -0
  172. package/dist/server/respond.d.ts.map +1 -0
  173. package/dist/server/respond.js +99 -0
  174. package/dist/server/respond.js.map +1 -0
  175. package/dist/server/runtime.d.ts +14 -0
  176. package/dist/server/runtime.d.ts.map +1 -0
  177. package/dist/server/runtime.js +25 -0
  178. package/dist/server/runtime.js.map +1 -0
  179. package/dist/server/session.d.ts +55 -0
  180. package/dist/server/session.d.ts.map +1 -0
  181. package/dist/server/session.js +172 -0
  182. package/dist/server/session.js.map +1 -0
  183. package/dist/server/sessionstore.d.ts +54 -0
  184. package/dist/server/sessionstore.d.ts.map +1 -0
  185. package/dist/server/sessionstore.js +232 -0
  186. package/dist/server/sessionstore.js.map +1 -0
  187. package/dist/server/storage/index.d.ts +11 -0
  188. package/dist/server/storage/index.d.ts.map +1 -0
  189. package/dist/server/storage/index.js +31 -0
  190. package/dist/server/storage/index.js.map +1 -0
  191. package/dist/server/storage/local.d.ts +5 -0
  192. package/dist/server/storage/local.d.ts.map +1 -0
  193. package/dist/server/storage/local.js +165 -0
  194. package/dist/server/storage/local.js.map +1 -0
  195. package/dist/server/storage/s3.d.ts +11 -0
  196. package/dist/server/storage/s3.d.ts.map +1 -0
  197. package/dist/server/storage/s3.js +133 -0
  198. package/dist/server/storage/s3.js.map +1 -0
  199. package/dist/server/storage/sigv4.d.ts +23 -0
  200. package/dist/server/storage/sigv4.d.ts.map +1 -0
  201. package/dist/server/storage/sigv4.js +79 -0
  202. package/dist/server/storage/sigv4.js.map +1 -0
  203. package/dist/server/storage/types.d.ts +37 -0
  204. package/dist/server/storage/types.d.ts.map +1 -0
  205. package/dist/server/storage/types.js +82 -0
  206. package/dist/server/storage/types.js.map +1 -0
  207. package/dist/server/stream.d.ts +43 -0
  208. package/dist/server/stream.d.ts.map +1 -0
  209. package/dist/server/stream.js +117 -0
  210. package/dist/server/stream.js.map +1 -0
  211. package/dist/server/streamstore.d.ts +29 -0
  212. package/dist/server/streamstore.d.ts.map +1 -0
  213. package/dist/server/streamstore.js +140 -0
  214. package/dist/server/streamstore.js.map +1 -0
  215. package/dist/server/tenants.d.ts +108 -0
  216. package/dist/server/tenants.d.ts.map +1 -0
  217. package/dist/server/tenants.js +172 -0
  218. package/dist/server/tenants.js.map +1 -0
  219. package/dist/server/transport/index.d.ts +5 -0
  220. package/dist/server/transport/index.d.ts.map +1 -0
  221. package/dist/server/transport/index.js +3 -0
  222. package/dist/server/transport/index.js.map +1 -0
  223. package/dist/server/transport/memory.d.ts +14 -0
  224. package/dist/server/transport/memory.d.ts.map +1 -0
  225. package/dist/server/transport/memory.js +36 -0
  226. package/dist/server/transport/memory.js.map +1 -0
  227. package/dist/server/transport/types.d.ts +44 -0
  228. package/dist/server/transport/types.d.ts.map +1 -0
  229. package/dist/server/transport/types.js +79 -0
  230. package/dist/server/transport/types.js.map +1 -0
  231. package/dist/server/worker.d.ts +33 -0
  232. package/dist/server/worker.d.ts.map +1 -0
  233. package/dist/server/worker.js +428 -0
  234. package/dist/server/worker.js.map +1 -0
  235. package/dist/testing.d.ts +146 -0
  236. package/dist/testing.d.ts.map +1 -0
  237. package/dist/testing.js +406 -0
  238. package/dist/testing.js.map +1 -0
  239. package/dist/theme/contracts.d.ts +4 -0
  240. package/dist/theme/contracts.d.ts.map +1 -0
  241. package/dist/theme/contracts.js +87 -0
  242. package/dist/theme/contracts.js.map +1 -0
  243. package/dist/theme/index.d.ts +8 -0
  244. package/dist/theme/index.d.ts.map +1 -0
  245. package/dist/theme/index.js +7 -0
  246. package/dist/theme/index.js.map +1 -0
  247. package/dist/theme/joints.d.ts +19 -0
  248. package/dist/theme/joints.d.ts.map +1 -0
  249. package/dist/theme/joints.js +126 -0
  250. package/dist/theme/joints.js.map +1 -0
  251. package/dist/theme/ktl/compile.d.ts +26 -0
  252. package/dist/theme/ktl/compile.d.ts.map +1 -0
  253. package/dist/theme/ktl/compile.js +226 -0
  254. package/dist/theme/ktl/compile.js.map +1 -0
  255. package/dist/theme/ktl/lexer.d.ts +20 -0
  256. package/dist/theme/ktl/lexer.d.ts.map +1 -0
  257. package/dist/theme/ktl/lexer.js +56 -0
  258. package/dist/theme/ktl/lexer.js.map +1 -0
  259. package/dist/theme/ktl/parser.d.ts +64 -0
  260. package/dist/theme/ktl/parser.d.ts.map +1 -0
  261. package/dist/theme/ktl/parser.js +220 -0
  262. package/dist/theme/ktl/parser.js.map +1 -0
  263. package/dist/theme/render.d.ts +19 -0
  264. package/dist/theme/render.d.ts.map +1 -0
  265. package/dist/theme/render.js +246 -0
  266. package/dist/theme/render.js.map +1 -0
  267. package/dist/theme/templates.d.ts +2 -0
  268. package/dist/theme/templates.d.ts.map +1 -0
  269. package/dist/theme/templates.js +40 -0
  270. package/dist/theme/templates.js.map +1 -0
  271. package/dist/theme/tokens.d.ts +4 -0
  272. package/dist/theme/tokens.d.ts.map +1 -0
  273. package/dist/theme/tokens.js +15 -0
  274. package/dist/theme/tokens.js.map +1 -0
  275. package/dist/theme/viewmodel.d.ts +6 -0
  276. package/dist/theme/viewmodel.d.ts.map +1 -0
  277. package/dist/theme/viewmodel.js +40 -0
  278. package/dist/theme/viewmodel.js.map +1 -0
  279. package/dist/types.d.ts +614 -0
  280. package/dist/types.d.ts.map +1 -0
  281. package/dist/types.js +3 -0
  282. package/dist/types.js.map +1 -0
  283. package/package.json +60 -0
@@ -0,0 +1,976 @@
1
+ // Composition: turn a set of modules into one manifest.
2
+ //
3
+ // The manifest is the single artifact the whole framework reads. It is the module
4
+ // contract, the database schema, the theme contract and the agent capability
5
+ // descriptor at once. Every field records which module contributed it — that
6
+ // provenance is what makes upgrade diffs and non-destructive migrations possible.
7
+ import { topoSort } from './graph.js';
8
+ import { Diagnostics } from './errors.js';
9
+ import { parseType } from './types.js';
10
+ import { ambiguousRoutes, parseRoutePattern } from './routes.js';
11
+ const qualify = (mod, name) => `${mod}.${name}`;
12
+ const jointKey = (mod, name) => `${mod}:${name}`;
13
+ export function compose(modules, opts = {}) {
14
+ const diag = new Diagnostics();
15
+ const order = topoSort(modules);
16
+ const canSee = (m, other) => m.name === other || m.depends.includes(other);
17
+ const manifest = {
18
+ ket: '0.0.0',
19
+ order: order.map((m) => m.name),
20
+ modules: {},
21
+ models: {},
22
+ menus: {},
23
+ joints: {},
24
+ fills: [],
25
+ functions: {},
26
+ jobs: {},
27
+ views: {},
28
+ regions: { required: [...(opts.appRequires ?? [])], provided: {} },
29
+ islands: {},
30
+ sections: {},
31
+ contentTypes: {},
32
+ taxonomies: {},
33
+ relations: {},
34
+ tokens: {},
35
+ assets: {},
36
+ styles: [],
37
+ routes: {},
38
+ patches: [],
39
+ messages: {},
40
+ };
41
+ for (const m of order) {
42
+ manifest.modules[m.name] = {
43
+ version: m.version,
44
+ kind: m.kind,
45
+ depends: [...m.depends],
46
+ app: m.app,
47
+ title: m.title,
48
+ summary: m.summary,
49
+ category: m.category,
50
+ install: m.install ?? 'manual',
51
+ removable: m.removable !== false,
52
+ };
53
+ }
54
+ // --- the served surface -------------------------------------------------
55
+ //
56
+ // Assets, stylesheets and routes are composed for the same reason models are:
57
+ // otherwise the app hand-assembles them, which means knowing another module's
58
+ // file layout and going on serving it after that module is switched off.
59
+ //
60
+ // `order` is dependency order, so a module that extends another contributes its
61
+ // stylesheet after it and can override it. That ordering is the point.
62
+ for (const m of order) {
63
+ if (m.assets)
64
+ manifest.assets[m.name] = typeof m.assets === 'string' ? m.assets : m.assets.pathname;
65
+ for (const href of m.styles) {
66
+ if (!m.assets) {
67
+ diag.add({
68
+ code: 'E_STYLE_WITHOUT_ASSETS',
69
+ module: m.name,
70
+ message: `"${m.name}" declares style "${href}" but no assets directory`,
71
+ hint: 'styles are resolved against the module assets directory, so a module with styles needs one',
72
+ });
73
+ continue;
74
+ }
75
+ manifest.styles.push({ by: m.name, href: `/_ket/asset/${m.name}/${href}` });
76
+ }
77
+ for (const [path, make] of Object.entries(m.routes)) {
78
+ let pattern;
79
+ try {
80
+ pattern = parseRoutePattern(path);
81
+ }
82
+ catch (error) {
83
+ const e = error;
84
+ diag.add({ code: e.code ?? 'E_ROUTE_PATTERN', module: m.name, message: e.message, hint: e.hint });
85
+ continue;
86
+ }
87
+ if (path.startsWith('/_ket/')) {
88
+ diag.add({
89
+ code: 'E_ROUTE_RESERVED',
90
+ module: m.name,
91
+ message: `"${m.name}" claims "${path}", which is reserved`,
92
+ hint: '/_ket/ belongs to the framework: health, the agent descriptor, streams and assets',
93
+ });
94
+ continue;
95
+ }
96
+ const taken = manifest.routes[path];
97
+ if (taken) {
98
+ diag.add({
99
+ code: 'E_ROUTE_CLASH',
100
+ module: m.name,
101
+ message: `both "${taken.by}" and "${m.name}" serve "${path}"`,
102
+ hint: 'two modules cannot own one path — rename one, or have one fill a joint in the other',
103
+ });
104
+ continue;
105
+ }
106
+ const ambiguous = Object.keys(manifest.routes).find((other) => ambiguousRoutes(parseRoutePattern(other), pattern));
107
+ if (ambiguous) {
108
+ const owner = manifest.routes[ambiguous].by;
109
+ diag.add({
110
+ code: 'E_ROUTE_AMBIGUOUS',
111
+ module: m.name,
112
+ message: `routes "${ambiguous}" owned by "${owner}" and "${path}" owned by "${m.name}" can match the same path with equal priority`,
113
+ hint: 'make one route more specific, or let one module own both paths',
114
+ });
115
+ continue;
116
+ }
117
+ manifest.routes[path] =
118
+ typeof make === 'function'
119
+ ? { by: m.name, anonymous: false, make }
120
+ : { by: m.name, anonymous: make.anonymous === true, make: make.handler };
121
+ }
122
+ }
123
+ // --- models -------------------------------------------------------------
124
+ for (const m of order) {
125
+ for (const [modelName, def] of Object.entries(m.models)) {
126
+ const key = qualify(m.name, modelName);
127
+ if (manifest.models[key]) {
128
+ diag.add({
129
+ code: 'E_MODEL_DUPLICATE',
130
+ module: m.name,
131
+ message: `model "${key}" is already defined`,
132
+ hint: 'rename it, or extend the existing one via `extend`',
133
+ });
134
+ continue;
135
+ }
136
+ if (!def.scope) {
137
+ diag.add({
138
+ code: 'E_MODEL_NO_SCOPE',
139
+ module: m.name,
140
+ message: `model "${key}" does not declare a scope`,
141
+ hint: "every model must say 'shared', 'company' or 'company+branch' — there is no default, because the safe-looking one is the one that leaks",
142
+ });
143
+ continue;
144
+ }
145
+ if (!['shared', 'company', 'company+branch'].includes(def.scope)) {
146
+ diag.add({
147
+ code: 'E_MODEL_BAD_SCOPE',
148
+ module: m.name,
149
+ message: `model "${key}" has unknown scope "${def.scope}"`,
150
+ });
151
+ continue;
152
+ }
153
+ const fields = {};
154
+ // The scope columns are added by the composer, never by the module: a module
155
+ // that spelled them itself could spell them differently, and the filter would
156
+ // silently stop matching.
157
+ if (def.scope !== 'shared')
158
+ fields['companyId'] = { base: 'text', optional: false, by: '(scope)' };
159
+ if (def.scope === 'company+branch')
160
+ fields['branchId'] = { base: 'text', optional: false, by: '(scope)' };
161
+ if (def.timestamps) {
162
+ fields['createdAt'] = { base: 'datetime', optional: true, by: '(timestamps)' };
163
+ fields['updatedAt'] = { base: 'datetime', optional: true, by: '(timestamps)' };
164
+ }
165
+ for (const [fname, tspec] of Object.entries(def.fields ?? {})) {
166
+ if (def.timestamps && (fname === 'createdAt' || fname === 'updatedAt')) {
167
+ diag.add({
168
+ code: 'E_TIMESTAMP_FIELD_RESERVED',
169
+ module: m.name,
170
+ message: `${key}.${fname} is supplied by timestamps: true`,
171
+ hint: `remove the explicit field or disable timestamps`,
172
+ });
173
+ continue;
174
+ }
175
+ const t = parseType(tspec);
176
+ if (!t.ok) {
177
+ diag.add({ code: 'E_BAD_TYPE', module: m.name, message: `${key}.${fname}: ${t.reason}` });
178
+ continue;
179
+ }
180
+ if (t.base === 'ref' && t.target === key && !t.optional) {
181
+ diag.add({
182
+ code: 'E_SELF_REF_REQUIRED',
183
+ module: m.name,
184
+ message: `field "${key}.${fname}" is a required reference to its own model`,
185
+ hint: `the first row could never satisfy it — write "${tspec}?"`,
186
+ });
187
+ continue;
188
+ }
189
+ fields[fname] = { base: t.base, optional: t.optional, target: t.target, by: m.name };
190
+ }
191
+ const indexes = {};
192
+ for (const [indexName, index] of Object.entries(def.indexes ?? {})) {
193
+ if (!/^[a-z][a-z0-9_]*$/.test(indexName)) {
194
+ diag.add({
195
+ code: 'E_INDEX_NAME',
196
+ module: m.name,
197
+ message: `${key} index name ${JSON.stringify(indexName)} must be lowercase snake_case`,
198
+ });
199
+ continue;
200
+ }
201
+ if (!index.fields.length) {
202
+ diag.add({ code: 'E_INDEX_EMPTY', module: m.name, message: `${key}.${indexName} has no fields` });
203
+ continue;
204
+ }
205
+ const unknown = index.fields.filter((field) => !fields[field]);
206
+ if (unknown.length) {
207
+ diag.add({
208
+ code: 'E_INDEX_UNKNOWN_FIELD',
209
+ module: m.name,
210
+ message: `${key}.${indexName} references unknown field(s): ${unknown.join(', ')}`,
211
+ });
212
+ continue;
213
+ }
214
+ if (new Set(index.fields).size !== index.fields.length) {
215
+ diag.add({
216
+ code: 'E_INDEX_DUPLICATE_FIELD',
217
+ module: m.name,
218
+ message: `${key}.${indexName} repeats a field`,
219
+ });
220
+ continue;
221
+ }
222
+ indexes[indexName] = { fields: [...index.fields], unique: index.unique === true, by: m.name };
223
+ }
224
+ manifest.models[key] = {
225
+ owner: m.name,
226
+ scope: def.scope,
227
+ timestamps: def.timestamps === true,
228
+ fields,
229
+ indexes,
230
+ };
231
+ }
232
+ }
233
+ // --- model extension: the core of the lego pillar ------------------------
234
+ for (const m of order) {
235
+ for (const [target, addl] of Object.entries(m.extend)) {
236
+ const model = manifest.models[target];
237
+ if (!model) {
238
+ diag.add({
239
+ code: 'E_EXTEND_UNKNOWN_MODEL',
240
+ module: m.name,
241
+ message: `cannot extend "${target}" - no such model`,
242
+ hint: `known models: ${Object.keys(manifest.models).join(', ') || '(none)'}`,
243
+ });
244
+ continue;
245
+ }
246
+ if (!canSee(m, model.owner)) {
247
+ diag.add({
248
+ code: 'E_EXTEND_NOT_DEPENDED',
249
+ module: m.name,
250
+ message: `extends "${target}" but does not depend on "${model.owner}"`,
251
+ hint: `add "${model.owner}" to ${m.name}.depends`,
252
+ });
253
+ continue;
254
+ }
255
+ for (const [fname, tspec] of Object.entries(addl)) {
256
+ // Checked ahead of the collision below so the message names the real cause:
257
+ // these columns are the isolation boundary, not a name somebody took first.
258
+ if (fname === 'companyId' || fname === 'branchId') {
259
+ diag.add({
260
+ code: 'E_SCOPE_FIELD_RESERVED',
261
+ module: m.name,
262
+ message: `"${target}.${fname}" is managed by the model's scope and cannot be extended`,
263
+ hint: 'the scope columns are the company boundary — a module able to redefine them would be able to move rows across it',
264
+ });
265
+ continue;
266
+ }
267
+ const existing = model.fields[fname];
268
+ if (existing) {
269
+ diag.add({
270
+ code: 'E_FIELD_COLLISION',
271
+ module: m.name,
272
+ message: `field "${target}.${fname}" already contributed by "${existing.by}"`,
273
+ hint: `pick a distinct name, e.g. "${m.name}_${fname}"`,
274
+ });
275
+ continue;
276
+ }
277
+ const t = parseType(tspec);
278
+ if (!t.ok) {
279
+ diag.add({ code: 'E_BAD_TYPE', module: m.name, message: `${target}.${fname}: ${t.reason}` });
280
+ continue;
281
+ }
282
+ // A field added to somebody else's model must be optional: rows already
283
+ // exist and have no value for it. This is enforced, not documented.
284
+ if (!t.optional && t.base !== 'json') {
285
+ diag.add({
286
+ code: 'E_EXTEND_REQUIRES_OPTIONAL',
287
+ module: m.name,
288
+ message: `field "${target}.${fname}" added to another module's model must be optional`,
289
+ hint: `write "${tspec}?" - existing rows have no value for it`,
290
+ });
291
+ continue;
292
+ }
293
+ model.fields[fname] = { base: t.base, optional: t.optional, target: t.target, by: m.name };
294
+ }
295
+ }
296
+ }
297
+ // --- relations -----------------------------------------------------------
298
+ //
299
+ // Checked against the models that exist and the key the relation travels on, so a
300
+ // typo is a build error rather than a query that quietly returns nothing. Reaching
301
+ // another module follows the same rule as extending it: only what you depend on.
302
+ for (const m of order) {
303
+ for (const [modelKey, rels] of Object.entries(m.relations)) {
304
+ const model = manifest.models[modelKey];
305
+ if (!model) {
306
+ diag.add({
307
+ code: 'E_RELATION_UNKNOWN_MODEL',
308
+ module: m.name,
309
+ message: `relation declared on "${modelKey}", which is not a model`,
310
+ });
311
+ continue;
312
+ }
313
+ if (!canSee(m, model.owner)) {
314
+ diag.add({
315
+ code: 'E_RELATION_NOT_DEPENDED',
316
+ module: m.name,
317
+ message: `declares a relation on "${modelKey}" but does not depend on "${model.owner}"`,
318
+ hint: `add "${model.owner}" to ${m.name}.depends`,
319
+ });
320
+ continue;
321
+ }
322
+ for (const [name, def] of Object.entries(rels)) {
323
+ const kind = 'belongsTo' in def ? 'belongsTo' : 'hasMany';
324
+ const target = 'belongsTo' in def ? def.belongsTo : def.hasMany;
325
+ const targetModel = manifest.models[target];
326
+ if (!targetModel) {
327
+ diag.add({
328
+ code: 'E_RELATION_UNKNOWN_TARGET',
329
+ module: m.name,
330
+ message: `relation "${modelKey}.${name}" points at "${target}", which is not a model`,
331
+ hint: `known models: ${Object.keys(manifest.models).join(', ')}`,
332
+ });
333
+ continue;
334
+ }
335
+ if (!canSee(m, targetModel.owner)) {
336
+ diag.add({
337
+ code: 'E_RELATION_NOT_DEPENDED',
338
+ module: m.name,
339
+ message: `relation "${modelKey}.${name}" reaches "${target}" but "${m.name}" does not depend on "${targetModel.owner}"`,
340
+ });
341
+ continue;
342
+ }
343
+ // The key lives on whichever side carries the foreign id: the model itself
344
+ // for belongsTo, the far side for hasMany.
345
+ const holderKey = kind === 'belongsTo' ? modelKey : target;
346
+ const holder = kind === 'belongsTo' ? model : targetModel;
347
+ if (!holder.fields[def.by]) {
348
+ diag.add({
349
+ code: 'E_RELATION_NO_KEY',
350
+ module: m.name,
351
+ message: `relation "${modelKey}.${name}" travels on "${holderKey}.${def.by}", which does not exist`,
352
+ hint: `fields on ${holderKey}: ${Object.keys(holder.fields).join(', ')}`,
353
+ });
354
+ continue;
355
+ }
356
+ // Crossing the company boundary through a relation would be a leak the
357
+ // scope check never sees, because the child query is built from parent ids.
358
+ if (model.scope !== 'shared' && targetModel.scope === 'shared') {
359
+ /* narrowing: fine */
360
+ }
361
+ else if (model.scope === 'shared' && targetModel.scope !== 'shared') {
362
+ diag.add({
363
+ code: 'E_RELATION_WIDENS_SCOPE',
364
+ module: m.name,
365
+ message: `relation "${modelKey}.${name}" reaches company-scoped "${target}" from shared "${modelKey}"`,
366
+ hint: 'a shared row would expose rows of every company through it — put the relation on the scoped side',
367
+ });
368
+ continue;
369
+ }
370
+ ;
371
+ (manifest.relations[modelKey] ??= {})[name] = { kind, target, by: def.by, declaredBy: m.name };
372
+ }
373
+ }
374
+ }
375
+ // --- navigation ----------------------------------------------------------
376
+ //
377
+ // Ids are global and chosen by the module, the way a joint key is: a second
378
+ // module claiming one is a build error naming both, rather than one of them
379
+ // quietly winning. Parenting onto somebody else's entry needs the same declared
380
+ // dependency filling their joint would.
381
+ for (const m of order) {
382
+ for (const [id, def] of Object.entries(m.menus)) {
383
+ const taken = manifest.menus[id];
384
+ if (taken) {
385
+ diag.add({
386
+ code: 'E_MENU_DUPLICATE',
387
+ module: m.name,
388
+ message: `both "${taken.by}" and "${m.name}" declare menu "${id}"`,
389
+ hint: 'menu ids are global — prefix yours with the module name',
390
+ });
391
+ continue;
392
+ }
393
+ manifest.menus[id] = { ...def, by: m.name };
394
+ }
395
+ }
396
+ for (const [id, def] of Object.entries(manifest.menus)) {
397
+ if (def.parent !== undefined) {
398
+ const parent = manifest.menus[def.parent];
399
+ if (!parent) {
400
+ diag.add({
401
+ code: 'E_MENU_UNKNOWN_PARENT',
402
+ module: def.by,
403
+ message: `menu "${id}" hangs under "${def.parent}", which nothing declares`,
404
+ hint: `declared menus: ${Object.keys(manifest.menus).join(', ') || '(none)'}`,
405
+ });
406
+ continue;
407
+ }
408
+ const owner = manifest.modules[def.by];
409
+ if (parent.by !== def.by &&
410
+ !canSee({ name: def.by, depends: owner?.depends ?? [] }, parent.by)) {
411
+ diag.add({
412
+ code: 'E_MENU_NOT_DEPENDED',
413
+ module: def.by,
414
+ message: `menu "${id}" hangs under "${def.parent}", owned by "${parent.by}", which "${def.by}" does not depend on`,
415
+ hint: `add "${parent.by}" to ${def.by}.depends`,
416
+ });
417
+ }
418
+ }
419
+ }
420
+ // --- joints (published extension points) and fills -----------------------
421
+ for (const m of order) {
422
+ for (const [name, def] of Object.entries(m.joints)) {
423
+ manifest.joints[jointKey(m.name, name)] = {
424
+ owner: m.name,
425
+ props: def.props ?? {},
426
+ multiple: def.multiple !== false,
427
+ omittedBy: [],
428
+ };
429
+ }
430
+ }
431
+ for (const m of order) {
432
+ for (const [key, value] of Object.entries(m.fills)) {
433
+ const joint = manifest.joints[key];
434
+ if (!joint) {
435
+ const near = Object.keys(manifest.joints).filter((k) => k.split(':')[1] === key.split(':')[1]);
436
+ diag.add({
437
+ code: 'E_FILL_UNKNOWN_JOINT',
438
+ module: m.name,
439
+ message: `fills joint "${key}", which no installed module publishes`,
440
+ hint: near.length
441
+ ? `did you mean "${near[0]}"?`
442
+ : `published joints: ${Object.keys(manifest.joints).join(', ') || '(none)'}`,
443
+ });
444
+ continue;
445
+ }
446
+ if (!canSee(m, joint.owner)) {
447
+ diag.add({
448
+ code: 'E_FILL_NOT_DEPENDED',
449
+ module: m.name,
450
+ message: `fills "${key}" but does not depend on "${joint.owner}"`,
451
+ hint: `add "${joint.owner}" to ${m.name}.depends`,
452
+ });
453
+ continue;
454
+ }
455
+ manifest.fills.push({ joint: key, by: m.name, template: value });
456
+ }
457
+ }
458
+ for (const [key, joint] of Object.entries(manifest.joints)) {
459
+ if (joint.multiple)
460
+ continue;
461
+ const fillers = manifest.fills.filter((fill) => fill.joint === key).map((fill) => fill.by);
462
+ if (fillers.length > 1) {
463
+ diag.add({
464
+ code: 'E_JOINT_CARDINALITY',
465
+ module: joint.owner,
466
+ message: `joint "${key}" accepts one fill but ${fillers.length} modules fill it`,
467
+ hint: `fillers: ${fillers.join(', ')}; set multiple:true or keep one contributor`,
468
+ });
469
+ }
470
+ }
471
+ // Omissions travel the same road as fills: a declared joint, and a declared
472
+ // dependency on whoever published it.
473
+ for (const m of order) {
474
+ for (const key of m.omits) {
475
+ const joint = manifest.joints[key];
476
+ if (!joint) {
477
+ diag.add({
478
+ code: 'E_OMIT_UNKNOWN_JOINT',
479
+ module: m.name,
480
+ message: `omits joint "${key}", which no installed module publishes`,
481
+ hint: `published joints: ${Object.keys(manifest.joints).join(', ') || '(none)'}`,
482
+ });
483
+ continue;
484
+ }
485
+ if (!canSee(m, joint.owner)) {
486
+ diag.add({
487
+ code: 'E_OMIT_NOT_DEPENDED',
488
+ module: m.name,
489
+ message: `omits "${key}" but does not depend on "${joint.owner}"`,
490
+ hint: `add "${joint.owner}" to ${m.name}.depends`,
491
+ });
492
+ continue;
493
+ }
494
+ joint.omittedBy.push(m.name);
495
+ }
496
+ }
497
+ // An omitted joint that somebody else fills is a fill nobody will ever see. It
498
+ // is not an error — the two modules may be deliberate — but it is exactly the
499
+ // kind of thing that gets discovered six months later, so it is recorded where
500
+ // `ket check` and the upgrade diff will show it.
501
+ for (const [key, joint] of Object.entries(manifest.joints)) {
502
+ if (!joint.omittedBy.length)
503
+ continue;
504
+ const fillers = manifest.fills.filter((f) => f.joint === key).map((f) => f.by);
505
+ if (fillers.length) {
506
+ manifest.patches.push({
507
+ by: joint.omittedBy.join(', '),
508
+ target: key,
509
+ reason: `omitted, so fills from ${fillers.join(', ')} will not render`,
510
+ });
511
+ }
512
+ }
513
+ // --- server functions ----------------------------------------------------
514
+ for (const m of order) {
515
+ for (const [fname, def] of Object.entries(m.functions)) {
516
+ if (def.exposure !== undefined && def.exposure !== 'http' && def.exposure !== 'internal') {
517
+ diag.add({
518
+ code: 'E_FUNCTION_EXPOSURE',
519
+ module: m.name,
520
+ message: `function "${qualify(m.name, fname)}" has unknown exposure "${String(def.exposure)}"`,
521
+ hint: 'use "http" or "internal"',
522
+ });
523
+ }
524
+ if (def.provision === true && def.exposure !== 'internal') {
525
+ diag.add({
526
+ code: 'E_PROVISION_EXPOSED',
527
+ module: m.name,
528
+ message: `provision function "${qualify(m.name, fname)}" must be internal`,
529
+ hint: 'set exposure: "internal" so bootstrap credentials never have a generic endpoint',
530
+ });
531
+ }
532
+ manifest.functions[qualify(m.name, fname)] = {
533
+ by: m.name,
534
+ input: def.input ?? {},
535
+ output: def.output ?? {},
536
+ effects: [...(def.effects ?? [])],
537
+ crossCompany: def.crossCompany === true,
538
+ anonymous: def.anonymous === true,
539
+ exposure: def.exposure ?? 'http',
540
+ provision: def.provision === true,
541
+ idempotent: def.idempotent === true,
542
+ dryRun: def.dryRun !== false,
543
+ agent: def.agent === true,
544
+ };
545
+ }
546
+ }
547
+ // --- background jobs -----------------------------------------------------
548
+ //
549
+ // Jobs run later and often on another process, but they touch the same data.
550
+ // Their contract is therefore composed and checked as strictly as a function's
551
+ // rather than being left as an import-time registry only the worker can see.
552
+ for (const m of order) {
553
+ for (const [name, def] of Object.entries(m.jobs)) {
554
+ const key = qualify(m.name, name);
555
+ if (!/^[a-z][a-zA-Z0-9_]*$/.test(name)) {
556
+ diag.add({ code: 'E_JOB_NAME', module: m.name, message: `invalid job name "${name}"` });
557
+ continue;
558
+ }
559
+ const queue = def.queue ?? 'default';
560
+ if (!/^[a-z][a-z0-9_-]*$/.test(queue)) {
561
+ diag.add({
562
+ code: 'E_JOB_QUEUE',
563
+ module: m.name,
564
+ message: `job "${key}" has invalid queue "${queue}"`,
565
+ hint: 'use lowercase letters, digits, underscore or dash',
566
+ });
567
+ continue;
568
+ }
569
+ if (def.idempotent !== true) {
570
+ diag.add({
571
+ code: 'E_JOB_NOT_IDEMPOTENT',
572
+ module: m.name,
573
+ message: `job "${key}" must declare idempotent: true`,
574
+ hint: 'workers provide at-least-once delivery, so a crashed job may run again',
575
+ });
576
+ continue;
577
+ }
578
+ const maxAttempts = def.maxAttempts ?? 20;
579
+ const timeoutMs = def.timeoutMs ?? 300_000;
580
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
581
+ diag.add({ code: 'E_JOB_ATTEMPTS', module: m.name, message: `job "${key}" needs maxAttempts >= 1` });
582
+ continue;
583
+ }
584
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1) {
585
+ diag.add({ code: 'E_JOB_TIMEOUT', module: m.name, message: `job "${key}" needs timeoutMs >= 1` });
586
+ continue;
587
+ }
588
+ for (const [input, spec] of Object.entries(def.input ?? {})) {
589
+ const parsed = parseType(spec);
590
+ if (!parsed.ok)
591
+ diag.add({ code: 'E_BAD_TYPE', module: m.name, message: `${key} input ${input}: ${parsed.reason}` });
592
+ }
593
+ for (const effect of def.effects ?? []) {
594
+ // Enqueue targets are validated after every job has been collected, so a
595
+ // producer may refer to a job contributed later in dependency order.
596
+ if (effect.startsWith('enqueue'))
597
+ continue;
598
+ if (effect === 'storage:read' ||
599
+ effect === 'storage:write' ||
600
+ effect === 'storage:remove' ||
601
+ effect === 'transport:send')
602
+ continue;
603
+ const match = /^(read|write):(.+)$/.exec(effect);
604
+ const model = match ? manifest.models[match[2]] : null;
605
+ if (!match || !model) {
606
+ diag.add({
607
+ code: 'E_JOB_EFFECT',
608
+ module: m.name,
609
+ message: `job "${key}" declares unknown effect "${effect}"`,
610
+ });
611
+ continue;
612
+ }
613
+ if (!canSee(m, model.owner)) {
614
+ diag.add({
615
+ code: 'E_JOB_EFFECT_NOT_DEPENDED',
616
+ module: m.name,
617
+ message: `job "${key}" touches ${match[2]} but does not depend on "${model.owner}"`,
618
+ });
619
+ }
620
+ }
621
+ manifest.jobs[key] = {
622
+ by: m.name,
623
+ queue,
624
+ input: { ...(def.input ?? {}) },
625
+ effects: [...(def.effects ?? [])],
626
+ crossCompany: def.crossCompany === true,
627
+ idempotent: true,
628
+ maxAttempts,
629
+ timeoutMs,
630
+ };
631
+ }
632
+ }
633
+ // Enqueue is a first-class effect. Moving a write to another process must not
634
+ // let the producer bypass the operation boundary: both functions and jobs must
635
+ // name the exact background operation they are allowed to schedule.
636
+ for (const m of order) {
637
+ const producers = [
638
+ ...Object.entries(m.functions).map(([name, def]) => ({
639
+ kind: 'function',
640
+ key: qualify(m.name, name),
641
+ effects: def.effects ?? [],
642
+ })),
643
+ ...Object.entries(m.jobs).map(([name, def]) => ({
644
+ kind: 'job',
645
+ key: qualify(m.name, name),
646
+ effects: def.effects ?? [],
647
+ })),
648
+ ];
649
+ for (const producer of producers) {
650
+ for (const effect of producer.effects) {
651
+ if (!effect.startsWith('enqueue'))
652
+ continue;
653
+ const match = /^enqueue:(.+)$/.exec(effect);
654
+ const target = match ? manifest.jobs[match[1]] : null;
655
+ if (!match || !target) {
656
+ diag.add({
657
+ code: producer.kind === 'job' ? 'E_JOB_EFFECT' : 'E_FN_EFFECT',
658
+ module: m.name,
659
+ message: `${producer.kind} "${producer.key}" declares unknown effect "${effect}"`,
660
+ });
661
+ continue;
662
+ }
663
+ if (!canSee(m, target.by)) {
664
+ diag.add({
665
+ code: producer.kind === 'job' ? 'E_JOB_EFFECT_NOT_DEPENDED' : 'E_FN_EFFECT_NOT_DEPENDED',
666
+ module: m.name,
667
+ message: `${producer.kind} "${producer.key}" enqueues ${match[1]} but does not depend on "${target.by}"`,
668
+ });
669
+ }
670
+ }
671
+ }
672
+ }
673
+ // A gate on a function that does not exist. Checked here rather than with the
674
+ // rest of navigation, because the functions it names are only collected above.
675
+ //
676
+ // Which of two things it is depends on whether the module is here. If it is and
677
+ // the function is not, somebody mistyped, and hiding the entry would hide the
678
+ // mistake — that is a build error. If the module is absent, the entry is gated on
679
+ // something this deployment simply does not ship, which is a soft dependency and
680
+ // exactly what a gate is for: buildMenu drops it and nobody is told off.
681
+ for (const [id, def] of Object.entries(manifest.menus)) {
682
+ if (def.needs === undefined || manifest.functions[def.needs])
683
+ continue;
684
+ const owner = def.needs.split('.')[0];
685
+ if (!manifest.modules[owner])
686
+ continue;
687
+ diag.add({
688
+ code: 'E_MENU_UNKNOWN_FUNCTION',
689
+ module: def.by,
690
+ message: `menu "${id}" needs "${def.needs}", which "${owner}" does not declare`,
691
+ hint: 'the entry is hidden from anyone who may not call it, so the name has to exist',
692
+ });
693
+ }
694
+ // --- view models: the only data surface a theme may read -----------------
695
+ for (const m of order) {
696
+ for (const [vname, def] of Object.entries(m.views)) {
697
+ const key = qualify(m.name, vname);
698
+ const model = manifest.models[def.of];
699
+ if (!model) {
700
+ diag.add({
701
+ code: 'E_VIEW_UNKNOWN_MODEL',
702
+ module: m.name,
703
+ message: `view "${key}" projects unknown model "${def.of}"`,
704
+ });
705
+ continue;
706
+ }
707
+ const missing = (def.fields ?? []).filter((f) => !model.fields[f]);
708
+ if (missing.length) {
709
+ diag.add({
710
+ code: 'E_VIEW_UNKNOWN_FIELD',
711
+ module: m.name,
712
+ message: `view "${key}" exposes field(s) not on ${def.of}: ${missing.join(', ')}`,
713
+ hint: `available: ${Object.keys(model.fields).join(', ')}`,
714
+ });
715
+ continue;
716
+ }
717
+ manifest.views[key] = { of: def.of, fields: [...(def.fields ?? [])], by: m.name };
718
+ }
719
+ }
720
+ // Joint and island props use the scalar vocabulary or a declared view-model key.
721
+ // Views are composed first so a contract may name one regardless of module order.
722
+ const validContractType = (spec) => {
723
+ if (typeof spec !== 'string')
724
+ return false;
725
+ if (parseType(spec).ok)
726
+ return true;
727
+ const view = spec.endsWith('?') ? spec.slice(0, -1) : spec;
728
+ return manifest.views[view] !== undefined;
729
+ };
730
+ for (const [key, joint] of Object.entries(manifest.joints)) {
731
+ for (const [name, spec] of Object.entries(joint.props)) {
732
+ if (!validContractType(spec)) {
733
+ diag.add({
734
+ code: 'E_JOINT_PROP_TYPE',
735
+ module: joint.owner,
736
+ message: `joint "${key}" prop "${name}" has unknown type "${spec}"`,
737
+ hint: 'use a scalar type or a composed view-model key',
738
+ });
739
+ }
740
+ }
741
+ }
742
+ // --- islands -------------------------------------------------------------
743
+ for (const m of order) {
744
+ for (const [name, def] of Object.entries(m.islands)) {
745
+ const existing = manifest.islands[name];
746
+ if (existing) {
747
+ diag.add({
748
+ code: 'E_ISLAND_DUPLICATE',
749
+ module: m.name,
750
+ message: `island "${name}" is already provided by "${existing.by}"`,
751
+ });
752
+ continue;
753
+ }
754
+ if (!def || typeof def !== 'object' || typeof def.view !== 'function') {
755
+ diag.add({
756
+ code: 'E_ISLAND_SHAPE',
757
+ module: m.name,
758
+ message: `island "${name}" needs a view factory`,
759
+ hint: 'declare { view: props => () => html`...`, props, client? }',
760
+ });
761
+ continue;
762
+ }
763
+ for (const [prop, spec] of Object.entries(def.props ?? {})) {
764
+ if (!validContractType(spec)) {
765
+ diag.add({
766
+ code: 'E_ISLAND_PROP_TYPE',
767
+ module: m.name,
768
+ message: `island "${name}" prop "${prop}" has unknown type "${spec}"`,
769
+ hint: 'use a scalar type or a composed view-model key',
770
+ });
771
+ }
772
+ }
773
+ if (def.key !== undefined && !Array.isArray(def.key)) {
774
+ diag.add({
775
+ code: 'E_ISLAND_KEY',
776
+ module: m.name,
777
+ message: `island "${name}" key must be an array of required scalar prop names`,
778
+ });
779
+ }
780
+ else if (Array.isArray(def.key)) {
781
+ if (new Set(def.key).size !== def.key.length) {
782
+ diag.add({ code: 'E_ISLAND_KEY', module: m.name, message: `island "${name}" repeats a key prop` });
783
+ }
784
+ for (const field of def.key) {
785
+ const spec = def.props?.[field];
786
+ const parsed = spec === undefined ? null : parseType(spec);
787
+ if (!parsed?.ok || parsed.optional || parsed.base === 'json') {
788
+ diag.add({
789
+ code: 'E_ISLAND_KEY',
790
+ module: m.name,
791
+ message: `island "${name}" key prop "${field}" must be a declared, required scalar`,
792
+ });
793
+ }
794
+ }
795
+ }
796
+ const client = def.client;
797
+ if (client !== undefined && (typeof client !== 'string' || client.length === 0)) {
798
+ diag.add({
799
+ code: 'E_ISLAND_CLIENT',
800
+ module: m.name,
801
+ message: `island "${name}" client must be a non-empty relative path`,
802
+ });
803
+ }
804
+ if (typeof client === 'string' && client && !m.assets) {
805
+ diag.add({
806
+ code: 'E_ISLAND_CLIENT_WITHOUT_ASSETS',
807
+ module: m.name,
808
+ message: `island "${name}" declares client module "${client}" but "${m.name}" has no assets directory`,
809
+ hint: 'declare module assets and place the prebuilt browser module inside it',
810
+ });
811
+ }
812
+ if (typeof client === 'string' &&
813
+ client &&
814
+ (client.startsWith('/') ||
815
+ client.includes('\\') ||
816
+ client.includes('?') ||
817
+ client.includes('#') ||
818
+ client.split('/').includes('..'))) {
819
+ diag.add({
820
+ code: 'E_ISLAND_CLIENT_PATH',
821
+ module: m.name,
822
+ message: `island "${name}" client path must stay inside the module assets directory`,
823
+ });
824
+ }
825
+ manifest.islands[name] = {
826
+ by: m.name,
827
+ props: { ...(def.props ?? {}) },
828
+ ...(def.key === undefined ? {} : { key: [...def.key] }),
829
+ ...(typeof client === 'string' && client
830
+ ? {
831
+ client: {
832
+ src: `/_ket/asset/${m.name}/${client}`,
833
+ export: def.export ?? 'default',
834
+ },
835
+ }
836
+ : {}),
837
+ };
838
+ }
839
+ }
840
+ // --- sections: placeable by data, so their settings must be declared -------
841
+ for (const m of order) {
842
+ for (const [name, def] of Object.entries(m.sections)) {
843
+ const existing = manifest.sections[name];
844
+ if (existing) {
845
+ diag.add({
846
+ code: 'E_SECTION_DUPLICATE',
847
+ module: m.name,
848
+ message: `section "${name}" is already provided by "${existing.by}"`,
849
+ });
850
+ continue;
851
+ }
852
+ manifest.sections[name] = { ...def, by: m.name };
853
+ }
854
+ }
855
+ // --- CMS content registry --------------------------------------------------
856
+ // Local names are qualified exactly like models and views. A module may point
857
+ // at another module's type or taxonomy only when it depends on that module.
858
+ const registryRef = (m, name) => name.includes('.') ? name : qualify(m.name, name);
859
+ for (const m of order) {
860
+ for (const [name, def] of Object.entries(m.contentTypes ?? {})) {
861
+ const key = qualify(m.name, name);
862
+ const fields = {};
863
+ for (const [field, type] of Object.entries(def.fields ?? {})) {
864
+ const parsed = parseType(type);
865
+ if (!parsed.ok) {
866
+ diag.add({
867
+ code: 'E_CONTENT_FIELD_TYPE',
868
+ module: m.name,
869
+ message: `${key}.${field}: ${parsed.reason}`,
870
+ });
871
+ continue;
872
+ }
873
+ fields[field] = type;
874
+ }
875
+ if (def.detailPath && !def.detailPath.includes('{slug}')) {
876
+ diag.add({
877
+ code: 'E_CONTENT_DETAIL_PATH',
878
+ module: m.name,
879
+ message: `content type "${key}" detailPath must contain {slug}`,
880
+ });
881
+ }
882
+ manifest.contentTypes[key] = {
883
+ ...def,
884
+ by: m.name,
885
+ fields,
886
+ taxonomies: (def.taxonomies ?? []).map((ref) => registryRef(m, ref)),
887
+ };
888
+ }
889
+ for (const [name, def] of Object.entries(m.taxonomies ?? {})) {
890
+ const key = qualify(m.name, name);
891
+ manifest.taxonomies[key] = {
892
+ ...def,
893
+ by: m.name,
894
+ hierarchical: def.hierarchical === true,
895
+ contentTypes: def.contentTypes.map((ref) => registryRef(m, ref)),
896
+ };
897
+ }
898
+ }
899
+ for (const [key, type] of Object.entries(manifest.contentTypes)) {
900
+ for (const taxonomy of type.taxonomies) {
901
+ const target = manifest.taxonomies[taxonomy];
902
+ if (!target) {
903
+ diag.add({
904
+ code: 'E_CONTENT_TAXONOMY_MISSING',
905
+ module: type.by,
906
+ message: `content type "${key}" references unknown taxonomy "${taxonomy}"`,
907
+ });
908
+ continue;
909
+ }
910
+ if (!canSee(order.find((m) => m.name === type.by), target.by))
911
+ diag.add({
912
+ code: 'E_CONTENT_TAXONOMY_DEPENDENCY',
913
+ module: type.by,
914
+ message: `content type "${key}" reaches taxonomy "${taxonomy}" without depending on "${target.by}"`,
915
+ });
916
+ }
917
+ }
918
+ for (const [key, taxonomy] of Object.entries(manifest.taxonomies)) {
919
+ for (const contentType of taxonomy.contentTypes) {
920
+ const target = manifest.contentTypes[contentType];
921
+ if (!target) {
922
+ diag.add({
923
+ code: 'E_TAXONOMY_CONTENT_MISSING',
924
+ module: taxonomy.by,
925
+ message: `taxonomy "${key}" references unknown content type "${contentType}"`,
926
+ });
927
+ continue;
928
+ }
929
+ if (!canSee(order.find((m) => m.name === taxonomy.by), target.by))
930
+ diag.add({
931
+ code: 'E_TAXONOMY_CONTENT_DEPENDENCY',
932
+ module: taxonomy.by,
933
+ message: `taxonomy "${key}" reaches content type "${contentType}" without depending on "${target.by}"`,
934
+ });
935
+ }
936
+ }
937
+ // --- theme <-> app region contract ---------------------------------------
938
+ for (const m of order) {
939
+ for (const r of m.provides)
940
+ (manifest.regions.provided[r] ??= []).push(m.name);
941
+ for (const name of Object.keys(m.templates)) {
942
+ const list = (manifest.regions.provided[name] ??= []);
943
+ if (!list.includes(m.name))
944
+ list.push(m.name);
945
+ }
946
+ for (const r of m.requires)
947
+ if (!manifest.regions.required.includes(r))
948
+ manifest.regions.required.push(r);
949
+ }
950
+ // A headless app renders nothing, so the region contract does not apply to it.
951
+ // Requirements are still recorded, so adding a theme later checks them.
952
+ for (const r of opts.headless ? [] : manifest.regions.required) {
953
+ if (!manifest.regions.provided[r]) {
954
+ diag.add({
955
+ code: 'E_REGION_MISSING',
956
+ message: `region "${r}" is required but no installed theme provides it`,
957
+ hint: `add a template named "${r}" to your theme, or drop the requirement`,
958
+ });
959
+ }
960
+ }
961
+ // --- messages: prefixed by module, so two modules may both own a "title" ---
962
+ for (const m of order) {
963
+ for (const [locale, catalog] of Object.entries(m.messages)) {
964
+ const target = (manifest.messages[locale] ??= {});
965
+ for (const [key, message] of Object.entries(catalog))
966
+ target[`${m.name}.${key}`] = message;
967
+ }
968
+ }
969
+ // --- tokens: later modules layer over earlier ones ------------------------
970
+ for (const m of order)
971
+ Object.assign(manifest.tokens, m.tokens);
972
+ diag.throwIfAny();
973
+ manifest.diagnostics = [];
974
+ return manifest;
975
+ }
976
+ //# sourceMappingURL=compose.js.map