@jskit-ai/crud-server-generator 0.1.26 → 0.1.28

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,157 +1,58 @@
1
- import { toInsertDateTime } from "@jskit-ai/database-runtime/shared";
2
- import { applyVisibility, applyVisibilityOwners } from "@jskit-ai/database-runtime/shared/visibility";
3
1
  import {
4
- DEFAULT_LIST_LIMIT,
5
- normalizeCrudListLimit,
6
- requireCrudTableName,
7
- buildWritePayload as baseBuildWritePayload,
8
- mapRecordRow as baseMapRecordRow,
9
- resolveColumnName,
10
- resolveCrudIdColumn
11
- } from "@jskit-ai/crud-core/server/repositorySupport";
12
-
13
- const DEFAULT_ID_COLUMN = __JSKIT_CRUD_ID_COLUMN__;
14
- const OUTPUT_KEYS = Object.freeze(__JSKIT_CRUD_REPOSITORY_OUTPUT_KEYS__);
15
- const WRITE_KEYS = Object.freeze(__JSKIT_CRUD_REPOSITORY_WRITE_KEYS__);
16
- const COLUMN_OVERRIDES = Object.freeze(__JSKIT_CRUD_REPOSITORY_COLUMN_OVERRIDES__);
17
- const CREATED_AT_COLUMN = __JSKIT_CRUD_REPOSITORY_CREATED_AT_COLUMN__;
18
- const UPDATED_AT_COLUMN = __JSKIT_CRUD_REPOSITORY_UPDATED_AT_COLUMN__;
2
+ createCrudRepositoryRuntime,
3
+ crudRepositoryList,
4
+ crudRepositoryFindById,
5
+ crudRepositoryListByIds,
6
+ crudRepositoryCreate,
7
+ crudRepositoryUpdateById,
8
+ crudRepositoryDeleteById
9
+ } from "@jskit-ai/crud-core/server/repositoryMethods";
10
+ import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
11
+
12
+ const LIST_CONFIG = Object.freeze({
13
+ // defaultLimit: 20,
14
+ // maxLimit: 100,
15
+ // searchColumns: ["name"]
16
+ });
19
17
 
