@jskit-ai/kernel 0.1.55 → 0.1.57

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 (57) hide show
  1. package/package.json +3 -2
  2. package/server/actions/ActionRuntimeServiceProvider.test.js +23 -15
  3. package/server/http/lib/kernel.test.js +447 -0
  4. package/server/http/lib/routeRegistration.js +236 -15
  5. package/server/http/lib/routeTransport.js +126 -0
  6. package/server/http/lib/routeValidator.js +133 -198
  7. package/server/http/lib/routeValidator.test.js +385 -278
  8. package/server/http/lib/router.js +17 -2
  9. package/server/platform/providerRuntime.test.js +7 -7
  10. package/server/runtime/bootBootstrapRoutes.js +2 -18
  11. package/server/runtime/bootBootstrapRoutes.test.js +5 -14
  12. package/server/runtime/fastifyBootstrap.js +119 -0
  13. package/server/runtime/fastifyBootstrap.test.js +119 -1
  14. package/server/runtime/moduleConfig.js +32 -62
  15. package/server/runtime/moduleConfig.test.js +48 -24
  16. package/server/support/pageTargets.js +15 -9
  17. package/server/support/pageTargets.test.js +1 -1
  18. package/shared/actions/actionContributorHelpers.js +5 -11
  19. package/shared/actions/actionDefinitions.js +37 -150
  20. package/shared/actions/actionDefinitions.test.js +117 -136
  21. package/shared/actions/policies.js +25 -169
  22. package/shared/actions/policies.test.js +76 -87
  23. package/shared/actions/registry.test.js +24 -50
  24. package/shared/support/crudFieldContract.js +322 -0
  25. package/shared/support/crudFieldContract.test.js +67 -0
  26. package/shared/support/crudListFilters.js +582 -38
  27. package/shared/support/crudListFilters.test.js +178 -8
  28. package/shared/support/crudLookup.js +14 -7
  29. package/shared/support/crudLookup.test.js +91 -66
  30. package/shared/support/normalize.js +7 -0
  31. package/shared/support/normalize.test.js +4 -2
  32. package/shared/support/shellLayoutTargets.test.js +1 -1
  33. package/shared/validators/composeSchemaDefinitions.js +53 -0
  34. package/shared/validators/composeSchemaDefinitions.test.js +156 -0
  35. package/shared/validators/createCursorListValidator.js +22 -35
  36. package/shared/validators/createCursorListValidator.test.js +22 -23
  37. package/shared/validators/cursorPaginationQueryValidator.js +14 -24
  38. package/shared/validators/cursorPaginationQueryValidator.test.js +18 -8
  39. package/shared/validators/htmlTimeSchemas.js +6 -4
  40. package/shared/validators/index.js +15 -7
  41. package/shared/validators/jsonRestSchemaSupport.js +139 -0
  42. package/shared/validators/mergeObjectSchemas.js +44 -6
  43. package/shared/validators/mergeObjectSchemas.test.js +60 -35
  44. package/shared/validators/recordIdParamsValidator.js +19 -52
  45. package/shared/validators/recordIdParamsValidator.test.js +13 -8
  46. package/shared/validators/resourceRequiredMetadata.js +3 -3
  47. package/shared/validators/resourceRequiredMetadata.test.js +29 -16
  48. package/shared/validators/schemaDefinitions.js +126 -0
  49. package/shared/validators/schemaDefinitions.test.js +51 -0
  50. package/shared/validators/schemaPayloadValidation.js +65 -0
  51. package/test/barrelExposure.test.js +30 -0
  52. package/test/routeInputContractGuard.test.js +10 -6
  53. package/shared/validators/mergeValidators.js +0 -89
  54. package/shared/validators/mergeValidators.test.js +0 -116
  55. package/shared/validators/nestValidator.js +0 -53
  56. package/shared/validators/nestValidator.test.js +0 -60
  57. package/shared/validators/settingsFieldNormalization.js +0 -40
@@ -2,6 +2,21 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
4
  defineCrudListFilters,
