@jskit-ai/kernel 0.1.31 → 0.1.33

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.
@@ -0,0 +1,326 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ import {
7
+ deriveDefaultSubpagesHost,
8
+ normalizePagesRelativeTargetRoot,
9
+ resolvePageLinkTargetDetails,
10
+ resolvePageTargetDetails
11
+ } from "./pageTargets.js";
12
+
13
+ async function withTempApp(run) {
14
+ const appRoot = await mkdtemp(path.join(tmpdir(), "kernel-page-targets-"));
15
+ try {
16
+ return await run(appRoot);
17
+ } finally {
18
+ await rm(appRoot, { recursive: true, force: true });
19
+ }
20
+ }
21
+
22
+ async function writeFileInApp(appRoot, relativePath, source) {
23
+ const absoluteFile = path.join(appRoot, relativePath);
24
+ await mkdir(path.dirname(absoluteFile), { recursive: true });
25
+ await writeFile(absoluteFile, source, "utf8");
26
+ }
27
+
28
+ async function writeConfig(appRoot, source) {
29
+ await writeFileInApp(appRoot, "config/public.js", source);
30
+ }
31
+
32
+ async function writeShellLayout(appRoot, source = "") {
33
+ await writeFileInApp(
34
+ appRoot,
35
+ "src/components/ShellLayout.vue",
36
+ source ||
37
+ `<template>
38
+ <div>
39
+ <ShellOutlet host="shell-layout" position="top-right" />
40
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
41
+ </div>
42
+ </template>
43
+ `
44
+ );
45
+ }
46
+
47
+ test("resolvePageTargetDetails derives the surface and route data from an explicit page file", async () => {
48
+ await withTempApp(async (appRoot) => {
49
+ await writeConfig(
50
+ appRoot,
51
+ `export const config = {
52
+ surfaceDefinitions: {
53
+ admin: { id: "admin", pagesRoot: "w/[workspaceSlug]/admin", enabled: true }
54
+ }
55
+ };
56
+ `
57
+ );
58
+
59
+ const pageTarget = await resolvePageTargetDetails({
60
+ appRoot,
61
+ targetFile: "w/[workspaceSlug]/admin/catalog/index/products/index.vue",
62
+ context: "page target"
63
+ });
64
+
65
+ assert.equal(pageTarget.surfaceId, "admin");
66
+ assert.equal(pageTarget.surfacePagesRoot, "w/[workspaceSlug]/admin");
67
+ assert.equal(pageTarget.routeUrlSuffix, "/catalog/products");
68
+ assert.equal(pageTarget.placementId, "ui-generator.page.admin.catalog.products.link");
69
+ assert.deepEqual(pageTarget.visibleRouteSegments, ["catalog", "products"]);
70
+ assert.equal(deriveDefaultSubpagesHost(pageTarget), "catalog-products");
71
+ });
72
+ });
73
+
74
+ test("resolvePageTargetDetails includes surface in placement ids for identical routes on different surfaces", async () => {
75
+ await withTempApp(async (appRoot) => {
76
+ await writeConfig(
77
+ appRoot,
78
+ `export const config = {
79
+ surfaceDefinitions: {
80
+ app: { id: "app", pagesRoot: "app", enabled: true },
81
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
82
+ }
83
+ };
84
+ `
85
+ );
86
+
87
+ const appPageTarget = await resolvePageTargetDetails({
88
+ appRoot,
89
+ targetFile: "app/reports/index.vue",
90
+ context: "page target"
91
+ });
92
+ const adminPageTarget = await resolvePageTargetDetails({
93
+ appRoot,
94
+ targetFile: "admin/reports/index.vue",
95
+ context: "page target"
96
+ });
97
+
98
+ assert.equal(appPageTarget.placementId, "ui-generator.page.app.reports.link");
99
+ assert.equal(adminPageTarget.placementId, "ui-generator.page.admin.reports.link");
100
+ assert.notEqual(appPageTarget.placementId, adminPageTarget.placementId);
101
+ });
102
+ });
103
+
104
+ test("resolvePageTargetDetails chooses the most specific matching surface pagesRoot", async () => {
105
+ await withTempApp(async (appRoot) => {
106
+ await writeConfig(
107
+ appRoot,
108
+ `export const config = {
109
+ surfaceDefinitions: {
110
+ app: { id: "app", pagesRoot: "w/[workspaceSlug]", enabled: true },
111
+ admin: { id: "admin", pagesRoot: "w/[workspaceSlug]/admin", enabled: true }
112
+ }
113
+ };
114
+ `
115
+ );
116
+
117
+ const pageTarget = await resolvePageTargetDetails({
118
+ appRoot,
119
+ targetFile: "w/[workspaceSlug]/admin/catalog/index.vue",
120
+ context: "page target"
121
+ });
122
+
123
+ assert.equal(pageTarget.surfaceId, "admin");
124
+ assert.equal(pageTarget.surfacePagesRoot, "w/[workspaceSlug]/admin");
125
+ assert.equal(pageTarget.surfaceRelativeFilePath, "catalog/index.vue");
126
+ assert.equal(pageTarget.routeUrlSuffix, "/catalog");
127
+ assert.equal(pageTarget.placementId, "ui-generator.page.admin.catalog.link");
128
+ });
129
+ });
130
+
131
+ test("resolvePageTargetDetails rejects duplicate matching surface pagesRoot definitions", async () => {
132
+ await withTempApp(async (appRoot) => {
133
+ await writeConfig(
134
+ appRoot,
135
+ `export const config = {
136
+ surfaceDefinitions: {
137
+ adminA: { id: "admin-a", pagesRoot: "w/[workspaceSlug]/admin", enabled: true },
138
+ adminB: { id: "admin-b", pagesRoot: "w/[workspaceSlug]/admin", enabled: true }
139
+ }
140
+ };
141
+ `
142
+ );
143
+
144
+ await assert.rejects(
145
+ () =>
146
+ resolvePageTargetDetails({
147
+ appRoot,
148
+ targetFile: "w/[workspaceSlug]/admin/catalog/index.vue",
149
+ context: "page target"
150
+ }),
151
+ /multiple surfaces share pagesRoot "w\/\[workspaceSlug\]\/admin" \(admin-a, admin-b\)/
152
+ );
153
+ });
154
+ });
155
+
156
+ test("resolvePageTargetDetails rejects target files with a src/pages prefix", async () => {
157
+ await withTempApp(async (appRoot) => {
158
+ await writeConfig(
159
+ appRoot,
160
+ `export const config = {
161
+ surfaceDefinitions: {
162
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
163
+ }
164
+ };
165
+ `
166
+ );
167
+
168
+ await assert.rejects(
169
+ () =>
170
+ resolvePageTargetDetails({
171
+ appRoot,
172
+ targetFile: "src/pages/admin/reports/index.vue",
173
+ context: "page target"
174
+ }),
175
+ /must be relative to src\/pages\/, without the src\/pages\/ prefix/
176
+ );
177
+ });
178
+ });
179
+
180
+ test("normalizePagesRelativeTargetRoot rejects route roots with a src/pages prefix", () => {
181
+ assert.throws(
182
+ () =>
183
+ normalizePagesRelativeTargetRoot("src/pages/admin/customers", {
184
+ context: "crud-ui-generator",
185
+ label: 'option "target-root"'
186
+ }),
187
+ /must be relative to src\/pages\/, without the src\/pages\/ prefix/
188
+ );
189
+ });
190
+
191
+ test("resolvePageLinkTargetDetails falls back to the app default placement target", async () => {
192
+ await withTempApp(async (appRoot) => {
193
+ await writeConfig(
194
+ appRoot,
195
+ `export const config = {
196
+ surfaceDefinitions: {
197
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
198
+ }
199
+ };
200
+ `
201
+ );
202
+ await writeShellLayout(appRoot);
203
+
204
+ const details = await resolvePageLinkTargetDetails({
205
+ appRoot,
206
+ targetFile: "admin/reports/index.vue",
207
+ context: "page target"
208
+ });
209
+
210
+ assert.equal(details.pageTarget.surfaceId, "admin");
211
+ assert.equal(details.placementTarget.host, "shell-layout");
212
+ assert.equal(details.placementTarget.position, "primary-menu");
213
+ assert.equal(details.componentToken, "users.web.shell.surface-aware-menu-link-item");
214
+ assert.equal(details.linkTo, "");
215
+ });
216
+ });
217
+
218
+ test("resolvePageLinkTargetDetails inherits a file-route parent subpages host", async () => {
219
+ await withTempApp(async (appRoot) => {
220
+ await writeConfig(
221
+ appRoot,
222
+ `export const config = {
223
+ surfaceDefinitions: {
224
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
225
+ }
226
+ };
227
+ `
228
+ );
229
+ await writeShellLayout(appRoot);
230
+ await writeFileInApp(
231
+ appRoot,
232
+ "src/pages/admin/contacts/[contactId].vue",
233
+ `<template>
234
+ <SectionContainerShell>
235
+ <template #tabs>
236
+ <ShellOutlet host="contact-view" position="sub-pages" />
237
+ </template>
238
+ <RouterView />
239
+ </SectionContainerShell>
240
+ </template>
241
+ `
242
+ );
243
+
244
+ const details = await resolvePageLinkTargetDetails({
245
+ appRoot,
246
+ targetFile: "admin/contacts/[contactId]/notes/index.vue",
247
+ context: "page target"
248
+ });
249
+
250
+ assert.equal(details.parentHost?.id, "contact-view:sub-pages");
251
+ assert.equal(details.placementTarget.host, "contact-view");
252
+ assert.equal(details.placementTarget.position, "sub-pages");
253
+ assert.equal(details.componentToken, "local.main.ui.tab-link-item");
254
+ assert.equal(details.linkTo, "./notes");
255
+ });
256
+ });
257
+
258
+ test("resolvePageLinkTargetDetails honors explicit placement and link overrides", async () => {
259
+ await withTempApp(async (appRoot) => {
260
+ await writeConfig(
261
+ appRoot,
262
+ `export const config = {
263
+ surfaceDefinitions: {
264
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
265
+ }
266
+ };
267
+ `
268
+ );
269
+ await writeShellLayout(appRoot);
270
+
271
+ const details = await resolvePageLinkTargetDetails({
272
+ appRoot,
273
+ targetFile: "admin/contacts/[contactId]/index/notes/index.vue",
274
+ placement: "shell-layout:top-right",
275
+ componentToken: "custom.link-item",
276
+ linkTo: "./assistant-notes",
277
+ context: "page target"
278
+ });
279
+
280
+ assert.equal(details.placementTarget.host, "shell-layout");
281
+ assert.equal(details.placementTarget.position, "top-right");
282
+ assert.equal(details.componentToken, "custom.link-item");
283
+ assert.equal(details.linkTo, "./assistant-notes");
284
+ });
285
+ });
286
+
287
+ test("resolvePageLinkTargetDetails inherits an index-route parent subpages host for index children", async () => {
288
+ await withTempApp(async (appRoot) => {
289
+ await writeConfig(
290
+ appRoot,
291
+ `export const config = {
292
+ surfaceDefinitions: {
293
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
294
+ }
295
+ };
296
+ `
297
+ );
298
+ await writeShellLayout(appRoot);
299
+ await writeFileInApp(
300
+ appRoot,
301
+ "src/pages/admin/customers/[customerId]/index.vue",
302
+ `<template>
303
+ <SectionContainerShell>
304
+ <template #tabs>
305
+ <ShellOutlet host="customer-view" position="sub-pages" />
306
+ </template>
307
+ <RouterView />
308
+ </SectionContainerShell>
309
+ </template>
310
+ `
311
+ );
312
+
313
+ const details = await resolvePageLinkTargetDetails({
314
+ appRoot,
315
+ targetFile: "admin/customers/[customerId]/index/pets/index.vue",
316
+ context: "page target"
317
+ });
318
+
319
+ assert.equal(details.parentHost?.id, "customer-view:sub-pages");
320
+ assert.equal(details.parentHost?.pageFile, "src/pages/admin/customers/[customerId]/index.vue");
321
+ assert.equal(details.placementTarget.host, "customer-view");
322
+ assert.equal(details.placementTarget.position, "sub-pages");
323
+ assert.equal(details.componentToken, "local.main.ui.tab-link-item");
324
+ assert.equal(details.linkTo, "./pets");
325
+ });
326
+ });
@@ -180,6 +180,34 @@ function normalizePositiveInteger(value, { fallback = 0 } = {}) {
180
180
  return numeric;
181
181
  }