20
- const {
21
- selectColumns: SELECT_COLUMNS,
22
- outputMappings: OUTPUT_MAPPINGS,
23
- writeMappings: WRITE_MAPPINGS
24
- } = buildRepositoryColumnMetadata({
25
- outputKeys: OUTPUT_KEYS,
26
- writeKeys: WRITE_KEYS,
27
- columnOverrides: COLUMN_OVERRIDES
18
+ const repositoryRuntime = createCrudRepositoryRuntime(resource, {
19
+ context: "${option:namespace|snake} repository",
20
+ list: LIST_CONFIG
28
21
  });
29
22
 
30
- function createRepository(knex, { tableName, idColumn = DEFAULT_ID_COLUMN } = {}) {
23
+ function createRepository(knex, options = {}) {
31
24
  if (typeof knex !== "function") {
32
25
  throw new TypeError("crudRepository requires knex.");
33
26
  }
34
27
 
35
- const resolvedTableName = requireCrudTableName(tableName);
36
- const resolvedIdColumn = resolveCrudIdColumn(idColumn, { fallback: DEFAULT_ID_COLUMN });
37
-
38
- async function list({ cursor = 0, limit = DEFAULT_LIST_LIMIT } = {}, options = {}) {
39
- const client = options?.trx || knex;
40
- const normalizedCursor = Number.isInteger(Number(cursor)) && Number(cursor) > 0 ? Number(cursor) : 0;
41
- const normalizedLimit = normalizeCrudListLimit(limit);
42
- const visible = (queryBuilder) => applyVisibility(queryBuilder, options.visibilityContext);
43
-
44
- let query = client(resolvedTableName)
45
- .select(...SELECT_COLUMNS)
46
- .where(visible)
47
- .orderBy(resolvedIdColumn, "asc")
48
- .limit(normalizedLimit + 1);
49
-
50
- if (normalizedCursor > 0) {
51
- query = query.where(resolvedIdColumn, ">", normalizedCursor);
52
- }
53
-
54
- const rows = await query;
55
- const hasMore = rows.length > normalizedLimit;
56
- const pageRows = hasMore ? rows.slice(0, normalizedLimit) : rows;
57
- const items = pageRows.map((row) => baseMapRecordRow(row, OUTPUT_KEYS, COLUMN_OVERRIDES));
58
-
59
- return {
60
- items,
61
- nextCursor: hasMore && items.length > 0 ? String(items[items.length - 1].id) : null
62
- };
28
+ async function list(query = {}, callOptions = {}) {
29
+ return crudRepositoryList(repositoryRuntime, knex, query, options, callOptions);
63
30
  }
64
31
 
65
- async function findById(recordId, options = {}) {
66
- const client = options?.trx || knex;
67
- const visible = (queryBuilder) => applyVisibility(queryBuilder, options.visibilityContext);
68
- const row = await client(resolvedTableName)
69
- .select(...SELECT_COLUMNS)
70
- .where(visible)
71
- .where({
72
- [resolvedIdColumn]: Number(recordId)
73
- })
74
- .first();
75
-
76
- return baseMapRecordRow(row, OUTPUT_KEYS, COLUMN_OVERRIDES);
32
+ async function findById(recordId, callOptions = {}) {
33
+ return crudRepositoryFindById(repositoryRuntime, knex, recordId, options, callOptions);
77
34
  }
78
35
 
79
- async function create(payload = {}, options = {}) {
80
- const client = options?.trx || knex;
81
- const timestamp = toInsertDateTime();
82
- const insertPayload = baseBuildWritePayload(payload, WRITE_KEYS, COLUMN_OVERRIDES);
83
- if (CREATED_AT_COLUMN && !Object.hasOwn(insertPayload, CREATED_AT_COLUMN)) {
84
- insertPayload[CREATED_AT_COLUMN] = timestamp;
85
- }
86
- if (UPDATED_AT_COLUMN && !Object.hasOwn(insertPayload, UPDATED_AT_COLUMN)) {
87
- insertPayload[UPDATED_AT_COLUMN] = timestamp;
88
- }
89
-
90
- const withOwners = applyVisibilityOwners(insertPayload, options.visibilityContext);
91
- const [recordId] = await client(resolvedTableName).insert(withOwners);
92
-
93
- return findById(recordId, {
94
- ...options,
95
- trx: client
96
- });
36
+ async function listByIds(ids = [], callOptions = {}) {
37
+ return crudRepositoryListByIds(repositoryRuntime, knex, ids, options, callOptions);
97
38
  }
98
39
 
99
- async function updateById(recordId, patch = {}, options = {}) {
100
- const client = options?.trx || knex;
101
- const dbPatch = baseBuildWritePayload(patch, WRITE_KEYS, COLUMN_OVERRIDES);
102
- const visible = (queryBuilder) => applyVisibility(queryBuilder, options.visibilityContext);
103
- if (UPDATED_AT_COLUMN) {
104
- dbPatch[UPDATED_AT_COLUMN] = toInsertDateTime();
105
- }
106
-
107
- if (Object.keys(dbPatch).length < 1) {
108
- return findById(recordId, {
109
- ...options,
110
- trx: client
111
- });
112
- }
113
-
114
- await client(resolvedTableName)
115
- .where(visible)
116
- .where({
117
- [resolvedIdColumn]: Number(recordId)
118
- })
119
- .update(dbPatch);
120
-
121
- return findById(recordId, {
122
- ...options,
123
- trx: client
124
- });
40
+ async function create(payload = {}, callOptions = {}) {
41
+ return crudRepositoryCreate(repositoryRuntime, knex, payload, options, callOptions);
125
42
  }
126
43
 
127
- async function deleteById(recordId, options = {}) {
128
- const client = options?.trx || knex;
129
- const visible = (queryBuilder) => applyVisibility(queryBuilder, options.visibilityContext);
130
- const existing = await findById(recordId, {
131
- ...options,
132
- trx: client
133
- });
134
-
135
- if (!existing) {
136
- return null;
137
- }
138
-
139
- await client(resolvedTableName)
140
- .where(visible)
141
- .where({
142
- [resolvedIdColumn]: Number(recordId)
143
- })
144
- .delete();
44
+ async function updateById(recordId, patch = {}, callOptions = {}) {
45
+ return crudRepositoryUpdateById(repositoryRuntime, knex, recordId, patch, options, callOptions);
46
+ }
145
47
 
146
- return {
147
- id: existing.id,
148
- deleted: true
149
- };
48
+ async function deleteById(recordId, callOptions = {}) {
49
+ return crudRepositoryDeleteById(repositoryRuntime, knex, recordId, options, callOptions);
150
50
  }
151
51
 
152
52
  return Object.freeze({
153
53
  list,
154
54
  findById,
55
+ listByIds,
155
56
  create,
156
57
  updateById,
157
58
  deleteById
@@ -1,54 +1,54 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import { createCrudServiceEvents } from "@jskit-ai/crud-core/server/serviceEvents";
3
+ import { createCrudFieldAccessRuntime } from "@jskit-ai/crud-core/server/fieldAccess";
4
+ import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
5
+
6
+ const baseServiceEvents = createCrudServiceEvents(resource, {
7
+ context: "${option:namespace|camel}Service"
8
+ });
9
+ const fieldAccessRuntime = createCrudFieldAccessRuntime(resource, {
10
+ context: "${option:namespace|camel}Service"
11
+ });
2
12
 
3
13
  const serviceEvents = Object.freeze({
4
- createRecord: Object.freeze([
5
- Object.freeze({
6
- type: "entity.changed",
7
- source: "crud",
8
- entity: "record",
9
- operation: "created",
10
- entityId: ({ result }) => result?.id,
11
- realtime: Object.freeze({
12
- event: "${option:namespace|snake}.record.changed",
13
- audience: "event_scope"
14
- })
15
- })
16
- ]),
17
- updateRecord: Object.freeze([
18
- Object.freeze({
19
- type: "entity.changed",
20
- source: "crud",
21
- entity: "record",
22
- operation: "updated",
23
- entityId: ({ result }) => result?.id,
24
- realtime: Object.freeze({
25
- event: "${option:namespace|snake}.record.changed",
26
- audience: "event_scope"
27
- })
28
- })
29
- ]),
30
- deleteRecord: Object.freeze([
31
- Object.freeze({
32
- type: "entity.changed",
33
- source: "crud",
34
- entity: "record",
35
- operation: "deleted",
36
- entityId: ({ result }) => result?.id,
37
- realtime: Object.freeze({
38
- event: "${option:namespace|snake}.record.changed",
39
- audience: "event_scope"
40
- })
41
- })
42
- ])
14
+ createRecord: [...baseServiceEvents.createRecord],
15
+ updateRecord: [...baseServiceEvents.updateRecord],
16
+ deleteRecord: [...baseServiceEvents.deleteRecord]
43
17
  });
44
18
 
45
- function createService({ ${option:namespace|camel}Repository } = {}) {
19
+ const DEFAULT_FIELD_ACCESS = Object.freeze({
20
+ // Tip: use createFieldAccessForRoleMatrix(...) from @jskit-ai/crud-core/server/fieldAccess to centralize role matrices.
21
+ // Example:
22
+ // const DEFAULT_FIELD_ACCESS = createFieldAccessForRoleMatrix({
23
+ // default: {
24
+ // readable: { list: ["id", "name"], view: ["id", "name", "email"] },
25
+ // writable: { create: ["name", "email"], update: ["name"] }
26
+ // },
27
+ // admin: {
28
+ // readable: "*",
29
+ // writable: "*"
30
+ // },
31
+ // writeMode: "throw" // or "strip"
32
+ // });
33
+ // readable: ({ action, context }) => ["id", "name"], // null/"*" means no read filtering
34
+ // Read redaction behavior: drop optional fields; use null/default for required fields.
35
+ // writable: ({ action, context }) => ["name"], // null/"*" means no write filtering
36
+ // writeMode: "throw" // "throw" (default) or "strip"
37
+ });
38
+
39
+ function createService({ ${option:namespace|camel}Repository, fieldAccess = DEFAULT_FIELD_ACCESS } = {}) {
46
40
  if (!${option:namespace|camel}Repository) {
47
41
  throw new Error("${option:namespace|camel}Service requires ${option:namespace|camel}Repository.");
48
42
  }
49
43
 
50
44
  async function listRecords(query = {}, options = {}) {
51
- return ${option:namespace|camel}Repository.list(query, options);
45
+ const result = await ${option:namespace|camel}Repository.list(query, options);
46
+ return fieldAccessRuntime.filterReadableListResult(result, fieldAccess, {
47
+ action: "list",
48
+ query,
49
+ options,
50
+ context: options?.context
51
+ });
52
52
  }
53
53
 
54
54
  async function getRecord(recordId, options = {}) {
@@ -56,24 +56,50 @@ function createService({ ${option:namespace|camel}Repository } = {}) {
56
56
  if (!record) {
57
57
  throw new AppError(404, "Record not found.");
58
58
  }
59
-
60
- return record;
59
+ return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
60
+ action: "view",
61
+ recordId,
62
+ options,
63
+ context: options?.context
64
+ });
61
65
  }
62
66
 
63
67
  async function createRecord(payload = {}, options = {}) {
64
- const record = await ${option:namespace|camel}Repository.create(payload, options);
68
+ const writablePayload = await fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
69
+ action: "create",
70
+ payload,
71
+ options,
72
+ context: options?.context
73
+ });
74
+ const record = await ${option:namespace|camel}Repository.create(writablePayload, options);
65
75
  if (!record) {
66
76
  throw new Error("${option:namespace|camel}Service could not load the created record.");
67
77
  }
68
- return record;
78
+ return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
79
+ action: "create",
80
+ options,
81
+ context: options?.context
82
+ });
69
83
  }
70
84
 
71
85
  async function updateRecord(recordId, payload = {}, options = {}) {
72
- const record = await ${option:namespace|camel}Repository.updateById(recordId, payload, options);
86
+ const writablePayload = await fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
87
+ action: "update",
88
+ recordId,
89
+ payload,
90
+ options,
91
+ context: options?.context
92
+ });
93
+ const record = await ${option:namespace|camel}Repository.updateById(recordId, writablePayload, options);
73
94
  if (!record) {
74
95
  throw new AppError(404, "Record not found.");
75
96
  }
76
- return record;
97
+ return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
98
+ action: "update",
99
+ recordId,
100
+ options,
101
+ context: options?.context
102
+ });
77
103
  }
78
104
 
79
105
  async function deleteRecord(recordId, options = {}) {
@@ -93,4 +119,19 @@ function createService({ ${option:namespace|camel}Repository } = {}) {
93
119
  });
94
120
  }
95
121
 
122
+ // Optional event override example:
123
+ // const serviceEvents = {
124
+ // ...baseServiceEvents,
125
+ // createRecord: [
126
+ // ...baseServiceEvents.createRecord,
127
+ // {
128
+ // type: "${option:namespace|snake}.custom",
129
+ // source: "custom",
130
+ // entity: "record",
131
+ // operation: "created",
132
+ // entityId: ({ result }) => result?.id
133
+ // }
134
+ // ]
135
+ // };
136
+
96
137
  export { createService, serviceEvents };
@@ -6,26 +6,13 @@ import {
6
6
  } from "@jskit-ai/kernel/shared/validators";
7
7
  __JSKIT_CRUD_RESOURCE_NORMALIZE_SUPPORT_IMPORT__
8
8
  __JSKIT_CRUD_RESOURCE_JSON_IMPORT__
9
- function normalizeRecordInput(payload = {}) {
10
- const source = normalizeObjectInput(payload);
11
- const normalized = {};
12
9
 
13
- __JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__
14
-
15
- return normalized;
16
- }
17
-
18
- function normalizeRecordOutput(payload = {}) {
19
- const source = normalizeObjectInput(payload);
20
-
21
- return {
22
- __JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__
23
- };
24
- }
10
+ const RESOURCE_LOOKUP_CONTAINER_KEY = "lookups";
25
11
 
26
12
  const recordOutputSchema = Type.Object(
27
13
  {
28
14
  __JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__
15
+ [RESOURCE_LOOKUP_CONTAINER_KEY]: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
29
16
  },
30
17
  { additionalProperties: false }
31
18
  );
@@ -46,11 +33,62 @@ const patchBodySchema = Type.Partial(createBodySchema, {
46
33
 
47
34
  const recordOutputValidator = Object.freeze({
48
35
  schema: recordOutputSchema,
49
- normalize: normalizeRecordOutput
36
+ normalize(payload = {}) {
37
+ const source = normalizeObjectInput(payload);
38
+ const normalized = {
39
+ __JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__
40
+ };
41
+ if (Object.hasOwn(source, RESOURCE_LOOKUP_CONTAINER_KEY)) {
42
+ normalized[RESOURCE_LOOKUP_CONTAINER_KEY] = source[RESOURCE_LOOKUP_CONTAINER_KEY];
43
+ }
44
+
45
+ return normalized;
46
+ }
50
47
  });
51
48
 
52
- const ${option:namespace|singular|camel}Resource = {
49
+ const listOutputValidator = createCursorListValidator(recordOutputValidator);
50
+
51
+ const createBodyValidator = Object.freeze({
52
+ schema: createBodySchema,
53
+ normalize(payload = {}) {
54
+ const source = normalizeObjectInput(payload);
55
+ const normalized = {};
56
+
57
+ __JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__
58
+
59
+ return normalized;
60
+ }
61
+ });
62
+
63
+ const patchBodyValidator = Object.freeze({
64
+ schema: patchBodySchema,
65
+ normalize: createBodyValidator.normalize
66
+ });
67
+
68
+ const deleteOutputValidator = Object.freeze({
69
+ schema: Type.Object(
70
+ {
71
+ id: Type.Integer({ minimum: 1 }),
72
+ deleted: Type.Literal(true)
73
+ },
74
+ { additionalProperties: false }
75
+ ),
76
+ normalize(payload = {}) {
77
+ const source = normalizeObjectInput(payload);
78
+
79
+ return {
80
+ id: Number(source.id),
81
+ deleted: true
82
+ };
83
+ }
84
+ });
85
+
86
+ const RESOURCE_FIELD_META = [];
87
+
88
+ const resource = {
53
89
  resource: "${option:namespace|snake}",
90
+ tableName: __JSKIT_CRUD_TABLE_NAME__,
91
+ idColumn: __JSKIT_CRUD_ID_COLUMN__,
54
92
  messages: {
55
93
  validation: "Fix invalid values and try again.",
56
94
  saveSuccess: "Record saved.",
@@ -58,10 +96,20 @@ const ${option:namespace|singular|camel}Resource = {
58
96
  deleteSuccess: "Record deleted.",
59
97
  deleteError: "Unable to delete record."
60
98
  },
99
+ contract: {
100
+ lookup: {
101
+ containerKey: RESOURCE_LOOKUP_CONTAINER_KEY,
102
+ defaultInclude: "*", // Set "none" to disable lookup hydration unless include=... is passed.
103
+ maxDepth: 3 // Lower this to limit nested lookup hydration depth.
104
+ }
105
+ },
61
106
  operations: {
62
107
  list: {
108
+ realtime: {
109
+ events: ["${option:namespace|snake}.record.changed"] // Add more events e.g. for lookup records
110
+ },
63
111
  method: "GET",
64
- outputValidator: createCursorListValidator(recordOutputValidator)
112
+ outputValidator: listOutputValidator
65
113
  },
66
114
  view: {
67
115
  method: "GET",
@@ -69,41 +117,25 @@ const ${option:namespace|singular|camel}Resource = {
69
117
  },
70
118
  create: {
71
119
  method: "POST",
72
- bodyValidator: {
73
- schema: createBodySchema,
74
- normalize: normalizeRecordInput
75
- },
120
+ bodyValidator: createBodyValidator,
76
121
  outputValidator: recordOutputValidator
77
122
  },
78
123
  patch: {
79
124
  method: "PATCH",
80
- bodyValidator: {
81
- schema: patchBodySchema,
82
- normalize: normalizeRecordInput
83
- },
125
+ bodyValidator: patchBodyValidator,
84
126
  outputValidator: recordOutputValidator
85
127
  },
86
128
  delete: {
87
129
  method: "DELETE",
88
- outputValidator: {
89
- schema: Type.Object(
90
- {
91
- id: Type.Integer({ minimum: 1 }),
92
- deleted: Type.Literal(true)
93
- },
94
- { additionalProperties: false }
95
- ),
96
- normalize(payload = {}) {
97
- const source = normalizeObjectInput(payload);
98
-
99
- return {
100
- id: Number(source.id),
101
- deleted: true
102
- };
103
- }
104
- }
130
+ outputValidator: deleteOutputValidator
105
131
  }
106
- }
132
+ },
133
+ fieldMeta: RESOURCE_FIELD_META
107
134
  };
108
135
 
109
- export { ${option:namespace|singular|camel}Resource };
136
+ export { resource };
137
+
138
+ // @jskit-contract crud.resource.field-meta.${option:namespace|snake}.v1
139
+ void RESOURCE_FIELD_META;
140
+
141
+ __JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__