5
+ CRUD_LIST_FILTER_INVALID_VALUES_REJECT,
6
+ CRUD_LIST_FILTER_INVALID_VALUES_DISCARD,
7
+ INVALID_CRUD_LIST_FILTER_QUERY_VALUE,
8
+ parseCrudListRangeQueryExpression,
9
+ formatCrudListRangeQueryExpression,
10
+ createCrudListFilterInitialValue,
11
+ isCrudListFilterMultiValue,
12
+ isCrudListFilterStructuredValue,
13
+ normalizeCrudListFilterUiValue,
14
+ areCrudListFilterUiValuesEqual,
15
+ hasCrudListFilterUiValue,
16
+ listCrudListFilterChipValues,
17
+ formatCrudListFilterDefaultChipLabel,
18
+ formatCrudListFilterQueryValue,
19
+ parseCrudListFilterQueryValue,
5
20
  resolveCrudListFilterQueryKeys,
6
21
  resolveCrudListFilterOptionLabel
7
22
  } from "./crudListFilters.js";
@@ -52,8 +67,7 @@ test("defineCrudListFilters normalizes common filter shapes", () => {
52
67
  key: "arrivalDate",
53
68
  type: "dateRange",
54
69
  label: "Arrival Date",
55
- fromKey: "arrivalDateFrom",
56
- toKey: "arrivalDateTo",
70
+ queryKey: "arrivalDate",
57
71
  options: [],
58
72
  lookup: null,
59
73
  chipLabel: null,
@@ -64,8 +78,7 @@ test("defineCrudListFilters normalizes common filter shapes", () => {
64
78
  key: "weight",
65
79
  type: "numberRange",
66
80
  label: "Weight",
67
- minKey: "weightMin",
68
- maxKey: "weightMax",
81
+ queryKey: "weight",
69
82
  options: [],
70
83
  lookup: null,
71
84
  chipLabel: null,
@@ -104,12 +117,37 @@ test("defineCrudListFilters rejects duplicate query keys", () => {
104
117
  type: "dateRange",
105
118
  label: "Arrival Date"
106
119
  },
107
- arrivalDateFrom: {
120
+ arrivalDateExact: {
108
121
  type: "date",
109
- label: "Arrival Date From"
122
+ label: "Arrival Date From",
123
+ queryKey: "arrivalDate"
110
124
  }
111
125
  }),
112
- /both use query key "arrivalDateFrom"/
126
+ /both use query key "arrivalDate"/
127
+ );
128
+ });
129
+
130
+ test("defineCrudListFilters rejects split range keys", () => {
131
+ assert.throws(
132
+ () => defineCrudListFilters({
133
+ arrivalDate: {
134
+ type: "dateRange",
135
+ label: "Arrival Date",
136
+ fromKey: "arrivalDateFrom"
137
+ }
138
+ }),
139
+ /unsupported split range keys/
140
+ );
141
+
142
+ assert.throws(
143
+ () => defineCrudListFilters({
144
+ weight: {
145
+ type: "numberRange",
146
+ label: "Weight",
147
+ minKey: "weightMin"
148
+ }
149
+ }),
150
+ /unsupported split range keys/
113
151
  );
114
152
  });
115
153
 
@@ -129,7 +167,139 @@ test("resolveCrudListFilter helpers expose query keys and option labels", () =>
129
167
  });
130
168
 
131
169
  assert.deepEqual(resolveCrudListFilterQueryKeys(filters.status), ["status"]);
132
- assert.deepEqual(resolveCrudListFilterQueryKeys(filters.arrivalDate), ["arrivalDateFrom", "arrivalDateTo"]);
170
+ assert.deepEqual(resolveCrudListFilterQueryKeys(filters.arrivalDate), ["arrivalDate"]);
133
171
  assert.equal(resolveCrudListFilterOptionLabel(filters.status, "active"), "Active");
134
172
  assert.equal(resolveCrudListFilterOptionLabel(filters.status, "missing", { fallback: "Unknown" }), "Unknown");
135
173
  });
