@jskit-ai/crud-core 0.1.30 → 0.1.32

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/crud-core",
4
- version: "0.1.30",
4
+ version: "0.1.32",
5
5
  kind: "runtime",
6
6
  description: "Shared CRUD helpers used by CRUD modules.",
7
7
  dependsOn: [
@@ -26,7 +26,7 @@ export default Object.freeze({
26
26
  mutations: {
27
27
  dependencies: {
28
28
  runtime: {
29
- "@jskit-ai/crud-core": "0.1.30"
29
+ "@jskit-ai/crud-core": "0.1.32"
30
30
  },
31
31
  dev: {}
32
32
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -14,9 +14,11 @@
14
14
  "./shared/crudNamespaceSupport": "./src/shared/crudNamespaceSupport.js",
15
15
  "./server/repositorySupport": "./src/server/repositorySupport.js",
16
16
  "./server/repositoryMethods": "./src/server/repositoryMethods.js",
17
+ "./server/createHooksToCollectChildren": "./src/server/createHooksToCollectChildren.js",
17
18
  "./server/createCrudRepositoryFromResource": "./src/server/createCrudRepositoryFromResource.js",
18
19
  "./server/lookupProviders": "./src/server/lookupProviders.js",
19
20
  "./server/serviceEvents": "./src/server/serviceEvents.js",
21
+ "./server/serviceMethods": "./src/server/serviceMethods.js",
20
22
  "./server/fieldAccess": "./src/server/fieldAccess.js",
21
23
  "./server/createCrudServiceFromResource": "./src/server/createCrudServiceFromResource.js",
22
24
  "./server/crudModuleConfig": "./src/server/crudModuleConfig.js",
@@ -24,11 +26,11 @@
24
26
  },
25
27
  "dependencies": {
26
28
  "@tanstack/vue-query": "^5.90.5",
27
- "@jskit-ai/kernel": "0.1.22",
28
- "@jskit-ai/realtime": "0.1.21",
29
- "@jskit-ai/shell-web": "0.1.21",
30
- "@jskit-ai/users-core": "0.1.31",
31
- "@jskit-ai/users-web": "0.1.36",
29
+ "@jskit-ai/kernel": "0.1.24",
30
+ "@jskit-ai/realtime": "0.1.23",
31
+ "@jskit-ai/shell-web": "0.1.23",
32
+ "@jskit-ai/users-core": "0.1.33",
33
+ "@jskit-ai/users-web": "0.1.38",
32
34
  "typebox": "^1.0.81"
33
35
  }
34
36
  }
@@ -3,6 +3,7 @@ import {
3
3
  crudRepositoryList,
4
4
  crudRepositoryFindById,
5
5
  crudRepositoryListByIds,
6
+ crudRepositoryListByForeignIds,
6
7
  crudRepositoryCreate,
7
8
  crudRepositoryUpdateById,
8
9
  crudRepositoryDeleteById
@@ -19,34 +20,39 @@ function createCrudRepositoryFromResource(resource = {}, { context = "crudReposi
19
20
  throw new TypeError("crudRepository requires knex.");
20
21
  }
21
22
 
22
- async function listRecords(query = {}, callOptions = {}) {
23
- return crudRepositoryList(runtime, knex, query, options, callOptions);
23
+ async function listRecords(query = {}, callOptions = {}, hooks = null) {
24
+ return crudRepositoryList(runtime, knex, query, options, callOptions, hooks);
24
25
  }
25
26
 
26
- async function findById(recordId, callOptions = {}) {
27
- return crudRepositoryFindById(runtime, knex, recordId, options, callOptions);
27
+ async function findById(recordId, callOptions = {}, hooks = null) {
28
+ return crudRepositoryFindById(runtime, knex, recordId, options, callOptions, hooks);
28
29
  }
29
30
 
30
- async function listByIds(ids = [], callOptions = {}) {
31
- return crudRepositoryListByIds(runtime, knex, ids, options, callOptions);
31
+ async function listByIds(ids = [], callOptions = {}, hooks = null) {
32
+ return crudRepositoryListByIds(runtime, knex, ids, options, callOptions, hooks);
32
33
  }
33
34
 
34
- async function create(payload = {}, callOptions = {}) {
35
- return crudRepositoryCreate(runtime, knex, payload, options, callOptions);
35
+ async function listByForeignIds(ids = [], foreignKey = "", callOptions = {}, hooks = null) {
36
+ return crudRepositoryListByForeignIds(runtime, knex, ids, foreignKey, options, callOptions, hooks);
36
37
  }
37
38
 
38
- async function updateById(recordId, patch = {}, callOptions = {}) {
39
- return crudRepositoryUpdateById(runtime, knex, recordId, patch, options, callOptions);
39
+ async function create(payload = {}, callOptions = {}, hooks = null) {
40
+ return crudRepositoryCreate(runtime, knex, payload, options, callOptions, hooks);
40
41
  }
41
42
 
42
- async function deleteById(recordId, callOptions = {}) {
43
- return crudRepositoryDeleteById(runtime, knex, recordId, options, callOptions);
43
+ async function updateById(recordId, patch = {}, callOptions = {}, hooks = null) {
44
+ return crudRepositoryUpdateById(runtime, knex, recordId, patch, options, callOptions, hooks);
45
+ }
46
+
47
+ async function deleteById(recordId, callOptions = {}, hooks = null) {
48
+ return crudRepositoryDeleteById(runtime, knex, recordId, options, callOptions, hooks);
44
49
  }
45
50
 
46
51
  return Object.freeze({
47
52
  list: listRecords,
48
53
  findById,
49
54
  listByIds,
55
+ listByForeignIds,
50
56
  create,
51
57
  updateById,
52
58
  deleteById
@@ -1,12 +1,16 @@
1
- import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
- import { requireCrudNamespace } from "../shared/crudNamespaceSupport.js";
3
- import { createCrudFieldAccessRuntime } from "./fieldAccess.js";
4
1
  import { createCrudServiceEvents } from "./serviceEvents.js";
2
+ import {
3
+ createCrudServiceRuntime,
4
+ crudServiceListRecords,
5
+ crudServiceGetRecord,
6
+ crudServiceCreateRecord,
7
+ crudServiceUpdateRecord,
8
+ crudServiceDeleteRecord
9
+ } from "./serviceMethods.js";
5
10
 
6
11
  function createCrudServiceFromResource(resource = {}, { context = "crudService" } = {}) {
7
- const namespace = requireCrudNamespace(resource?.resource, { context: `${context} resource.resource` });
12
+ const runtime = createCrudServiceRuntime(resource, { context });
8
13
  const baseServiceEvents = createCrudServiceEvents(resource, { context });
9
- const fieldAccessRuntime = createCrudFieldAccessRuntime(resource, { context });
10
14
 
11
15
  function createBaseService({ repository, fieldAccess = {} } = {}) {
12
16
  if (!repository) {
@@ -14,73 +18,23 @@ function createCrudServiceFromResource(resource = {}, { context = "crudService"
14
18
  }
15
19
 
16
20
  async function listRecords(query = {}, options = {}) {
17
- const result = await repository.list(query, options);
18
- return fieldAccessRuntime.filterReadableListResult(result, fieldAccess, {
19
- action: "list",
20
- query,
21
- options,
22
- context: options?.context
23
- });
21
+ return crudServiceListRecords(runtime, repository, fieldAccess, query, options);
24
22
  }
25
23
 
26
24
  async function getRecord(recordId, options = {}) {
27
- const record = await repository.findById(recordId, options);
28
- if (!record) {
29
- throw new AppError(404, "Record not found.");
30
- }
31
-
32
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
33
- action: "view",
34
- recordId,
35
- options,
36
- context: options?.context
37
- });
25
+ return crudServiceGetRecord(runtime, repository, fieldAccess, recordId, options);
38
26
  }
39
27
 
40
28
  async function createRecord(payload = {}, options = {}) {
41
- const writablePayload = await fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
42
- action: "create",
43
- payload,
44
- options,
45
- context: options?.context
46
- });
47
- const record = await repository.create(writablePayload, options);
48
- if (!record) {
49
- throw new Error(`${namespace}Service could not load the created record.`);
50
- }
51
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
52
- action: "create",
53
- options,
54
- context: options?.context
55
- });
29
+ return crudServiceCreateRecord(runtime, repository, fieldAccess, payload, options);
56
30
  }
57
31
 
58
32
  async function updateRecord(recordId, payload = {}, options = {}) {
59
- const writablePayload = await fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
60
- action: "update",
61
- recordId,
62
- payload,
63
- options,
64
- context: options?.context
65
- });
66
- const record = await repository.updateById(recordId, writablePayload, options);
67
- if (!record) {
68
- throw new AppError(404, "Record not found.");
69
- }
70
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
71
- action: "update",
72
- recordId,
73
- options,
74
- context: options?.context
75
- });
33
+ return crudServiceUpdateRecord(runtime, repository, fieldAccess, recordId, payload, options);
76
34
  }
77
35
 
78
36
  async function deleteRecord(recordId, options = {}) {
79
- const deleted = await repository.deleteById(recordId, options);
80
- if (!deleted) {
81
- throw new AppError(404, "Record not found.");
82
- }
83
- return deleted;
37
+ return crudServiceDeleteRecord(runtime, repository, fieldAccess, recordId, options);
84
38
  }
85
39
 
86
40
  return Object.freeze({
@@ -0,0 +1,247 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+
3
+ function normalizeOwnerKey(value) {
4
+ if (value === null || value === undefined) {
5
+ return "";
6
+ }
7
+
8
+ if (typeof value === "string") {
9
+ return value.trim();
10
+ }
11
+ if (typeof value === "number") {
12
+ return Number.isFinite(value) ? String(value) : "";
13
+ }
14
+ if (typeof value === "bigint") {
15
+ return String(value);
16
+ }
17
+
18
+ return String(value).trim();
19
+ }
20
+
21
+ function toDefaultChildCallOptions(callOptions = {}) {
22
+ const nextOptions = {};
23
+ if (Object.hasOwn(callOptions, "trx")) {
24
+ nextOptions.trx = callOptions.trx;
25
+ }
26
+ if (Object.hasOwn(callOptions, "visibilityContext")) {
27
+ nextOptions.visibilityContext = callOptions.visibilityContext;
28
+ }
29
+
30
+ return nextOptions;
31
+ }
32
+
33
+ function resolveListChildrenHandler(options = {}, { context = "createHooksToCollectChildren" } = {}) {
34
+ if (typeof options.listChildren === "function") {
35
+ return options.listChildren;
36
+ }
37
+
38
+ const childRepository = options.childRepository;
39
+ const childListMethod = normalizeText(options.childListMethod) || "listByIds";
40
+ const childForeignKey = normalizeText(options.childForeignKey);
41
+ if (!childRepository || typeof childRepository !== "object" || Array.isArray(childRepository)) {
42
+ throw new TypeError(
43
+ `${context} requires listChildren(ids, options, ctx) or childRepository.`
44
+ );
45
+ }
46
+
47
+ const listChildren = childRepository[childListMethod];
48
+ if (typeof listChildren !== "function") {
49
+ throw new TypeError(`${context} requires childRepository.${childListMethod} to be a function.`);
50
+ }
51
+
52
+ if (childListMethod === "listByIds") {
53
+ if (!childForeignKey) {
54
+ throw new TypeError(`${context} requires childForeignKey when using childRepository.listByIds.`);
55
+ }
56
+
57
+ return (ids = [], childCallOptions = {}, hookContext = {}) =>
58
+ listChildren.call(childRepository, ids, {
59
+ ...childCallOptions,
60
+ valueKey: childForeignKey
61
+ }, hookContext);
62
+ }
63
+
64
+ if (childListMethod === "listByForeignIds") {
65
+ if (!childForeignKey) {
66
+ throw new TypeError(`${context} requires childForeignKey when using childRepository.listByForeignIds.`);
67
+ }
68
+
69
+ return (ids = [], childCallOptions = {}, hookContext = {}) =>
70
+ listChildren.call(childRepository, ids, childForeignKey, childCallOptions, hookContext);
71
+ }
72
+
73
+ return (ids = [], childCallOptions = {}, hookContext = {}) => {
74
+ if (childForeignKey) {
75
+ return listChildren.call(childRepository, ids, childForeignKey, childCallOptions, hookContext);
76
+ }
77
+ return listChildren.call(childRepository, ids, childCallOptions, hookContext);
78
+ };
79
+ }
80
+
81
+ function resolveGetChildOwnerId(options = {}, { context = "createHooksToCollectChildren" } = {}) {
82
+ if (typeof options.getChildOwnerId === "function") {
83
+ return options.getChildOwnerId;
84
+ }
85
+
86
+ const childOwnerIdKey = normalizeText(options.childOwnerIdKey);
87
+ const childForeignKey = normalizeText(options.childForeignKey);
88
+ const ownerKey = childOwnerIdKey || childForeignKey;
89
+ if (!ownerKey) {
90
+ throw new TypeError(`${context} requires childOwnerIdKey, childForeignKey, or getChildOwnerId.`);
91
+ }
92
+
93
+ return (child = {}) => {
94
+ if (!child || typeof child !== "object" || Array.isArray(child)) {
95
+ return undefined;
96
+ }
97
+ if (childOwnerIdKey && Object.hasOwn(child, childOwnerIdKey)) {
98
+ return child[childOwnerIdKey];
99
+ }
100
+ return child[ownerKey];
101
+ };
102
+ }
103
+
104
+ function resolveLookupContainerKey(record = {}, hookContext = {}, options = {}) {
105
+ const explicitContainerKey = normalizeText(options.lookupContainerKey);
106
+ if (explicitContainerKey) {
107
+ return explicitContainerKey;
108
+ }
109
+
110
+ const runtimeContainerKey = normalizeText(hookContext?.runtime?.lookup?.containerKey);
111
+ if (runtimeContainerKey) {
112
+ return runtimeContainerKey;
113
+ }
114
+
115
+ if (Object.hasOwn(record, "lookups")) {
116
+ return "lookups";
117
+ }
118
+
119
+ return "lookups";
120
+ }
121
+
122
+ function createHooksToCollectChildren(options = {}) {
123
+ const context = normalizeText(options.context) || "createHooksToCollectChildren";
124
+ const childKey = normalizeText(options.childKey);
125
+ if (!childKey) {
126
+ throw new TypeError(`${context} requires childKey.`);
127
+ }
128
+
129
+ const listChildren = resolveListChildrenHandler(options, {
130
+ context
131
+ });
132
+ const getParentId = typeof options.getParentId === "function"
133
+ ? options.getParentId
134
+ : (record = {}) => record?.id;
135
+ const getChildOwnerId = resolveGetChildOwnerId(options, {
136
+ context
137
+ });
138
+ const normalizeCollectionOwnerKey = typeof options.normalizeOwnerKey === "function"
139
+ ? options.normalizeOwnerKey
140
+ : normalizeOwnerKey;
141
+ const buildChildCallOptions = typeof options.buildChildCallOptions === "function"
142
+ ? options.buildChildCallOptions
143
+ : ({ callOptions = {} } = {}) => toDefaultChildCallOptions(callOptions);
144
+ const stateMapKey = options.stateMapKey || Symbol(`crud.children.${childKey}`);
145
+ const attachToLookupContainer = options.attachToLookupContainer !== false;
146
+ const attachChildren = typeof options.attachChildren === "function"
147
+ ? options.attachChildren
148
+ : (record = {}, children = [], hookContext = {}) => {
149
+ if (!attachToLookupContainer) {
150
+ return {
151
+ ...record,
152
+ [childKey]: children
153
+ };
154
+ }
155
+
156
+ const containerKey = resolveLookupContainerKey(record, hookContext, options);
157
+ const sourceContainer = record?.[containerKey];
158
+ const normalizedContainer =
159
+ sourceContainer && typeof sourceContainer === "object" && !Array.isArray(sourceContainer)
160
+ ? sourceContainer
161
+ : {};
162
+
163
+ return {
164
+ ...record,
165
+ [containerKey]: {
166
+ ...normalizedContainer,
167
+ [childKey]: children
168
+ }
169
+ };
170
+ };
171
+
172
+ return Object.freeze({
173
+ async afterQuery(records = [], ctx = {}) {
174
+ const normalizedRecords = Array.isArray(records) ? records : [];
175
+ const ownerIds = [];
176
+ const seenOwnerKeys = new Set();
177
+
178
+ for (const record of normalizedRecords) {
179
+ const ownerId = getParentId(record, ctx);
180
+ const ownerKey = normalizeCollectionOwnerKey(ownerId);
181
+ if (!ownerKey || seenOwnerKeys.has(ownerKey)) {
182
+ continue;
183
+ }
184
+
185
+ seenOwnerKeys.add(ownerKey);
186
+ ownerIds.push(ownerId);
187
+ }
188
+
189
+ const state = ctx?.state && typeof ctx.state === "object" ? ctx.state : null;
190
+ if (!state) {
191
+ throw new TypeError(`${context} requires ctx.state object.`);
192
+ }
193
+
194
+ if (ownerIds.length < 1) {
195
+ state[stateMapKey] = new Map();
196
+ return;
197
+ }
198
+
199
+ const childCallOptions = buildChildCallOptions({
200
+ callOptions: ctx.callOptions || {},
201
+ records: normalizedRecords,
202
+ ownerIds,
203
+ context: ctx
204
+ });
205
+ const children = await listChildren(ownerIds, childCallOptions, ctx);
206
+ if (!Array.isArray(children)) {
207
+ throw new TypeError(`${context} listChildren must return an array.`);
208
+ }
209
+
210
+ const childrenByOwnerKey = new Map();
211
+ for (const child of children) {
212
+ const childOwnerId = getChildOwnerId(child, ctx);
213
+ const childOwnerKey = normalizeCollectionOwnerKey(childOwnerId);
214
+ if (!childOwnerKey) {
215
+ continue;
216
+ }
217
+
218
+ const currentList = childrenByOwnerKey.get(childOwnerKey);
219
+ if (currentList) {
220
+ currentList.push(child);
221
+ continue;
222
+ }
223
+
224
+ childrenByOwnerKey.set(childOwnerKey, [child]);
225
+ }
226
+
227
+ state[stateMapKey] = childrenByOwnerKey;
228
+ },
229
+
230
+ transformReturnedRecord(record = {}, ctx = {}) {
231
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
232
+ return record;
233
+ }
234
+
235
+ const state = ctx?.state && typeof ctx.state === "object" ? ctx.state : null;
236
+ const childrenByOwnerKey = state ? state[stateMapKey] : null;
237
+ const ownerKey = normalizeCollectionOwnerKey(getParentId(record, ctx));
238
+ const children = ownerKey && childrenByOwnerKey instanceof Map
239
+ ? (childrenByOwnerKey.get(ownerKey) || [])
240
+ : [];
241
+
242
+ return attachChildren(record, children, ctx);
243
+ }
244
+ });
245
+ }
246
+
247
+ export { createHooksToCollectChildren };
@@ -1,7 +1,11 @@
1
1
  import { Type } from "typebox";
2
- import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators";
2
+ import {
3
+ normalizeObjectInput,
4
+ positiveIntegerValidator,
5
+ cursorPaginationQueryValidator
6
+ } from "@jskit-ai/kernel/shared/validators";
3
7
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
- import { resolveCrudLookupFieldKeys } from "@jskit-ai/kernel/shared/support/crudLookup";
8
+ import { resolveCrudParentFilterKeys as resolveSharedCrudParentFilterKeys } from "@jskit-ai/kernel/shared/support/crudLookup";
5
9
 
6
10
  const listSearchQueryValidator = Object.freeze({
7
11
  schema: Type.Object(
@@ -41,16 +45,59 @@ const lookupIncludeQueryValidator = Object.freeze({
41
45
  }
42
46
  });
43
47
 
44
- function resolveCrudParentFilterKeys(resource = {}) {
45
- const createSchemaProperties = resource?.operations?.create?.bodyValidator?.schema?.properties;
46
- const allowedKeys = createSchemaProperties && typeof createSchemaProperties === "object" && !Array.isArray(createSchemaProperties)
47
- ? Object.keys(createSchemaProperties)
48
- : [];
49
- return resolveCrudLookupFieldKeys(resource, {
50
- allowKeys: allowedKeys
48
+ function resolveCrudListUsesOrderedCursor(list = {}) {
49
+ const orderBy = Array.isArray(list?.orderBy) ? list.orderBy : [];
50
+ for (const entry of orderBy) {
51
+ if (typeof entry === "string" && normalizeText(entry)) {
52
+ return true;
53
+ }
54
+ if (entry && typeof entry === "object" && !Array.isArray(entry) && normalizeText(entry.column)) {
55
+ return true;
56
+ }
57
+ }
58
+
59
+ return false;
60
+ }
61
+
62
+ function createCrudCursorPaginationQueryValidator(list = {}) {
63
+ if (resolveCrudListUsesOrderedCursor(list) !== true) {
64
+ return cursorPaginationQueryValidator;
65
+ }
66
+
67
+ return Object.freeze({
68
+ schema: Type.Object(
69
+ {
70
+ cursor: Type.Optional(
71
+ Type.Union([
72
+ positiveIntegerValidator.schema,
73
+ Type.String({ minLength: 1 })
74
+ ])
75
+ ),
76
+ limit: Type.Optional(positiveIntegerValidator.schema)
77
+ },
78
+ { additionalProperties: false }
79
+ ),
80
+ normalize(payload = {}) {
81
+ const source = normalizeObjectInput(payload);
82
+ const normalized = {};
83
+
84
+ if (Object.hasOwn(source, "cursor")) {
85
+ normalized.cursor = normalizeText(source.cursor);
86
+ }
87
+
88
+ if (Object.hasOwn(source, "limit")) {
89
+ normalized.limit = positiveIntegerValidator.normalize(source.limit);
90
+ }
91
+
92
+ return normalized;
93
+ }
51
94
  });
52
95
  }
53
96
 
97
+ function resolveCrudParentFilterKeys(resource = {}) {
98
+ return resolveSharedCrudParentFilterKeys(resource);
99
+ }
100
+
54
101
  function createCrudParentFilterQueryValidator(resource = {}) {
55
102
  const keys = resolveCrudParentFilterKeys(resource);
56
103
  const schemaProperties = {};
@@ -80,6 +127,7 @@ function createCrudParentFilterQueryValidator(resource = {}) {
80
127
  }
81
128
 
82
129
  export {
130
+ createCrudCursorPaginationQueryValidator,
83
131
  listSearchQueryValidator,
84
132
  lookupIncludeQueryValidator,
85
133
  resolveCrudParentFilterKeys,