182
182
 
183
+ function normalizeCanonicalRecordIdText(value, { fallback = null } = {}) {
184
+ if (value == null) {
185
+ return fallback;
186
+ }
187
+
188
+ const normalized = String(value).trim();
189
+ return /^[1-9][0-9]*$/.test(normalized) ? normalized : fallback;
190
+ }
191
+
192
+ function normalizeRecordId(value, { fallback = null } = {}) {
193
+ if (value == null) {
194
+ return fallback;
195
+ }
196
+
197
+ if (typeof value === "string") {
198
+ return normalizeCanonicalRecordIdText(value, { fallback });
199
+ }
200
+
201
+ if (typeof value === "bigint") {
202
+ if (value < 1n) {
203
+ return fallback;
204
+ }
205
+ return normalizeCanonicalRecordIdText(value, { fallback });
206
+ }
207
+
208
+ return fallback;
209
+ }
210
+
183
211
  function normalizeOpaqueId(value, { fallback = null } = {}) {
184
212
  if (value == null) {
185
213
  return fallback;
@@ -191,7 +219,7 @@ function normalizeOpaqueId(value, { fallback = null } = {}) {
191
219
  }
192
220
 
193
221
  if (typeof value === "number") {
194
- return Number.isFinite(value) ? value : fallback;
222
+ return Number.isFinite(value) ? String(value) : fallback;
195
223
  }
196
224
 
197
225
  if (typeof value === "bigint") {
@@ -244,6 +272,8 @@ export {
244
272
  normalizeUniqueTextList,
245
273
  normalizeInteger,
246
274
  normalizePositiveInteger,
275
+ normalizeCanonicalRecordIdText,
276
+ normalizeRecordId,
247
277
  normalizeOpaqueId,
248
278
  normalizeOneOf,
249
279
  ensureNonEmptyText
@@ -3,11 +3,13 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  hasValue,
5
5
  normalizeBoolean,
6
+ normalizeCanonicalRecordIdText,
6
7
  normalizeFiniteInteger,
7
8
  normalizeFiniteNumber,
8
9
  normalizeIfInSource,
9
10
  normalizeIfPresent,
10
11
  normalizeOrNull,
12
+ normalizeRecordId,
11
13
  normalizeOpaqueId,
12
14
  normalizePositiveInteger,
13
15
  normalizeOneOf,
@@ -172,10 +174,27 @@ test("normalizeOrNull normalizes non-nullish values and coerces nullish to null"
172
174
  );
173
175
  });
174
176
 
177
+ test("normalizeRecordId accepts canonical string and bigint identifiers only", () => {
178
+ const unsafeNumericId = Number(9007199254740993n);
179
+ assert.equal(normalizeRecordId(" 7 "), "7");
180
+ assert.equal(normalizeRecordId(10n), "10");
181
+ assert.equal(normalizeRecordId(7), null);
182
+ assert.equal(normalizeRecordId(unsafeNumericId), null);
183
+ assert.equal(normalizeRecordId(""), null);
184
+ assert.equal(normalizeRecordId(null), null);
185
+ });
186
+
187
+ test("normalizeCanonicalRecordIdText validates canonical positive decimal identifiers", () => {
188
+ assert.equal(normalizeCanonicalRecordIdText(" 7 "), "7");
189
+ assert.equal(normalizeCanonicalRecordIdText("007"), null);
190
+ assert.equal(normalizeCanonicalRecordIdText("0"), null);
191
+ assert.equal(normalizeCanonicalRecordIdText("abc"), null);
192
+ });
193
+
175
194
  test("normalizeOpaqueId preserves opaque identifiers", () => {
176
195
  assert.equal(normalizeOpaqueId(" user-123 "), "user-123");
177
- assert.equal(normalizeOpaqueId(7), 7);
178
- assert.equal(normalizeOpaqueId(0), 0);
196
+ assert.equal(normalizeOpaqueId(7), "7");
197
+ assert.equal(normalizeOpaqueId(0), "0");
179
198
  assert.equal(normalizeOpaqueId(10n), "10");
180
199
  assert.equal(normalizeOpaqueId(""), null);
181
200
  assert.equal(normalizeOpaqueId(null), null);
@@ -49,7 +49,7 @@ function normalizeVisibilityContext(value = {}) {
49
49
  scopeKind: normalizedScopeKind,
50
50
  requiresActorScope: source.requiresActorScope === true,
51
51
  scopeOwnerId: normalizeOpaqueId(source.scopeOwnerId),
52
- userOwnerId: normalizeOpaqueId(source.userOwnerId)
52
+ userId: normalizeOpaqueId(source.userId)
53
53
  });
54
54
  }
55
55
 
@@ -19,20 +19,20 @@ test("normalizeRouteVisibilityToken normalizes visibility tokens for module-leve
19
19
  });
20
20
 
21
21
  test("normalizeVisibilityContext normalizes mode and owner identifiers", () => {
22
- assert.deepEqual(normalizeVisibilityContext({ visibility: "user", userOwnerId: "7" }), {
22
+ assert.deepEqual(normalizeVisibilityContext({ visibility: "user", userId: "7" }), {
23
23
  visibility: "user",
24
24
  scopeKind: null,
25
25
  requiresActorScope: false,
26
26
  scopeOwnerId: null,
27
- userOwnerId: "7"
27
+ userId: "7"
28
28
  });
29
29
 
30
- assert.deepEqual(normalizeVisibilityContext({ visibility: "workspace_user", scopeOwnerId: "4", userOwnerId: 9 }), {
30
+ assert.deepEqual(normalizeVisibilityContext({ visibility: "workspace_user", scopeOwnerId: "4", userId: 9 }), {
31
31
  visibility: "workspace_user",
32
32
  scopeKind: null,
33
33
  requiresActorScope: false,
34
34
  scopeOwnerId: "4",
35
- userOwnerId: 9
35
+ userId: "9"
36
36
  });
37
37
 
38
38
  assert.deepEqual(normalizeVisibilityContext({ visibility: "workspace", scopeOwnerId: "0" }), {
@@ -40,6 +40,6 @@ test("normalizeVisibilityContext normalizes mode and owner identifiers", () => {
40
40
  scopeKind: null,
41
41
  requiresActorScope: false,
42
42
  scopeOwnerId: "0",
43
- userOwnerId: null
43
+ userId: null
44
44
  });
45
45
  });
@@ -1,14 +1,13 @@
1
1
  import { Type } from "typebox";
2
- import { normalizeText } from "../support/normalize.js";
3
2
  import { normalizeObjectInput } from "./inputNormalization.js";
4
- import { positiveIntegerValidator } from "./recordIdParamsValidator.js";
3
+ import { positiveIntegerValidator, recordIdInputSchema, recordIdValidator } from "./recordIdParamsValidator.js";
5
4
 
6
5
  function normalizeCursorPaginationQuery(input = {}) {
7
6
  const source = normalizeObjectInput(input);
8
7
  const normalized = {};
9
8
 
10
9
  if (Object.hasOwn(source, "cursor")) {
11
- normalized.cursor = normalizeText(source.cursor);
10
+ normalized.cursor = recordIdValidator.normalize(source.cursor);
12
11
  }
13
12
 
14
13
  if (Object.hasOwn(source, "limit")) {
@@ -21,7 +20,7 @@ function normalizeCursorPaginationQuery(input = {}) {
21
20
  const cursorPaginationQueryValidator = Object.freeze({
22
21
  schema: Type.Object(
23
22
  {
24
- cursor: Type.Optional(positiveIntegerValidator.schema),
23
+ cursor: Type.Optional(recordIdInputSchema),
25
24
  limit: Type.Optional(positiveIntegerValidator.schema)
26
25
  },
27
26
  { additionalProperties: false }
@@ -11,9 +11,8 @@ test("cursorPaginationQueryValidator normalizes numeric strings as cursor text",
11
11
 
12
12
  test("cursorPaginationQueryValidator schema rejects opaque cursor strings", () => {
13
13
  assert.equal(
14
- cursorPaginationQueryValidator.schema.properties.cursor.anyOf.some(
15
- (entry) => entry.type === "string" && entry.pattern === "^[1-9][0-9]*$"
16
- ),
14
+ cursorPaginationQueryValidator.schema.properties.cursor.type === "string" &&
15
+ cursorPaginationQueryValidator.schema.properties.cursor.pattern === "^[1-9][0-9]*$",
17
16
  true
18
17
  );
19
18
  });
@@ -8,7 +8,17 @@ export {
8
8
  export { mergeObjectSchemas } from "./mergeObjectSchemas.js";
9
9
  export { mergeValidators } from "./mergeValidators.js";
10
10
  export { nestValidator } from "./nestValidator.js";
11
- export { recordIdParamsValidator, positiveIntegerValidator } from "./recordIdParamsValidator.js";
11
+ export {
12
+ RECORD_ID_PATTERN,
13
+ recordIdSchema,
14
+ recordIdInputSchema,
15
+ nullableRecordIdSchema,
16
+ nullableRecordIdInputSchema,
17
+ recordIdValidator,
18
+ nullableRecordIdValidator,
19
+ recordIdParamsValidator,
20
+ positiveIntegerValidator
21
+ } from "./recordIdParamsValidator.js";
12
22
  export { normalizeSettingsFieldInput, normalizeSettingsFieldOutput } from "./settingsFieldNormalization.js";
13
23
  export {
14
24
  normalizeRequiredFieldList,
@@ -1,23 +1,51 @@
1
1
  import { Type } from "typebox";
2
2
  import { normalizeObjectInput } from "./inputNormalization.js";
3
- import { normalizePositiveInteger, normalizeText } from "../support/normalize.js";
3
+ import { normalizePositiveInteger, normalizeRecordId } from "../support/normalize.js";
4
4
 
5
- function normalizeRecordId(value) {
6
- return normalizePositiveInteger(normalizeText(value));
7
- }
5
+ const RECORD_ID_PATTERN = "^[1-9][0-9]*$";
6
+
7
+ const recordIdSchema = Type.String({
8
+ minLength: 1,
9
+ pattern: RECORD_ID_PATTERN
10
+ });
11
+
12
+ const recordIdInputSchema = recordIdSchema;
13
+
14
+ const nullableRecordIdSchema = Type.Union([recordIdSchema, Type.Null()]);
15
+ const nullableRecordIdInputSchema = Type.Union([recordIdInputSchema, Type.Null()]);
8
16
 
9
17
  const positiveIntegerValidator = Object.freeze({
10
18
  schema: Type.Union([
11
19
  Type.Integer({ minimum: 1 }),
12
- Type.String({ minLength: 1, pattern: "^[1-9][0-9]*$" })
20
+ Type.String({ minLength: 1, pattern: RECORD_ID_PATTERN })
13
21
  ]),
14
- normalize: normalizeRecordId
22
+ normalize(value) {
23
+ return normalizePositiveInteger(value);
24
+ }
25
+ });
26
+
27
+ const recordIdValidator = Object.freeze({
28
+ schema: recordIdInputSchema,
29
+ normalize(value) {
30
+ return normalizeRecordId(value, {
31
+ fallback: ""
32
+ });
33
+ }
34
+ });
35
+
36
+ const nullableRecordIdValidator = Object.freeze({
37
+ schema: nullableRecordIdInputSchema,
38
+ normalize(value) {
39
+ return normalizeRecordId(value, {
40
+ fallback: null
41
+ });
42
+ }
15
43
  });
16
44
 
17
45
  const recordIdParamsValidator = Object.freeze({
18
46
  schema: Type.Object(
19
47
  {
20
- recordId: Type.Optional(positiveIntegerValidator.schema)
48
+ recordId: Type.Optional(recordIdInputSchema)
21
49
  },
22
50
  { additionalProperties: false }
23
51
  ),
@@ -26,11 +54,21 @@ const recordIdParamsValidator = Object.freeze({
26
54
  const normalized = {};
27
55
 
28
56
  if (Object.hasOwn(source, "recordId")) {
29
- normalized.recordId = normalizeRecordId(source.recordId);
57
+ normalized.recordId = recordIdValidator.normalize(source.recordId);
30
58
  }
31
59
 
32
60
  return normalized;
33
61
  }
34
62
  });
35
63
 
36
- export { recordIdParamsValidator, positiveIntegerValidator };
64
+ export {
65
+ RECORD_ID_PATTERN,
66
+ recordIdSchema,
67
+ recordIdInputSchema,
68
+ nullableRecordIdSchema,
69
+ nullableRecordIdInputSchema,
70
+ recordIdValidator,
71
+ nullableRecordIdValidator,
72
+ recordIdParamsValidator,
73
+ positiveIntegerValidator
74
+ };
@@ -3,15 +3,18 @@ import assert from "node:assert/strict";
3
3
 
4
4
  import { recordIdParamsValidator } from "./recordIdParamsValidator.js";
5
5
 
6
- test("recordIdParamsValidator normalizes string id to positive integer", () => {
6
+ test("recordIdParamsValidator normalizes canonical string ids", () => {
7
7
  assert.deepEqual(recordIdParamsValidator.normalize({ recordId: "42" }), {
8
- recordId: 42
8
+ recordId: "42"
9
9
  });
10
10
  });
11
11
 
12
- test("recordIdParamsValidator normalizes invalid id to 0", () => {
12
+ test("recordIdParamsValidator rejects invalid ids", () => {
13
13
  assert.deepEqual(recordIdParamsValidator.normalize({ recordId: "nope" }), {
14
- recordId: 0
14
+ recordId: ""
15
+ });
16
+ assert.deepEqual(recordIdParamsValidator.normalize({ recordId: 42 }), {
17
+ recordId: ""
15
18
  });
16
19
  });
17
20