174
+
175
+ test("crud list range helpers parse and format single-key range expressions", () => {
176
+ assert.deepEqual(parseCrudListRangeQueryExpression("2026-04-01"), {
177
+ exact: true,
178
+ start: "2026-04-01",
179
+ end: "2026-04-01"
180
+ });
181
+ assert.deepEqual(parseCrudListRangeQueryExpression("2026-04-01..2026-04-30"), {
182
+ exact: false,
183
+ start: "2026-04-01",
184
+ end: "2026-04-30"
185
+ });
186
+ assert.deepEqual(parseCrudListRangeQueryExpression("..2026-04-30"), {
187
+ exact: false,
188
+ start: "",
189
+ end: "2026-04-30"
190
+ });
191
+ assert.equal(parseCrudListRangeQueryExpression(".."), null);
192
+
193
+ assert.equal(formatCrudListRangeQueryExpression("2026-04-01", ""), "2026-04-01..");
194
+ assert.equal(
195
+ formatCrudListRangeQueryExpression("2026-04-01", "2026-04-01", { collapseExact: true }),
196
+ "2026-04-01"
197
+ );
198
+ });
199
+
200
+ test("crud list filter helpers share canonical UI and query normalization", () => {
201
+ const filters = defineCrudListFilters({
202
+ status: {
203
+ type: "enumMany",
204
+ label: "Status",
205
+ options: [
206
+ { value: "active", label: "Active" },
207
+ { value: "archived", label: "Archived" }
208
+ ]
209
+ },
210
+ supplierContactId: {
211
+ type: "recordIdMany",
212
+ label: "Supplier"
213
+ },
214
+ arrivalDate: {
215
+ type: "dateRange",
216
+ label: "Arrival Date"
217
+ }
218
+ });
219
+
220
+ assert.deepEqual(createCrudListFilterInitialValue(filters.status), []);
221
+ assert.equal(isCrudListFilterMultiValue(filters.status), true);
222
+ assert.equal(isCrudListFilterMultiValue(filters.arrivalDate), false);
223
+ assert.equal(isCrudListFilterStructuredValue(filters.arrivalDate), true);
224
+ assert.equal(isCrudListFilterStructuredValue(filters.status), false);
225
+ assert.deepEqual(
226
+ normalizeCrudListFilterUiValue(filters.status, ["active", "unexpected"]),
227
+ ["active"]
228
+ );
229
+ assert.deepEqual(
230
+ parseCrudListFilterQueryValue(filters.status, ["active", "unexpected"], {
231
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_DISCARD
232
+ }),
233
+ ["active"]
234
+ );
235
+ assert.equal(
236
+ parseCrudListFilterQueryValue(filters.status, ["active", "unexpected"], {
237
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
238
+ }),
239
+ INVALID_CRUD_LIST_FILTER_QUERY_VALUE
240
+ );
241
+
242
+ assert.deepEqual(
243
+ normalizeCrudListFilterUiValue(filters.supplierContactId, ["7", "bad", 4]),
244
+ ["7", "4"]
245
+ );
246
+ assert.deepEqual(
247
+ parseCrudListFilterQueryValue(filters.supplierContactId, ["7", "bad", 4], {
248
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_DISCARD
249
+ }),
250
+ ["7", "4"]
251
+ );
252
+ assert.equal(
253
+ parseCrudListFilterQueryValue(filters.supplierContactId, { bad: true }, {
254
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
255
+ }),
256
+ INVALID_CRUD_LIST_FILTER_QUERY_VALUE
257
+ );
258
+
259
+ assert.deepEqual(
260
+ normalizeCrudListFilterUiValue(filters.arrivalDate, "..2026-04-30"),
261
+ {
262
+ from: "",
263
+ to: "2026-04-30"
264
+ }
265
+ );
266
+ assert.equal(
267
+ formatCrudListFilterQueryValue(filters.arrivalDate, {
268
+ from: "2026-04-30",
269
+ to: "2026-04-30"
270
+ }),
271
+ "2026-04-30"
272
+ );
273
+ assert.equal(
274
+ areCrudListFilterUiValuesEqual(
275
+ filters.arrivalDate,
276
+ {
277
+ from: "2026-04-30",
278
+ to: "2026-04-30"
279
+ },
280
+ "2026-04-30"
281
+ ),
282
+ true
283
+ );
284
+ assert.equal(hasCrudListFilterUiValue(filters.status, ["active", "unexpected"]), true);
285
+ assert.equal(hasCrudListFilterUiValue(filters.arrivalDate, { from: "", to: "" }), false);
286
+ assert.deepEqual(listCrudListFilterChipValues(filters.status, ["active", "unexpected"]), ["active"]);
287
+ assert.deepEqual(
288
+ listCrudListFilterChipValues(filters.arrivalDate, {
289
+ from: "2026-04-30",
290
+ to: "2026-04-30"
291
+ }),
292
+ [{
293
+ from: "2026-04-30",
294
+ to: "2026-04-30"
295
+ }]
296
+ );
297
+ assert.equal(
298
+ formatCrudListFilterDefaultChipLabel(filters.status, "active", {
299
+ resolveAtomicValue(value) {
300
+ return resolveCrudListFilterOptionLabel(filters.status, value);
301
+ }
302
+ }),
303
+ "Status: Active"
304
+ );
305
+ });
@@ -1,5 +1,9 @@
1
1
  import { normalizeText } from "./normalize.js";
