@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.
- package/package.json +1 -1
- package/server/http/lib/kernel.test.js +5 -5
- package/server/runtime/entityChangeEvents.js +5 -5
- package/server/runtime/entityChangeEvents.test.js +5 -5
- package/server/runtime/serviceAuthorization.test.js +1 -1
- package/server/support/index.js +10 -0
- package/server/support/pageTargets.js +684 -0
- package/server/support/pageTargets.test.js +326 -0
- package/shared/support/normalize.js +31 -1
- package/shared/support/normalize.test.js +21 -2
- package/shared/support/visibility.js +1 -1
- package/shared/support/visibility.test.js +5 -5
- package/shared/validators/cursorPaginationQueryValidator.js +3 -4
- package/shared/validators/cursorPaginationQueryValidator.test.js +2 -3
- package/shared/validators/index.js +11 -1
- package/shared/validators/recordIdParamsValidator.js +47 -9
- package/shared/validators/recordIdParamsValidator.test.js +7 -4
|
@@ -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
|
-
|
|
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",
|
|
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
|
-
|
|
27
|
+
userId: "7"
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
assert.deepEqual(normalizeVisibilityContext({ visibility: "workspace_user", scopeOwnerId: "4",
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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.
|
|
15
|
-
|
|
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 {
|
|
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,
|
|
3
|
+
import { normalizePositiveInteger, normalizeRecordId } from "../support/normalize.js";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
20
|
+
Type.String({ minLength: 1, pattern: RECORD_ID_PATTERN })
|
|
13
21
|
]),
|
|
14
|
-
normalize
|
|
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(
|
|
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 =
|
|
57
|
+
normalized.recordId = recordIdValidator.normalize(source.recordId);
|
|
30
58
|
}
|
|
31
59
|
|
|
32
60
|
return normalized;
|
|
33
61
|
}
|
|
34
62
|
});
|
|
35
63
|
|
|
36
|
-
export {
|
|
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
|
|
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
|
|
12
|
+
test("recordIdParamsValidator rejects invalid ids", () => {
|
|
13
13
|
assert.deepEqual(recordIdParamsValidator.normalize({ recordId: "nope" }), {
|
|
14
|
-
recordId:
|
|
14
|
+
recordId: ""
|
|
15
|
+
});
|
|
16
|
+
assert.deepEqual(recordIdParamsValidator.normalize({ recordId: 42 }), {
|
|
17
|
+
recordId: ""
|
|
15
18
|
});
|
|
16
19
|
});
|
|
17
20
|
|