@jskit-ai/crud-core 0.1.63 → 0.1.64

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.
@@ -6,7 +6,6 @@ import {
6
6
  import { applyVisibility, applyVisibilityOwners } from "@jskit-ai/database-runtime/shared/visibility";
7
7
  import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime/errors";
8
8
  import { isRecord, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
9
- import { Check, Errors } from "typebox/value";
10
9
  import {
11
10
  DEFAULT_LIST_LIMIT,
12
11
  MAX_LIST_LIMIT,
@@ -20,11 +19,15 @@ import {
20
19
  resolveColumnName,
21
20
  resolveCrudIdColumn
22
21
  } from "../repositorySupport.js";
22
+ import {
23
+ resolveStructuredSchemaTransportSchema,
24
+ validateSchemaPayload
25
+ } from "@jskit-ai/kernel/shared/validators";
23
26
  import {
24
27
  createCrudLookupRuntime,
25
28
  hydrateCrudLookupRecords
26
29
  } from "./lookupHydration.js";
27
- import { CRUD_FIELD_REPOSITORY_STORAGE_COLUMN } from "../../shared/crudFieldMetaSupport.js";
30
+ import { CRUD_FIELD_STORAGE_COLUMN } from "@jskit-ai/kernel/shared/support/crudFieldContract";
28
31
 
29
32
  const LIST_ORDER_DIRECTION_ASC = "asc";
30
33
  const LIST_ORDER_DIRECTION_DESC = "desc";
@@ -77,11 +80,11 @@ function resolveRepositoryDefaults(resource = {}, repositoryMapping = {}, { cont
77
80
 
78
81
  const idColumn = normalizeText(resource.idColumn) || resolveColumnName("id", repositoryMapping.columnOverrides) || "id";
79
82
  const createdAtColumn = repositoryMapping.outputKeys.includes("createdAt") &&
80
- repositoryMapping.fieldStorageByKey?.createdAt === CRUD_FIELD_REPOSITORY_STORAGE_COLUMN
83
+ repositoryMapping.fieldStorageByKey?.createdAt === CRUD_FIELD_STORAGE_COLUMN
81
84
  ? resolveColumnName("createdAt", repositoryMapping.columnOverrides)
82
85
  : "";
83
86
  const updatedAtColumn = repositoryMapping.outputKeys.includes("updatedAt") &&
84
- repositoryMapping.fieldStorageByKey?.updatedAt === CRUD_FIELD_REPOSITORY_STORAGE_COLUMN
87
+ repositoryMapping.fieldStorageByKey?.updatedAt === CRUD_FIELD_STORAGE_COLUMN
85
88
  ? resolveColumnName("updatedAt", repositoryMapping.columnOverrides)
86
89
  : "";
87
90
 
@@ -112,7 +115,7 @@ function normalizeCrudVirtualFieldHandlers(
112
115
  }
113
116
  if (Object.keys(virtualFields).length > 0) {
114
117
  throw new Error(
115
- `${context} virtualFields contains registrations, but the resource does not declare any repository.storage "virtual" fields.`
118
+ `${context} virtualFields contains registrations, but the resource does not declare any storage.virtual fields.`
116
119
  );
117
120
  }
118
121
  return Object.freeze([]);
@@ -131,7 +134,7 @@ function normalizeCrudVirtualFieldHandlers(
131
134
  }
132
135
  if (!expectedKeys.has(key)) {
133
136
  throw new Error(
134
- `${context} virtualFields["${key}"] is unknown; declare the field in resource.fieldMeta with repository.storage "virtual".`
137
+ `${context} virtualFields["${key}"] is unknown; declare the field in the resource schema with storage.virtual true.`
135
138
  );
136
139
  }
137
140
  if (!handlerConfig || typeof handlerConfig !== "object" || Array.isArray(handlerConfig)) {
@@ -289,48 +292,32 @@ function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = [], { idCol
289
292
  });
290
293
  }
291
294
 
292
- function formatOutputValidationError(issue = {}) {
293
- const path = Array.isArray(issue.path) ? issue.path.join(".") : "";
294
- const value = issue.value;
295
- const message = normalizeText(issue.message) || "Invalid value";
296
- if (path) {
297
- return `${path}: ${message}`;
298
- }
299
- if (value !== undefined) {
300
- return `${message} (${JSON.stringify(value)})`;
301
- }
302
- return message;
303
- }
304
-
305
295
  function resolveRecordOutputValidator(resource = {}, { context = "crudRepository" } = {}) {
306
- const outputValidator = resource?.operations?.view?.outputValidator;
307
- if (!outputValidator || typeof outputValidator !== "object" || Array.isArray(outputValidator)) {
308
- throw new TypeError(`${context} requires operations.view.outputValidator.`);
296
+ const output = resource?.operations?.view?.output;
297
+ if (!output || typeof output !== "object" || Array.isArray(output)) {
298
+ throw new TypeError(`${context} requires operations.view.output.`);
309
299
  }
310
- if (!outputValidator.schema || typeof outputValidator.schema !== "object" || Array.isArray(outputValidator.schema)) {
311
- throw new TypeError(`${context} requires operations.view.outputValidator.schema.`);
300
+ const schema = resolveStructuredSchemaTransportSchema(output, {
301
+ context: `${context} operations.view.output`,
302
+ defaultMode: "replace"
303
+ });
304
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
305
+ throw new TypeError(`${context} requires operations.view.output to resolve to an object schema.`);
312
306
  }
313
307
 
314
- return Object.freeze({
315
- schema: outputValidator.schema,
316
- normalize: typeof outputValidator.normalize === "function" ? outputValidator.normalize : null
317
- });
308
+ return output;
318
309
  }
319
310
 
320
311
  function resolveOperationBodyValidator(resource = {}, operationKey = "", { context = "crudRepository" } = {}) {
321
- const bodyValidator = resource?.operations?.[operationKey]?.bodyValidator;
322
- if (bodyValidator == null) {
323
- return Object.freeze({
324
- normalize: null
325
- });
312
+ const body = resource?.operations?.[operationKey]?.body;
313
+ if (body == null) {
314
+ return null;
326
315
  }
327
- if (!bodyValidator || typeof bodyValidator !== "object" || Array.isArray(bodyValidator)) {
328
- throw new TypeError(`${context} operations.${operationKey}.bodyValidator must be an object when provided.`);
316
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
317
+ throw new TypeError(`${context} operations.${operationKey}.body must be an object when provided.`);
329
318
  }
330
319
 
331
- return Object.freeze({
332
- normalize: typeof bodyValidator.normalize === "function" ? bodyValidator.normalize : null
333
- });
320
+ return body;
334
321
  }
335
322
 
336
323
  function extractExplicitFieldErrors(error) {
@@ -357,34 +344,21 @@ async function normalizeRepositoryInputPayload(
357
344
  actionContextBase = {}
358
345
  } = {}
359
346
  ) {
360
- const inputValidator = operationKey === "patch"
347
+ const input = operationKey === "patch"
361
348
  ? runtime.input?.patch
362
349
  : runtime.input?.create;
363
350
  const normalizedPayload = normalizeCrudRepositoryObjectInput(payload);
364
351
 
365
- if (typeof inputValidator?.normalize !== "function") {
352
+ if (!input) {
366
353
  return normalizedPayload;
367
354
  }
368
355
 
369
356
  try {
370
- const nextPayload = await inputValidator.normalize(normalizedPayload, {
371
- phase,
372
- action,
373
- recordId,
374
- existingRecord,
375
- context: actionContextBase?.callOptions?.context,
376
- callOptions: actionContextBase?.callOptions,
377
- repositoryOptions: actionContextBase?.repositoryOptions
357
+ const nextPayload = validateSchemaPayload(input, normalizedPayload, {
358
+ phase: "input",
359
+ context: `${runtime?.context || "crudRepository"} operations.${operationKey}.body`
378
360
  });
379
- if (nextPayload === undefined) {
380
- return normalizedPayload;
381
- }
382
- if (!nextPayload || typeof nextPayload !== "object" || Array.isArray(nextPayload)) {
383
- throw new TypeError(
384
- `${runtime?.context || "crudRepository"} operations.${operationKey}.bodyValidator.normalize must return an object when it returns a value.`
385
- );
386
- }
387
- return nextPayload;
361
+ return normalizeCrudRepositoryObjectInput(nextPayload);
388
362
  } catch (error) {
389
363
  const explicitFieldErrors = extractExplicitFieldErrors(error);
390
364
  if (explicitFieldErrors) {
@@ -395,24 +369,25 @@ async function normalizeRepositoryInputPayload(
395
369
  }
396
370
 
397
371
  async function normalizeRepositoryOutputRecord(runtime = {}, record = {}, { operation = "list" } = {}) {
398
- const outputRuntime = runtime.output;
399
- let normalizedRecord = record;
400
- if (typeof outputRuntime.normalize === "function") {
401
- normalizedRecord = await outputRuntime.normalize(record, {
402
- phase: "crudRepositoryOutput",
403
- operation
372
+ try {
373
+ return validateSchemaPayload(runtime.output, record, {
374
+ phase: "output",
375
+ context: `${runtime?.context || "crudRepository"} operations.view.output`
404
376
  });
377
+ } catch (error) {
378
+ const explicitFieldErrors = extractExplicitFieldErrors(error);
379
+ if (explicitFieldErrors) {
380
+ const detailMessage = Object.entries(explicitFieldErrors)
381
+ .map(([field, message]) => `${field}: ${message}`)
382
+ .join(", ");
383
+ throw new TypeError(
384
+ `${runtime?.context || "crudRepository"} ${operation} output validation failed: ${detailMessage}.`
385
+ );
386
+ }
387
+ throw new TypeError(
388
+ `${runtime?.context || "crudRepository"} ${operation} output validation failed: ${String(error?.message || "Invalid value.")}.`
389
+ );
405
390
  }
406
-
407
- if (Check(outputRuntime.schema, normalizedRecord)) {
408
- return normalizedRecord;
409
- }
410
-
411
- const issues = [...Errors(outputRuntime.schema, normalizedRecord)];
412
- const formattedIssue = formatOutputValidationError(issues[0]);
413
- throw new TypeError(
414
- `${runtime?.context || "crudRepository"} ${operation} output validation failed: ${formattedIssue}.`
415
- );
416
391
  }
417
392
 
418
393
  function encodeOrderedListCursorValue(value = null) {
@@ -1070,7 +1045,7 @@ async function listRecordsByIds(runtime, knex, ids = [], callOptions = {}) {
1070
1045
  `${runtime.context || "crudRepository"} listByIds requires valueKey "${lookupValueKey}" to exist in output schema.`
1071
1046
  );
1072
1047
  }
1073
- if (runtime.mapping.fieldStorageByKey?.[lookupValueKey] !== CRUD_FIELD_REPOSITORY_STORAGE_COLUMN) {
1048
+ if (runtime.mapping.fieldStorageByKey?.[lookupValueKey] !== CRUD_FIELD_STORAGE_COLUMN) {
1074
1049
  throw new TypeError(
1075
1050
  `${runtime.context || "crudRepository"} listByIds requires valueKey "${lookupValueKey}" to be column-backed.`
1076
1051
  );
@@ -6,6 +6,7 @@ import {
6
6
  normalizeCrudLookupContainerKey,
7
7
  resolveCrudLookupContainerKey
8
8
  } from "@jskit-ai/kernel/shared/support/crudLookup";
9
+ import { buildCrudFieldContractMap } from "@jskit-ai/kernel/shared/support/crudFieldContract";
9
10
  import { normalizeCrudLookupApiPath } from "../lookupPathSupport.js";
10
11
 
11
12
  const DEFAULT_LOOKUP_INCLUDE = "*";
@@ -136,7 +137,9 @@ function createCrudLookupRuntime(resource = {}, { outputKeys = [] } = {}) {
136
137
  .filter(Boolean)
137
138
  );
138
139
 
139
- const sourceEntries = Array.isArray(resource?.fieldMeta) ? resource.fieldMeta : [];
140
+ const sourceEntries = Object.values(buildCrudFieldContractMap(resource, {
141
+ context: "crud lookup runtime field contract"
142
+ }));
140
143
  const lookupEntries = [];
141
144
  const seenKeys = new Set();
142
145
 
@@ -0,0 +1,74 @@
1
+ import { createJsonApiResourceRouteContract } from "@jskit-ai/http-runtime/shared/validators/jsonApiRouteTransport";
2
+ import {
3
+ composeSchemaDefinitions,
4
+ recordIdParamsValidator
5
+ } from "@jskit-ai/kernel/shared/validators";
6
+ import {
7
+ createCrudCursorPaginationQueryValidator,
8
+ listSearchQueryValidator as defaultListSearchQueryValidator,
9
+ lookupIncludeQueryValidator as defaultLookupIncludeQueryValidator,
10
+ createCrudParentFilterQueryValidator
11
+ } from "./listQueryValidators.js";
12
+
13
+ function createCrudJsonApiRouteContracts({
14
+ resource = {},
15
+ routeParamsValidator = null,
16
+ listSearchQueryValidator = defaultListSearchQueryValidator,
17
+ lookupIncludeQueryValidator = defaultLookupIncludeQueryValidator
18
+ } = {}) {
19
+ const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator({
20
+ orderBy: resource?.defaultSort
21
+ });
22
+ const listParentFilterQueryValidator = createCrudParentFilterQueryValidator(resource);
23
+ const listRouteQueryValidator = composeSchemaDefinitions([
24
+ listCursorPaginationQueryValidator,
25
+ listSearchQueryValidator,
26
+ listParentFilterQueryValidator,
27
+ lookupIncludeQueryValidator
28
+ ]);
29
+ const recordRouteParamsValidator = routeParamsValidator
30
+ ? composeSchemaDefinitions([
31
+ routeParamsValidator,
32
+ recordIdParamsValidator
33
+ ])
34
+ : recordIdParamsValidator;
35
+ const routeType = resource?.namespace;
36
+
37
+ return Object.freeze({
38
+ listRouteContract: createJsonApiResourceRouteContract({
39
+ type: routeType,
40
+ query: listRouteQueryValidator,
41
+ output: resource?.operations?.view?.output,
42
+ outputKind: "collection"
43
+ }),
44
+ viewRouteContract: createJsonApiResourceRouteContract({
45
+ type: routeType,
46
+ query: lookupIncludeQueryValidator,
47
+ output: resource?.operations?.view?.output,
48
+ outputKind: "record"
49
+ }),
50
+ createRouteContract: createJsonApiResourceRouteContract({
51
+ type: routeType,
52
+ body: resource?.operations?.create?.body,
53
+ output: resource?.operations?.create?.output,
54
+ outputKind: "record",
55
+ successStatus: 201,
56
+ includeValidation400: true
57
+ }),
58
+ updateRouteContract: createJsonApiResourceRouteContract({
59
+ type: routeType,
60
+ body: resource?.operations?.patch?.body,
61
+ output: resource?.operations?.patch?.output,
62
+ outputKind: "record",
63
+ includeValidation400: true
64
+ }),
65
+ deleteRouteContract: createJsonApiResourceRouteContract({
66
+ type: routeType,
67
+ outputKind: "no-content",
68
+ successStatus: 204
69
+ }),
70
+ recordRouteParamsValidator
71
+ });
72
+ }
73
+
74
+ export { createCrudJsonApiRouteContracts };
@@ -3,6 +3,22 @@ import {
3
3
  resolveCrudRecordChangedEvent
4
4
  } from "../shared/crudNamespaceSupport.js";
5
5
 
6
+ function normalizeCrudEventEntityId(value = null) {
7
+ return value == null ? "" : String(value).trim();
8
+ }
9
+
10
+ function resolveCrudEntityIdFromResult({ result } = {}) {
11
+ return normalizeCrudEventEntityId(result?.id);
12
+ }
13
+
14
+ function resolveCrudJsonApiEntityIdFromResult({ result } = {}) {
15
+ return normalizeCrudEventEntityId(result?.data?.id);
16
+ }
17
+
18
+ function resolveCrudEntityIdFromArgs({ args = [] } = {}) {
19
+ return normalizeCrudEventEntityId(args[0]);
20
+ }
21
+
6
22
  function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}) {
7
23
  const namespace = requireCrudNamespace(resource?.namespace, { context: `${context} resource.namespace` });
8
24
  const recordChangedEventName = resolveCrudRecordChangedEvent(namespace);
@@ -14,7 +30,7 @@ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}
14
30
  source: "crud",
15
31
  entity: "record",
16
32
  operation: "created",
17
- entityId: ({ result }) => result?.id,
33
+ entityId: resolveCrudEntityIdFromResult,
18
34
  realtime: Object.freeze({
19
35
  event: recordChangedEventName,
20
36
  audience: "event_scope"
@@ -27,7 +43,7 @@ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}
27
43
  source: "crud",
28
44
  entity: "record",
29
45
  operation: "updated",
30
- entityId: ({ result }) => result?.id,
46
+ entityId: resolveCrudEntityIdFromResult,
31
47
  realtime: Object.freeze({
32
48
  event: recordChangedEventName,
33
49
  audience: "event_scope"
@@ -40,7 +56,56 @@ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}
40
56
  source: "crud",
41
57
  entity: "record",
42
58
  operation: "deleted",
43
- entityId: ({ result }) => result?.id,
59
+ entityId: resolveCrudEntityIdFromResult,
60
+ realtime: Object.freeze({
61
+ event: recordChangedEventName,
62
+ audience: "event_scope"
63
+ })
64
+ })
65
+ ])
66
+ });
67
+ }
68
+
69
+ function createCrudJsonApiServiceEvents(namespace = "", { context = "createCrudJsonApiServiceEvents" } = {}) {
70
+ const normalizedNamespace = requireCrudNamespace(namespace, {
71
+ context: `${context} namespace`
72
+ });
73
+ const recordChangedEventName = resolveCrudRecordChangedEvent(normalizedNamespace);
74
+
75
+ return Object.freeze({
76
+ createDocument: Object.freeze([
77
+ Object.freeze({
78
+ type: "entity.changed",
79
+ source: "crud",
80
+ entity: "record",
81
+ operation: "created",
82
+ entityId: resolveCrudJsonApiEntityIdFromResult,
83
+ realtime: Object.freeze({
84
+ event: recordChangedEventName,
85
+ audience: "event_scope"
86
+ })
87
+ })
88
+ ]),
89
+ patchDocumentById: Object.freeze([
90
+ Object.freeze({
91
+ type: "entity.changed",
92
+ source: "crud",
93
+ entity: "record",
94
+ operation: "updated",
95
+ entityId: resolveCrudEntityIdFromArgs,
96
+ realtime: Object.freeze({
97
+ event: recordChangedEventName,
98
+ audience: "event_scope"
99
+ })
100
+ })
101
+ ]),
102
+ deleteDocumentById: Object.freeze([
103
+ Object.freeze({
104
+ type: "entity.changed",
105
+ source: "crud",
106
+ entity: "record",
107
+ operation: "deleted",
108
+ entityId: resolveCrudEntityIdFromArgs,
44
109
  realtime: Object.freeze({
45
110
  event: recordChangedEventName,
46
111
  audience: "event_scope"
@@ -50,4 +115,10 @@ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}
50
115
  });
51
116
  }
52
117
 
53
- export { createCrudServiceEvents };
118
+ export {
119
+ createCrudServiceEvents,
120
+ createCrudJsonApiServiceEvents,
121
+ resolveCrudEntityIdFromArgs,
122
+ resolveCrudEntityIdFromResult,
123
+ resolveCrudJsonApiEntityIdFromResult
124
+ };
@@ -0,0 +1,54 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import {
3
+ DEFAULT_CRUD_LOOKUP_CONTAINER_KEY,
4
+ normalizeCrudLookupContainerKey
5
+ } from "@jskit-ai/kernel/shared/support/crudLookup";
6
+
7
+ const CRUD_RUNTIME_LOOKUPS_FIELD_KEY = DEFAULT_CRUD_LOOKUP_CONTAINER_KEY;
8
+ const CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE = "autocomplete";
9
+ const CRUD_LOOKUP_FORM_CONTROL_SELECT = "select";
10
+
11
+ function checkCrudLookupFormControl(
12
+ value,
13
+ {
14
+ context = "crud field ui.formControl",
15
+ defaultValue = CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE
16
+ } = {}
17
+ ) {
18
+ const resolvedValue = value === undefined || value === null || value === "" ? defaultValue : value;
19
+ if (resolvedValue === "") {
20
+ return "";
21
+ }
22
+
23
+ if (
24
+ resolvedValue === CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE ||
25
+ resolvedValue === CRUD_LOOKUP_FORM_CONTROL_SELECT
26
+ ) {
27
+ return resolvedValue;
28
+ }
29
+
30
+ throw new Error(
31
+ `${context} must be "${CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE}" or "${CRUD_LOOKUP_FORM_CONTROL_SELECT}". ` +
32
+ `Received: ${JSON.stringify(resolvedValue)}.`
33
+ );
34
+ }
35
+
36
+ function isCrudRuntimeOutputOnlyFieldKey(
37
+ value = "",
38
+ {
39
+ lookupContainerKey = CRUD_RUNTIME_LOOKUPS_FIELD_KEY
40
+ } = {}
41
+ ) {
42
+ const resolvedLookupContainerKey = normalizeCrudLookupContainerKey(lookupContainerKey, {
43
+ context: "crud runtime lookup container key"
44
+ });
45
+ return normalizeText(value) === resolvedLookupContainerKey;
46
+ }
47
+
48
+ export {
49
+ CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE,
50
+ CRUD_LOOKUP_FORM_CONTROL_SELECT,
51
+ CRUD_RUNTIME_LOOKUPS_FIELD_KEY,
52
+ checkCrudLookupFormControl,
53
+ isCrudRuntimeOutputOnlyFieldKey
54
+ };
@@ -1,31 +1,5 @@
1
- import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
-
3
- function normalizeCrudNamespace(value = "") {
4
- return normalizeText(value)
5
- .toLowerCase()
6
- .replace(/[^a-z0-9-]+/g, "-")
7
- .replace(/-+/g, "-")
8
- .replace(/^-+|-+$/g, "");
9
- }
10
-
11
- function requireCrudNamespace(namespace, { context = "requireCrudNamespace" } = {}) {
12
- const normalizedNamespace = normalizeCrudNamespace(namespace);
13
- if (!normalizedNamespace) {
14
- throw new TypeError(`${context} requires a non-empty namespace.`);
15
- }
16
-
17
- return normalizedNamespace;
18
- }
19
-
20
- function resolveCrudRecordChangedEvent(namespace = "") {
21
- const normalizedNamespace = requireCrudNamespace(namespace, {
22
- context: "resolveCrudRecordChangedEvent"
23
- });
24
- return `${normalizedNamespace.replace(/-/g, "_")}.record.changed`;
25
- }
26
-
27
1
  export {
28
2
  normalizeCrudNamespace,
29
3
  requireCrudNamespace,
30
4
  resolveCrudRecordChangedEvent
31
- };
5
+ } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
@@ -0,0 +1 @@
1
+ export { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
@@ -1,30 +1,35 @@
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 { createCrudServiceFromResource } from "../src/server/createCrudServiceFromResource.js";
4
5
 
6
+ function createOperationSchemaDefinition(structure = {}, mode = "replace") {
7
+ return {
8
+ schema: createSchema(structure),
9
+ mode
10
+ };
11
+ }
12
+
5
13
  function createResourceWithOutputSchema(overrides = {}) {
6
14
  return {
7
15
  namespace: "contacts",
8
16
  operations: {
9
17
  view: {
10
- outputValidator: {
11
- schema: {
12
- type: "object",
13
- properties: {
14
- id: { type: "integer" },
15
- name: { type: "string" },
16
- optionalSecret: { type: "string" },
17
- nullableSecret: {
18
- anyOf: [{ type: "string" }, { type: "null" }]
19
- },
20
- defaultedSecret: {
21
- type: "string",
22
- default: ""
23
- }
24
- },
25
- required: ["id", "name", "nullableSecret", "defaultedSecret"]
18
+ output: createOperationSchemaDefinition({
19
+ id: { type: "integer", required: true },
20
+ name: { type: "string", required: true },
21
+ optionalSecret: { type: "string", required: false },
22
+ nullableSecret: {
23
+ type: "string",
24
+ required: true,
25
+ nullable: true
26
+ },
27
+ defaultedSecret: {
28
+ type: "string",
29
+ required: true,
30
+ default: ""
26
31
  }
27
- }
32
+ })
28
33
  }
29
34
  },
30
35
  ...overrides
@@ -225,7 +230,7 @@ test("createCrudServiceFromResource readable field hooks require view output sch
225
230
 
226
231
  await assert.rejects(
227
232
  () => service.getRecord(1, {}),
228
- /requires resource\.operations\.view\.outputValidator\.schema for fieldAccess\.readable/
233
+ /requires resource\.operations\.view\.output for fieldAccess\.readable/
229
234
  );
230
235
  });
231
236
 
@@ -307,15 +312,12 @@ test("createCrudServiceFromResource readable filtering fails fast for required n
307
312
  namespace: "contacts",
308
313
  operations: {
309
314
  view: {
310
- outputValidator: {
311
- schema: {
312
- type: "object",
313
- properties: {
314
- id: { type: "integer" },
315
- strictSecret: { type: "string" }
316
- },
317
- required: ["id", "strictSecret"]
318
- }
315
+ output: {
316
+ schema: createSchema({
317
+ id: { type: "integer", required: true },
318
+ strictSecret: { type: "string", required: true }
319
+ }),
320
+ mode: "replace"
319
321
  }
320
322
  }
321
323
  }
@@ -334,6 +336,6 @@ test("createCrudServiceFromResource readable filtering fails fast for required n
334
336
 
335
337
  await assert.rejects(
336
338
  () => service.getRecord(1, {}),
337
- /cannot redact required non-nullable field "strictSecret" without schema\.default/
339
+ /cannot redact required non-nullable field "strictSecret" without a default value/
338
340
  );
339
341
  });
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  checkCrudLookupFormControl,
5
5
  isCrudRuntimeOutputOnlyFieldKey
6
- } from "../src/shared/crudFieldMetaSupport.js";
6
+ } from "../src/shared/crudFieldSupport.js";
7
7
 
8
8
  test("checkCrudLookupFormControl defaults to autocomplete", () => {
9
9
  assert.equal(checkCrudLookupFormControl(undefined), "autocomplete");
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { resolveCrudSurfacePolicyFromAppConfig } from "../src/server/crudModuleConfig.js";
4
+
5
+ test("resolveCrudSurfacePolicyFromAppConfig explains missing workspace surfaces for workspace-capable tenancy", () => {
6
+ assert.throws(
7
+ () =>
8
+ resolveCrudSurfacePolicyFromAppConfig(
9
+ {
10
+ namespace: "users",
11
+ surface: "admin",
12
+ ownershipFilter: "public",
13
+ relativePath: "/users"
14
+ },
15
+ {
16
+ tenancyMode: "personal",
17
+ surfaceDefaultId: "home",
18
+ surfaceDefinitions: {
19
+ home: {
20
+ id: "home",
21
+ enabled: true,
22
+ requiresAuth: false,
23
+ requiresWorkspace: false
24
+ }
25
+ }
26
+ },
27
+ {
28
+ context: "UsersProvider"
29
+ }
30
+ ),
31
+ /UsersProvider cannot resolve surface "admin".*@jskit-ai\/workspaces-core.*"app" and "admin" surfaces/s
32
+ );
33
+ });