2
2
  import { normalizePathname } from "../surface/paths.js";
3
+ import {
4
+ buildCrudFieldContractMap,
5
+ resolveCrudFieldSchemaProperties
6
+ } from "./crudFieldContract.js";
3
7
 
4
8
  const DEFAULT_CRUD_LOOKUP_CONTAINER_KEY = "lookups";
5
9
 
@@ -66,7 +70,9 @@ function resolveCrudLookupContainerKey(resource = {}, options = {}) {
66
70
 
67
71
  function resolveCrudLookupFieldEntries(resource = {}, { allowKeys = [] } = {}) {
68
72
  const source = resource && typeof resource === "object" && !Array.isArray(resource) ? resource : {};
69
- const entries = Array.isArray(source.fieldMeta) ? source.fieldMeta : [];
73
+ const entries = Object.values(buildCrudFieldContractMap(source, {
74
+ context: "crud lookup field entries"
75
+ }));
70
76
  const allowedKeySet = new Set(
71
77
  (Array.isArray(allowKeys) ? allowKeys : [])
72
78
  .map((entry) => normalizeText(entry))
@@ -109,12 +115,13 @@ function resolveCrudLookupFieldEntries(resource = {}, { allowKeys = [] } = {}) {
109
115
  }
110
116
 
111
117
  function resolveCrudLookupCreateSchemaKeys(resource = {}) {
112
- const createSchemaProperties = resource?.operations?.create?.bodyValidator?.schema?.properties;
113
- if (!createSchemaProperties || typeof createSchemaProperties !== "object" || Array.isArray(createSchemaProperties)) {
114
- return Object.freeze([]);
115
- }
116
-
117
- return Object.freeze(Object.keys(createSchemaProperties));
118
+ return Object.freeze(
119
+ Object.keys(
120
+ resolveCrudFieldSchemaProperties(resource?.operations?.create?.body, {
121
+ context: "crud lookup create schema"
122
+ })
123
+ )
124
+ );
118
125
  }
119
126
 
120
127
  function resolveCrudLookupFieldKeys(resource = {}, { allowKeys = [] } = {}) {
@@ -1,5 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { createSchema } from "json-rest-schema";
3
4
  import {
4
5
  DEFAULT_CRUD_LOOKUP_CONTAINER_KEY,
5
6
  normalizeCrudLookupApiPath,
@@ -13,6 +14,34 @@ import {
13
14
  resolveCrudParentFilterFieldKeyFromRouteParam
14
15
  } from "./crudLookup.js";
15
16
 
17
+ function createCrudResource({
18
+ viewFields = {},
19
+ createFields = {},
20
+ patchFields = {},
21
+ contract = {}
22
+ } = {}) {
23
+ return {
24
+ ...(Object.keys(contract).length > 0 ? { contract } : {}),
25
+ operations: {
26
+ view: {
27
+ output: {
28
+ schema: createSchema(viewFields)
29
+ }
30
+ },
31
+ create: {
32
+ body: {
33
+ schema: createSchema(createFields)
34
+ }
35
+ },
36
+ patch: {
37
+ body: {
38
+ schema: createSchema(patchFields)
39
+ }
40
+ }
41
+ }
42
+ };
43
+ }
44
+
16
45
  test("normalizeCrudLookupApiPath normalizes and rejects root", () => {
17
46
  assert.equal(normalizeCrudLookupApiPath("vets"), "/vets");
18
47
  assert.equal(normalizeCrudLookupApiPath("/vets//"), "/vets");
@@ -57,21 +86,21 @@ test("resolveCrudLookupContainerKey throws for invalid contract shape", () => {
57
86
  });
58
87
 
59
88
  test("resolveCrudLookupFieldKeys returns lookup field keys with optional allow-list", () => {
60
- const resource = {
61
- fieldMeta: [
62
- {
63
- key: "contactId",
89
+ const resource = createCrudResource({
90
+ viewFields: {
91
+ contactId: {
92
+ type: "integer",
64
93
  relation: {
65
94
  kind: "lookup",
66
95
  apiPath: "/contacts",
67
96
  valueKey: "id"
68
97
  }
69
98
  },
70
- {
71
- key: "status"
99
+ status: {
100
+ type: "string"
72
101
  },
73
- {
74
- key: "vetId",
102
+ vetId: {
103
+ type: "integer",
75
104
  parentRouteParamKey: "primaryVetId",
76
105
  relation: {
77
106
  kind: "lookup",
@@ -79,18 +108,18 @@ test("resolveCrudLookupFieldKeys returns lookup field keys with optional allow-l
79
108
  valueKey: "id"
80
109
  }
81
110
  }
82
- ]
83
- };
111
+ }
112
+ });
84
113
 
85
114
  assert.deepEqual(resolveCrudLookupFieldKeys(resource), ["contactId", "vetId"]);
86
115
  assert.deepEqual(resolveCrudLookupFieldKeys(resource, { allowKeys: ["vetId", "missing"] }), ["vetId"]);
87
116
  });
88
117
 
89
118
  test("resolveCrudLookupFieldKeyFromRouteParam matches parent route param aliases to canonical lookup field keys", () => {
90
- const resource = {
91
- fieldMeta: [
92
- {
93
- key: "staffContactId",
119
+ const resource = createCrudResource({
120
+ viewFields: {
121
+ staffContactId: {
122
+ type: "integer",
94
123
  parentRouteParamKey: "contactId",
95
124
  relation: {
96
125
  kind: "lookup",
@@ -98,16 +127,16 @@ test("resolveCrudLookupFieldKeyFromRouteParam matches parent route param aliases
98
127
  valueKey: "id"
99
128
  }
100
129
  },
101
- {
102
- key: "serviceId",
130
+ serviceId: {
131
+ type: "integer",
103
132
  relation: {
104
133
  kind: "lookup",
105
134
  apiPath: "/services",
106
135
  valueKey: "id"
107
136
  }
108
137
  }
109
- ]
110
- };
138
+ }
139
+ });
111
140
 
112
141
  assert.equal(resolveCrudLookupFieldKeyFromRouteParam(resource, "contactId"), "staffContactId");
113
142
  assert.equal(resolveCrudLookupFieldKeyFromRouteParam(resource, "staffContactId"), "staffContactId");
@@ -120,10 +149,10 @@ test("resolveCrudLookupFieldKeyFromRouteParam matches parent route param aliases
120
149
  });
121
150
 
122
151
  test("resolveCrudLookupFieldKeyFromRouteParam prefers exact field keys before alias matches", () => {
123
- const resource = {
124
- fieldMeta: [
125
- {
126
- key: "staffContactId",
152
+ const resource = createCrudResource({
153
+ viewFields: {
154
+ staffContactId: {
155
+ type: "integer",
127
156
  parentRouteParamKey: "contactId",
128
157
  relation: {
129
158
  kind: "lookup",
@@ -131,37 +160,25 @@ test("resolveCrudLookupFieldKeyFromRouteParam prefers exact field keys before al
131
160
  valueKey: "id"
132
161
  }
133
162
  },
134
- {
135
- key: "contactId",
163
+ contactId: {
164
+ type: "integer",
136
165
  relation: {
137
166
  kind: "lookup",
138
167
  apiPath: "/contacts",
139
168
  valueKey: "id"
140
169
  }
141
170
  }
142
- ]
143
- };
171
+ }
172
+ });
144
173
 
145
174
  assert.equal(resolveCrudLookupFieldKeyFromRouteParam(resource, "contactId"), "contactId");
146
175
  });
147
176
 
148
177
  test("resolveCrudParentFilterKeys keeps only lookup keys that are writable through create", () => {
149
- const resource = {
150
- operations: {
151
- create: {
152
- bodyValidator: {
153
- schema: {
154
- type: "object",
155
- properties: {
156
- serviceId: { type: "integer" }
157
- }
158
- }
159
- }
160
- }
161
- },
162
- fieldMeta: [
163
- {
164
- key: "staffContactId",
178
+ const resource = createCrudResource({
179
+ viewFields: {
180
+ staffContactId: {
181
+ type: "integer",
165
182
  parentRouteParamKey: "contactId",
166
183
  relation: {
167
184
  kind: "lookup",
@@ -169,37 +186,35 @@ test("resolveCrudParentFilterKeys keeps only lookup keys that are writable throu
169
186
  valueKey: "id"
170
187
  }
171
188
  },
172
- {
173
- key: "serviceId",
189
+ serviceId: {
190
+ type: "integer",
174
191
  relation: {
175
192
  kind: "lookup",
176
193
  apiPath: "/services",
177
194
  valueKey: "id"
178
195
  }
179
196
  }
180
- ]
181
- };
197
+ },
198
+ createFields: {
199
+ serviceId: {
200
+ type: "integer",
201
+ relation: {
202
+ kind: "lookup",
203
+ apiPath: "/services",
204
+ valueKey: "id"
205
+ }
206
+ }
207
+ }
208
+ });
182
209
 
183
210
  assert.deepEqual(resolveCrudParentFilterKeys(resource), ["serviceId"]);
184
211
  });
185
212
 
186
213
  test("resolveCrudParentFilterFieldKeyFromRouteParam uses the same allowed keys as server parent filters", () => {
187
- const resource = {
188
- operations: {
189
- create: {
190
- bodyValidator: {
191
- schema: {
192
- type: "object",
193
- properties: {
194
- serviceId: { type: "integer" }
195
- }
196
- }
197
- }
198
- }
199
- },
200
- fieldMeta: [
201
- {
202
- key: "staffContactId",
214
+ const resource = createCrudResource({
215
+ viewFields: {
216
+ staffContactId: {
217
+ type: "integer",
203
218
  parentRouteParamKey: "contactId",
204
219
  relation: {
205
220
  kind: "lookup",
@@ -207,16 +222,26 @@ test("resolveCrudParentFilterFieldKeyFromRouteParam uses the same allowed keys a
207
222
  valueKey: "id"
208
223
  }
209
224
  },
210
- {
211
- key: "serviceId",
225
+ serviceId: {
226
+ type: "integer",
212
227
  relation: {
213
228
  kind: "lookup",
214
229
  apiPath: "/services",
215
230
  valueKey: "id"
216
231
  }
217
232
  }
218
- ]
219
- };
233
+ },
234
+ createFields: {
235
+ serviceId: {
236
+ type: "integer",
237
+ relation: {
238
+ kind: "lookup",
239
+ apiPath: "/services",
240
+ valueKey: "id"
241
+ }
242
+ }
243
+ }
244
+ });
220
245
 
221
246
  assert.equal(resolveCrudParentFilterFieldKeyFromRouteParam(resource, "contactId"), "");
222
247
  assert.equal(resolveCrudParentFilterFieldKeyFromRouteParam(resource, "serviceId"), "serviceId");
@@ -198,6 +198,13 @@ function normalizeRecordId(value, { fallback = null } = {}) {
198
198
  return normalizeCanonicalRecordIdText(value, { fallback });
199
199
  }
200
200
 
201
+ if (typeof value === "number") {
202
+ if (!Number.isSafeInteger(value) || value < 1) {
203
+ return fallback;
204
+ }
205
+ return normalizeCanonicalRecordIdText(value, { fallback });
206
+ }
207
+
201
208
  if (typeof value === "bigint") {
202
209
  if (value < 1n) {
203
210
  return fallback;
@@ -174,11 +174,13 @@ test("normalizeOrNull normalizes non-nullish values and coerces nullish to null"
174
174
  );
175
175
  });
176
176
 
177
- test("normalizeRecordId accepts canonical string and bigint identifiers only", () => {
177
+ test("normalizeRecordId accepts canonical string, safe integer, and bigint identifiers", () => {
178
178
  const unsafeNumericId = Number(9007199254740993n);
179
179
  assert.equal(normalizeRecordId(" 7 "), "7");
180
+ assert.equal(normalizeRecordId(7), "7");
180
181
  assert.equal(normalizeRecordId(10n), "10");
181
- assert.equal(normalizeRecordId(7), null);
182
+ assert.equal(normalizeRecordId(7.5), null);
183
+ assert.equal(normalizeRecordId(0), null);
182
184
  assert.equal(normalizeRecordId(unsafeNumericId), null);
183
185
  assert.equal(normalizeRecordId(""), null);
184
186
  assert.equal(normalizeRecordId(null), null);
@@ -88,7 +88,7 @@ test("discoverShellOutletTargetsFromVueSource ignores disabled default markers",
88
88
  assert.equal(discovered.defaultTargetId, "");
89
89
  });
90
90
 
91
- test("discoverShellOutletTargetsFromVueSource rejects legacy split outlet attributes", () => {
91
+ test("discoverShellOutletTargetsFromVueSource rejects split outlet attributes", () => {
92
92
  const source = `
93
93
  <template>
94
94
  <ShellOutlet target="shell-layout:primary-menu" host="other-host" position="primary-menu" />
@@ -0,0 +1,53 @@
1
+ import { createSchema } from "json-rest-schema";
2
+ import { normalizeText } from "../support/normalize.js";
3
+ import { deepFreeze } from "../support/deepFreeze.js";
4
+ import { normalizeSingleSchemaDefinition } from "./schemaDefinitions.js";
5
+
6
+ function composeSchemaDefinitions(definitions, {
7
+ mode,
8
+ context = "schema definitions"
9
+ } = {}) {
10
+ if (!Array.isArray(definitions) || definitions.length < 1) {
11
+ throw new TypeError(`${context} must be a non-empty array of schema definitions.`);
12
+ }
13
+
14
+ const normalizedDefinitions = definitions.map((definition, index) =>
15
+ normalizeSingleSchemaDefinition(definition, {
16
+ context: `${context}[${index}]`
17
+ })
18
+ );
19
+
20
+ const mergedStructure = {};
21
+ for (const normalizedDefinition of normalizedDefinitions) {
22
+ for (const [fieldName, fieldDefinition] of Object.entries(normalizedDefinition.schema.getFieldDefinitions())) {
23
+ if (Object.prototype.hasOwnProperty.call(mergedStructure, fieldName)) {
24
+ throw new Error(`${context} cannot compose duplicate field "${fieldName}".`);
25
+ }
26
+
27
+ mergedStructure[fieldName] = fieldDefinition;
28
+ }
29
+ }
30
+
31
+ let resolvedMode = normalizeText(mode).toLowerCase();
32
+ if (!resolvedMode) {
33
+ const uniqueModes = Array.from(new Set(
34
+ normalizedDefinitions.map((definition) => normalizeText(definition.mode).toLowerCase())
35
+ )).filter(Boolean);
36
+ if (uniqueModes.length === 1 && uniqueModes[0] === "patch") {
37
+ resolvedMode = "patch";
38
+ } else {
39
+ throw new TypeError(`${context} requires an explicit mode unless all schema definitions use patch mode.`);
40
+ }
41
+ }
42
+
43
+ const schemaFactory = createSchema.createFactory(
44
+ normalizedDefinitions.map((definition) => definition.schema)
45
+ );
46
+
47
+ return deepFreeze({
48
+ schema: schemaFactory(mergedStructure),
49
+ mode: resolvedMode
50
+ });
51
+ }
52
+
53
+ export { composeSchemaDefinitions };