@jskit-ai/crud-core 0.1.63 → 0.1.65
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.descriptor.mjs +4 -2
- package/package.json +18 -10
- package/src/server/crudModuleConfig.js +25 -5
- package/src/server/fieldAccess.js +9 -39
- package/src/server/listFilters.js +384 -389
- package/src/server/listQueryValidators.js +39 -77
- package/src/server/lookupHydration.js +4 -1
- package/src/server/repositorySupport.js +71 -121
- package/src/server/resourceRuntime/index.js +49 -74
- package/src/server/resourceRuntime/lookupHydration.js +4 -1
- package/src/server/routeContracts.js +74 -0
- package/src/server/serviceEvents.js +75 -4
- package/src/shared/crudFieldSupport.js +54 -0
- package/src/shared/crudNamespaceSupport.js +1 -27
- package/src/shared/crudResource.js +1 -0
- package/test/createCrudServiceFromResource.test.js +30 -28
- package/test/{crudFieldMetaSupport.test.js → crudFieldSupport.test.js} +1 -1
- package/test/crudModuleConfig.test.js +33 -0
- package/test/crudResource.test.js +97 -0
- package/test/listFilters.test.js +221 -59
- package/test/listQueryValidators.test.js +131 -97
- package/test/repositorySupport.test.js +241 -241
- package/test/resourceRuntime.test.js +204 -248
- package/test/routeContracts.test.js +146 -0
- package/test/serviceEvents.test.js +41 -1
- package/test/serviceMethods.test.js +12 -10
- package/src/shared/crudFieldMetaSupport.js +0 -153
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createSchema } from "json-rest-schema";
|
|
4
|
+
import {
|
|
5
|
+
validateSchemaPayload
|
|
6
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
7
|
+
import { createCrudJsonApiRouteContracts } from "../src/server/routeContracts.js";
|
|
8
|
+
|
|
9
|
+
function createSchemaDefinition(structure = {}, mode = "patch") {
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
schema: createSchema(structure),
|
|
12
|
+
mode
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createCrudResource() {
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
namespace: "contacts",
|
|
19
|
+
defaultSort: Object.freeze(["-createdAt"]),
|
|
20
|
+
operations: Object.freeze({
|
|
21
|
+
view: Object.freeze({
|
|
22
|
+
output: createSchemaDefinition({
|
|
23
|
+
id: {
|
|
24
|
+
type: "string",
|
|
25
|
+
required: true
|
|
26
|
+
},
|
|
27
|
+
contactId: {
|
|
28
|
+
type: "string",
|
|
29
|
+
required: false,
|
|
30
|
+
relation: {
|
|
31
|
+
kind: "lookup",
|
|
32
|
+
apiPath: "/contacts",
|
|
33
|
+
valueKey: "id"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
name: {
|
|
37
|
+
type: "string",
|
|
38
|
+
required: true
|
|
39
|
+
}
|
|
40
|
+
}, "replace")
|
|
41
|
+
}),
|
|
42
|
+
create: Object.freeze({
|
|
43
|
+
body: createSchemaDefinition({
|
|
44
|
+
contactId: {
|
|
45
|
+
type: "string",
|
|
46
|
+
required: false,
|
|
47
|
+
relation: {
|
|
48
|
+
kind: "lookup",
|
|
49
|
+
apiPath: "/contacts",
|
|
50
|
+
valueKey: "id"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
name: {
|
|
54
|
+
type: "string",
|
|
55
|
+
required: true
|
|
56
|
+
}
|
|
57
|
+
}, "create"),
|
|
58
|
+
output: createSchemaDefinition({
|
|
59
|
+
id: {
|
|
60
|
+
type: "string",
|
|
61
|
+
required: true
|
|
62
|
+
},
|
|
63
|
+
name: {
|
|
64
|
+
type: "string",
|
|
65
|
+
required: true
|
|
66
|
+
}
|
|
67
|
+
}, "replace")
|
|
68
|
+
}),
|
|
69
|
+
patch: Object.freeze({
|
|
70
|
+
body: createSchemaDefinition({
|
|
71
|
+
name: {
|
|
72
|
+
type: "string",
|
|
73
|
+
required: false
|
|
74
|
+
}
|
|
75
|
+
}, "patch"),
|
|
76
|
+
output: createSchemaDefinition({
|
|
77
|
+
id: {
|
|
78
|
+
type: "string",
|
|
79
|
+
required: true
|
|
80
|
+
},
|
|
81
|
+
name: {
|
|
82
|
+
type: "string",
|
|
83
|
+
required: true
|
|
84
|
+
}
|
|
85
|
+
}, "replace")
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test("createCrudJsonApiRouteContracts builds default CRUD JSON:API contracts", async () => {
|
|
92
|
+
const resource = createCrudResource();
|
|
93
|
+
const routeParamsValidator = createSchemaDefinition({
|
|
94
|
+
workspaceSlug: {
|
|
95
|
+
type: "string",
|
|
96
|
+
required: true
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const contracts = createCrudJsonApiRouteContracts({
|
|
101
|
+
resource,
|
|
102
|
+
routeParamsValidator
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.equal(contracts.listRouteContract.transport.contentType, "application/vnd.api+json");
|
|
106
|
+
assert.equal(contracts.viewRouteContract.transport.contentType, "application/vnd.api+json");
|
|
107
|
+
assert.equal(contracts.createRouteContract.transport.contentType, "application/vnd.api+json");
|
|
108
|
+
assert.equal(contracts.updateRouteContract.transport.contentType, "application/vnd.api+json");
|
|
109
|
+
assert.equal(contracts.listRouteContract.responses[200].transportSchema.type, "object");
|
|
110
|
+
assert.equal(contracts.createRouteContract.responses[201].transportSchema.type, "object");
|
|
111
|
+
assert.equal(contracts.updateRouteContract.responses[200].transportSchema.type, "object");
|
|
112
|
+
assert.equal(Object.hasOwn(contracts.deleteRouteContract.responses, "204"), false);
|
|
113
|
+
assert.equal(contracts.createRouteContract.body, resource.operations.create.body);
|
|
114
|
+
assert.equal(contracts.updateRouteContract.body, resource.operations.patch.body);
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
Object.keys(contracts.recordRouteParamsValidator.schema.getFieldDefinitions()).sort(),
|
|
117
|
+
["recordId", "workspaceSlug"]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const normalizedListQuery = await validateSchemaPayload(contracts.listRouteContract.query, {
|
|
121
|
+
q: " hello ",
|
|
122
|
+
include: " ownerId ",
|
|
123
|
+
contactId: " 42 ",
|
|
124
|
+
cursor: " offset:2 ",
|
|
125
|
+
limit: "25"
|
|
126
|
+
}, { phase: "input" });
|
|
127
|
+
|
|
128
|
+
assert.deepEqual(normalizedListQuery, {
|
|
129
|
+
q: "hello",
|
|
130
|
+
include: "ownerId",
|
|
131
|
+
contactId: "42",
|
|
132
|
+
cursor: "offset:2",
|
|
133
|
+
limit: 25
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("createCrudJsonApiRouteContracts falls back to recordId params when no route params validator is provided", () => {
|
|
138
|
+
const contracts = createCrudJsonApiRouteContracts({
|
|
139
|
+
resource: createCrudResource()
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
assert.deepEqual(
|
|
143
|
+
Object.keys(contracts.recordRouteParamsValidator.schema.getFieldDefinitions()),
|
|
144
|
+
["recordId"]
|
|
145
|
+
);
|
|
146
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createCrudJsonApiServiceEvents,
|
|
5
|
+
createCrudServiceEvents,
|
|
6
|
+
resolveCrudEntityIdFromArgs,
|
|
7
|
+
resolveCrudEntityIdFromResult,
|
|
8
|
+
resolveCrudJsonApiEntityIdFromResult
|
|
9
|
+
} from "../src/server/serviceEvents.js";
|
|
4
10
|
|
|
5
11
|
test("createCrudServiceEvents builds CRUD realtime events from resource namespace", () => {
|
|
6
12
|
const events = createCrudServiceEvents({
|
|
@@ -26,3 +32,37 @@ test("createCrudServiceEvents validates required resource namespace", () => {
|
|
|
26
32
|
/resource\.namespace/
|
|
27
33
|
);
|
|
28
34
|
});
|
|
35
|
+
|
|
36
|
+
test("createCrudJsonApiServiceEvents builds JSON:API-aware create/update/delete event defaults", () => {
|
|
37
|
+
const events = createCrudJsonApiServiceEvents("contacts");
|
|
38
|
+
|
|
39
|
+
assert.equal(events.createDocument[0].realtime.event, "contacts.record.changed");
|
|
40
|
+
assert.equal(events.patchDocumentById[0].realtime.event, "contacts.record.changed");
|
|
41
|
+
assert.equal(events.deleteDocumentById[0].realtime.event, "contacts.record.changed");
|
|
42
|
+
assert.equal(events.createDocument[0].entityId({
|
|
43
|
+
result: {
|
|
44
|
+
data: {
|
|
45
|
+
id: "21"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}), "21");
|
|
49
|
+
assert.equal(events.patchDocumentById[0].entityId({
|
|
50
|
+
args: [22]
|
|
51
|
+
}), "22");
|
|
52
|
+
assert.equal(events.deleteDocumentById[0].entityId({
|
|
53
|
+
args: [23]
|
|
54
|
+
}), "23");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("service event entity-id helpers normalize ids from args, plain results, and JSON:API results", () => {
|
|
58
|
+
assert.equal(resolveCrudEntityIdFromArgs({ args: [12] }), "12");
|
|
59
|
+
assert.equal(resolveCrudEntityIdFromResult({ result: { id: 13 } }), "13");
|
|
60
|
+
assert.equal(resolveCrudJsonApiEntityIdFromResult({
|
|
61
|
+
result: {
|
|
62
|
+
data: {
|
|
63
|
+
id: 14
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}), "14");
|
|
67
|
+
assert.equal(resolveCrudEntityIdFromArgs({ args: [" "] }), "");
|
|
68
|
+
});
|
|
@@ -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
|
createCrudServiceRuntime,
|
|
5
6
|
crudServiceListRecords,
|
|
@@ -9,21 +10,22 @@ import {
|
|
|
9
10
|
crudServiceDeleteRecord
|
|
10
11
|
} from "../src/server/serviceMethods.js";
|
|
11
12
|
|
|
13
|
+
function createOperationSchemaDefinition(structure = {}, mode = "replace") {
|
|
14
|
+
return {
|
|
15
|
+
schema: createSchema(structure),
|
|
16
|
+
mode
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
function createResourceWithOutputSchema(overrides = {}) {
|
|
13
21
|
return {
|
|
14
22
|
namespace: "contacts",
|
|
15
23
|
operations: {
|
|
16
24
|
view: {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
id: { type: "integer" },
|
|
22
|
-
name: { type: "string" }
|
|
23
|
-
},
|
|
24
|
-
required: ["id", "name"]
|
|
25
|
-
}
|
|
26
|
-
}
|
|
25
|
+
output: createOperationSchemaDefinition({
|
|
26
|
+
id: { type: "integer", required: true },
|
|
27
|
+
name: { type: "string", required: true }
|
|
28
|
+
})
|
|
27
29
|
}
|
|
28
30
|
},
|
|
29
31
|
...overrides
|
|
@@ -1,153 +0,0 @@
|
|
|
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
|
-
const CRUD_FIELD_REPOSITORY_STORAGE_COLUMN = "column";
|
|
11
|
-
const CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL = "virtual";
|
|
12
|
-
const CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC = "datetime-utc";
|
|
13
|
-
|
|
14
|
-
function normalizeCrudFieldRepositoryWriteSerializer(
|
|
15
|
-
value,
|
|
16
|
-
{
|
|
17
|
-
context = "crud fieldMeta repository",
|
|
18
|
-
fieldKey = ""
|
|
19
|
-
} = {}
|
|
20
|
-
) {
|
|
21
|
-
const normalizedFieldKey = normalizeText(fieldKey);
|
|
22
|
-
const normalizedValue = normalizeText(value).toLowerCase();
|
|
23
|
-
if (!normalizedValue) {
|
|
24
|
-
return "";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (normalizedValue === CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC) {
|
|
28
|
-
return normalizedValue;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
throw new Error(
|
|
32
|
-
`${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} repository.writeSerializer must be ` +
|
|
33
|
-
`"${CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC}" when provided.`
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function checkCrudLookupFormControl(
|
|
38
|
-
value,
|
|
39
|
-
{
|
|
40
|
-
context = "crud fieldMeta ui.formControl",
|
|
41
|
-
defaultValue = CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE
|
|
42
|
-
} = {}
|
|
43
|
-
) {
|
|
44
|
-
const resolvedValue = value === undefined || value === null || value === "" ? defaultValue : value;
|
|
45
|
-
if (resolvedValue === "") {
|
|
46
|
-
return "";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
resolvedValue === CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE ||
|
|
51
|
-
resolvedValue === CRUD_LOOKUP_FORM_CONTROL_SELECT
|
|
52
|
-
) {
|
|
53
|
-
return resolvedValue;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
throw new Error(
|
|
57
|
-
`${context} must be "${CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE}" or "${CRUD_LOOKUP_FORM_CONTROL_SELECT}". ` +
|
|
58
|
-
`Received: ${JSON.stringify(resolvedValue)}.`
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isCrudRuntimeOutputOnlyFieldKey(
|
|
63
|
-
value = "",
|
|
64
|
-
{
|
|
65
|
-
lookupContainerKey = CRUD_RUNTIME_LOOKUPS_FIELD_KEY
|
|
66
|
-
} = {}
|
|
67
|
-
) {
|
|
68
|
-
const resolvedLookupContainerKey = normalizeCrudLookupContainerKey(lookupContainerKey, {
|
|
69
|
-
context: "crud runtime lookup container key"
|
|
70
|
-
});
|
|
71
|
-
return normalizeText(value) === resolvedLookupContainerKey;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function normalizeCrudFieldRepositoryConfig(
|
|
75
|
-
fieldMetaEntry = {},
|
|
76
|
-
{
|
|
77
|
-
context = "crud fieldMeta repository",
|
|
78
|
-
fieldKey = ""
|
|
79
|
-
} = {}
|
|
80
|
-
) {
|
|
81
|
-
const normalizedFieldKey = normalizeText(fieldKey || fieldMetaEntry?.key);
|
|
82
|
-
const repository = fieldMetaEntry?.repository;
|
|
83
|
-
if (repository === undefined || repository === null) {
|
|
84
|
-
return Object.freeze({
|
|
85
|
-
storage: CRUD_FIELD_REPOSITORY_STORAGE_COLUMN,
|
|
86
|
-
column: ""
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
if (!repository || typeof repository !== "object" || Array.isArray(repository)) {
|
|
90
|
-
throw new TypeError(
|
|
91
|
-
`${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} must be an object when provided.`
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const repositoryKeys = Object.keys(repository);
|
|
96
|
-
for (const repositoryKey of repositoryKeys) {
|
|
97
|
-
if (repositoryKey !== "column" && repositoryKey !== "storage" && repositoryKey !== "writeSerializer") {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} does not support repository.${repositoryKey}.`
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const column = normalizeText(repository.column);
|
|
105
|
-
const storage = normalizeText(repository.storage).toLowerCase();
|
|
106
|
-
const writeSerializer = normalizeCrudFieldRepositoryWriteSerializer(repository.writeSerializer, {
|
|
107
|
-
context,
|
|
108
|
-
fieldKey: normalizedFieldKey
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
if (!column && !storage && !writeSerializer) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} requires repository.column, repository.storage, or repository.writeSerializer.`
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (storage && storage !== CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL) {
|
|
118
|
-
throw new Error(
|
|
119
|
-
`${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} repository.storage must be "virtual" when provided.`
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
if (storage === CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL && column) {
|
|
123
|
-
throw new Error(
|
|
124
|
-
`${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} repository.storage "virtual" cannot define repository.column.`
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
if (storage === CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL && writeSerializer) {
|
|
128
|
-
throw new Error(
|
|
129
|
-
`${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} repository.storage "virtual" cannot define repository.writeSerializer.`
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return Object.freeze({
|
|
134
|
-
storage: storage === CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL
|
|
135
|
-
? CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL
|
|
136
|
-
: CRUD_FIELD_REPOSITORY_STORAGE_COLUMN,
|
|
137
|
-
column,
|
|
138
|
-
writeSerializer
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export {
|
|
143
|
-
CRUD_FIELD_REPOSITORY_STORAGE_COLUMN,
|
|
144
|
-
CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL,
|
|
145
|
-
CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC,
|
|
146
|
-
CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE,
|
|
147
|
-
CRUD_LOOKUP_FORM_CONTROL_SELECT,
|
|
148
|
-
CRUD_RUNTIME_LOOKUPS_FIELD_KEY,
|
|
149
|
-
checkCrudLookupFormControl,
|
|
150
|
-
isCrudRuntimeOutputOnlyFieldKey,
|
|
151
|
-
normalizeCrudFieldRepositoryConfig,
|
|
152
|
-
normalizeCrudFieldRepositoryWriteSerializer
|
|
153
|
-
};
|