@jskit-ai/crud-server-generator 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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/crud-server-generator",
4
- version: "0.1.31",
4
+ version: "0.1.33",
5
5
  kind: "generator",
6
6
  description: "CRUD server generator with routes, actions, and persistence scaffolding.",
7
7
  options: {
@@ -134,13 +134,13 @@ export default Object.freeze({
134
134
  mutations: {
135
135
  dependencies: {
136
136
  runtime: {
137
- "@jskit-ai/auth-core": "0.1.22",
138
- "@jskit-ai/crud-core": "0.1.31",
139
- "@jskit-ai/database-runtime": "0.1.23",
140
- "@jskit-ai/http-runtime": "0.1.22",
141
- "@jskit-ai/kernel": "0.1.23",
142
- "@jskit-ai/realtime": "0.1.22",
143
- "@jskit-ai/users-core": "0.1.32",
137
+ "@jskit-ai/auth-core": "0.1.24",
138
+ "@jskit-ai/crud-core": "0.1.33",
139
+ "@jskit-ai/database-runtime": "0.1.25",
140
+ "@jskit-ai/http-runtime": "0.1.24",
141
+ "@jskit-ai/kernel": "0.1.25",
142
+ "@jskit-ai/realtime": "0.1.24",
143
+ "@jskit-ai/users-core": "0.1.35",
144
144
  "@local/${option:namespace|kebab}": "file:packages/${option:namespace|kebab}",
145
145
  "typebox": "^1.0.81"
146
146
  },
@@ -247,6 +247,18 @@ export default Object.freeze({
247
247
  }
248
248
  }
249
249
  ],
250
- text: []
250
+ text: [
251
+ {
252
+ op: "append-text",
253
+ file: "config/roles.js",
254
+ position: "bottom",
255
+ skipIfContains: "\"crud.${option:namespace|snake}.list\"",
256
+ value:
257
+ "\nroleCatalog.roles.member.permissions.push(\n \"crud.${option:namespace|snake}.list\",\n \"crud.${option:namespace|snake}.view\",\n \"crud.${option:namespace|snake}.create\",\n \"crud.${option:namespace|snake}.update\",\n \"crud.${option:namespace|snake}.delete\"\n);\n",
258
+ reason: "Grant generated CRUD action permissions to the default member role in the app-owned role catalog.",
259
+ category: "crud",
260
+ id: "crud-role-catalog-permissions-${option:namespace|snake}"
261
+ }
262
+ ]
251
263
  }
252
264
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-server-generator",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -13,11 +13,11 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@babel/parser": "^7.29.2",
16
- "@jskit-ai/crud-core": "0.1.31",
17
- "@jskit-ai/database-runtime": "0.1.23",
18
- "@jskit-ai/http-runtime": "0.1.22",
19
- "@jskit-ai/kernel": "0.1.23",
20
- "@jskit-ai/users-core": "0.1.32",
16
+ "@jskit-ai/crud-core": "0.1.33",
17
+ "@jskit-ai/database-runtime": "0.1.25",
18
+ "@jskit-ai/http-runtime": "0.1.24",
19
+ "@jskit-ai/kernel": "0.1.25",
20
+ "@jskit-ai/users-core": "0.1.35",
21
21
  "recast": "^0.23.11",
22
22
  "typebox": "^1.0.81"
23
23
  }
@@ -369,7 +369,9 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
369
369
  } else if (typeKind === "date") {
370
370
  schemaExpression = 'Type.String({ format: "date", minLength: 1 })';
371
371
  } else if (typeKind === "time") {
372
- schemaExpression = 'Type.String({ format: "time", minLength: 1 })';
372
+ return column.nullable === true
373
+ ? "NULLABLE_HTML_TIME_STRING_SCHEMA"
374
+ : "HTML_TIME_STRING_SCHEMA";
373
375
  } else if (typeKind === "json") {
374
376
  schemaExpression = "Type.Any()";
375
377
  }
@@ -380,6 +382,17 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
380
382
  return schemaExpression;
381
383
  }
382
384
 
385
+ function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false } = {}) {
386
+ const imports = [
387
+ "normalizeObjectInput",
388
+ "createCursorListValidator"
389
+ ];
390
+ if (needsHtmlTimeSchemas) {
391
+ imports.push("HTML_TIME_STRING_SCHEMA", "NULLABLE_HTML_TIME_STRING_SCHEMA");
392
+ }
393
+ return `import {\n ${imports.join(",\n ")}\n} from "@jskit-ai/kernel/shared/validators";`;
394
+ }
395
+
383
396
  function renderInputNormalizer(column) {
384
397
  const typeKind = String(column.typeKind || "");
385
398
  const nullable = column?.nullable === true;
@@ -1064,6 +1077,29 @@ function renderResourceFieldMetaPushLines(entries = []) {
1064
1077
  return sourceEntries.map((entry) => renderFieldMetaEntryLines(entry)).join("\n\n");
1065
1078
  }
1066
1079
 
1080
+ function renderRepositoryListConfigLines(snapshot = {}) {
1081
+ const commentLines = [
1082
+ " // defaultLimit: 20,",
1083
+ " // maxLimit: 100,",
1084
+ " // searchColumns: [\"name\"],"
1085
+ ];
1086
+ const sourceColumns = Array.isArray(snapshot?.columns) ? snapshot.columns : [];
1087
+ const hasCreatedAtColumn = sourceColumns.some((column = {}) => normalizeText(column?.name) === "created_at");
1088
+ if (!hasCreatedAtColumn) {
1089
+ return commentLines.join("\n");
1090
+ }
1091
+
1092
+ return [
1093
+ ...commentLines,
1094
+ " orderBy: [",
1095
+ " {",
1096
+ " column: \"created_at\",",
1097
+ " direction: \"desc\"",
1098
+ " }",
1099
+ " ]"
1100
+ ].join("\n");
1101
+ }
1102
+
1067
1103
  function buildReplacementsFromSnapshot({
1068
1104
  snapshot,
1069
1105
  resolvedOwnershipFilter
@@ -1090,6 +1126,7 @@ function buildReplacementsFromSnapshot({
1090
1126
  const needsNullableDateInput = writableColumns.some(
1091
1127
  (column) => column.typeKind === "date" && column.nullable === true
1092
1128
  );
1129
+ const needsHtmlTimeSchemas = resourceColumns.some((column) => column.typeKind === "time");
1093
1130
  const needsDate = resourceColumns.some((column) => column.typeKind === "date");
1094
1131
  const needsJson = resourceColumns.some((column) => column.typeKind === "json");
1095
1132
  const needsNormalizeText = resourceColumns.some((column) =>
@@ -1107,6 +1144,9 @@ function buildReplacementsFromSnapshot({
1107
1144
  __JSKIT_CRUD_TABLE_NAME__: JSON.stringify(snapshot.tableName),
1108
1145
  __JSKIT_CRUD_ID_COLUMN__: JSON.stringify(snapshot.idColumn || DEFAULT_ID_COLUMN),
1109
1146
  __JSKIT_CRUD_RESOLVED_OWNERSHIP_FILTER__: resolvedOwnershipFilter,
1147
+ __JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__: renderResourceValidatorsImport({
1148
+ needsHtmlTimeSchemas
1149
+ }),
1110
1150
  __JSKIT_CRUD_RESOURCE_DATABASE_RUNTIME_IMPORT__: renderResourceDatabaseRuntimeImport({
1111
1151
  needsToIsoString: needsDateTimeOutput || needsDate,
1112
1152
  needsToDatabaseDateTimeUtc: needsDateTimeInput
@@ -1133,6 +1173,7 @@ function buildReplacementsFromSnapshot({
1133
1173
  __JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__: renderResourceOutputNormalizationLines(outputColumns),
1134
1174
  __JSKIT_CRUD_RESOURCE_CREATE_REQUIRED_FIELDS__: JSON.stringify(createRequiredFieldKeys),
1135
1175
  __JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__: renderResourceFieldMetaPushLines(fieldMetaEntries),
1176
+ __JSKIT_CRUD_LIST_CONFIG_LINES__: renderRepositoryListConfigLines(snapshot),
1136
1177
  __JSKIT_CRUD_MIGRATION_COLUMN_LINES__: renderMigrationColumnLines(snapshot),
1137
1178
  __JSKIT_CRUD_MIGRATION_INDEX_LINES__: renderMigrationIndexLines(snapshot),
1138
1179
  __JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__: renderMigrationForeignKeyLines(snapshot)
@@ -1,8 +1,8 @@
1
1
  import {
2
- cursorPaginationQueryValidator,
3
2
  recordIdParamsValidator
4
3
  } from "@jskit-ai/kernel/shared/validators";
5
4
  import {
5
+ createCrudCursorPaginationQueryValidator,
6
6
  listSearchQueryValidator,
7
7
  lookupIncludeQueryValidator,
8
8
  createCrudParentFilterQueryValidator
@@ -10,8 +10,17 @@ import {
10
10
  import { workspaceSlugParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";
11
11
  import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
12
12
  import { actionIds } from "./actionIds.js";
13
+ import { LIST_CONFIG } from "./listConfig.js";
13
14
 
15
+ const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator(LIST_CONFIG);
14
16
  const listParentFilterQueryValidator = createCrudParentFilterQueryValidator(resource);
17
+ const actionPermissions = Object.freeze({
18
+ list: "crud.${option:namespace|snake}.list",
19
+ view: "crud.${option:namespace|snake}.view",
20
+ create: "crud.${option:namespace|snake}.create",
21
+ update: "crud.${option:namespace|snake}.update",
22
+ delete: "crud.${option:namespace|snake}.delete"
23
+ });
15
24
 
16
25
  function requireActionSurface(surface = "") {
17
26
  const normalizedSurface = String(surface || "").trim().toLowerCase();
@@ -33,11 +42,12 @@ function createActions({ surface = "" } = {}) {
33
42
  channels: ["api", "automation", "internal"],
34
43
  surfaces: [actionSurface],
35
44
  permission: {
36
- require: "authenticated"
45
+ require: "all",
46
+ permissions: [actionPermissions.list]
37
47
  },
38
48
  inputValidator: [
39
49
  workspaceSlugParamsValidator,
40
- cursorPaginationQueryValidator,
50
+ listCursorPaginationQueryValidator,
41
51
  listSearchQueryValidator,
42
52
  listParentFilterQueryValidator,
43
53
  lookupIncludeQueryValidator
@@ -62,7 +72,8 @@ function createActions({ surface = "" } = {}) {
62
72
  channels: ["api", "automation", "internal"],
63
73
  surfaces: [actionSurface],
64
74
  permission: {
65
- require: "authenticated"
75
+ require: "all",
76
+ permissions: [actionPermissions.view]
66
77
  },
67
78
  inputValidator: [workspaceSlugParamsValidator, recordIdParamsValidator, lookupIncludeQueryValidator],
68
79
  outputValidator: resource.operations.view.outputValidator,
@@ -86,7 +97,8 @@ function createActions({ surface = "" } = {}) {
86
97
  channels: ["api", "automation", "internal"],
87
98
  surfaces: [actionSurface],
88
99
  permission: {
89
- require: "authenticated"
100
+ require: "all",
101
+ permissions: [actionPermissions.create]
90
102
  },
91
103
  inputValidator: [
92
104
  workspaceSlugParamsValidator,
@@ -114,7 +126,8 @@ function createActions({ surface = "" } = {}) {
114
126
  channels: ["api", "automation", "internal"],
115
127
  surfaces: [actionSurface],
116
128
  permission: {
117
- require: "authenticated"
129
+ require: "all",
130
+ permissions: [actionPermissions.update]
118
131
  },
119
132
  inputValidator: [
120
133
  workspaceSlugParamsValidator,
@@ -143,7 +156,8 @@ function createActions({ surface = "" } = {}) {
143
156
  channels: ["api", "automation", "internal"],
144
157
  surfaces: [actionSurface],
145
158
  permission: {
146
- require: "authenticated"
159
+ require: "all",
160
+ permissions: [actionPermissions.delete]
147
161
  },
148
162
  inputValidator: [workspaceSlugParamsValidator, recordIdParamsValidator],
149
163
  outputValidator: resource.operations.delete.outputValidator,
@@ -0,0 +1,5 @@
1
+ const LIST_CONFIG = Object.freeze({
2
+ __JSKIT_CRUD_LIST_CONFIG_LINES__
3
+ });
4
+
5
+ export { LIST_CONFIG };
@@ -1,12 +1,12 @@
1
1
  import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
2
2
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
3
3
  import {
4
+ createCrudCursorPaginationQueryValidator,
4
5
  listSearchQueryValidator,
5
6
  lookupIncludeQueryValidator,
6
7
  createCrudParentFilterQueryValidator
7
8
  } from "@jskit-ai/crud-core/server/listQueryValidators";
8
9
  import {
9
- cursorPaginationQueryValidator,
10
10
  recordIdParamsValidator
11
11
  } from "@jskit-ai/kernel/shared/validators";
12
12
  import { routeParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";
@@ -15,7 +15,9 @@ import { buildWorkspaceInputFromRouteParams } from "@jskit-ai/users-core/server/
15
15
  import { resolveApiBasePath } from "@jskit-ai/users-core/shared/support/usersApiPaths";
16
16
  import { actionIds } from "./actionIds.js";
17
17
  import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
18
+ import { LIST_CONFIG } from "./listConfig.js";
18
19
 
20
+ const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator(LIST_CONFIG);
19
21
  const listParentFilterQueryValidator = createCrudParentFilterQueryValidator(resource);
20
22
 
21
23
  function registerRoutes(
@@ -47,7 +49,7 @@ function registerRoutes(
47
49
  },
48
50
  paramsValidator: routeParamsValidator,
49
51
  queryValidator: [
50
- cursorPaginationQueryValidator,
52
+ listCursorPaginationQueryValidator,
51
53
  listSearchQueryValidator,
52
54
  listParentFilterQueryValidator,
53
55
  lookupIncludeQueryValidator
@@ -3,18 +3,12 @@ import {
3
3
  crudRepositoryList,
4
4
  crudRepositoryFindById,
5
5
  crudRepositoryListByIds,
6
- crudRepositoryListByForeignIds,
7
6
  crudRepositoryCreate,
8
7
  crudRepositoryUpdateById,
9
8
  crudRepositoryDeleteById
10
9
  } from "@jskit-ai/crud-core/server/repositoryMethods";
11
10
  import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
12
-
13
- const LIST_CONFIG = Object.freeze({
14
- // defaultLimit: 20,
15
- // maxLimit: 100,
16
- // searchColumns: ["name"]
17
- });
11
+ import { LIST_CONFIG } from "./listConfig.js";
18
12
 
19
13
  const repositoryRuntime = createCrudRepositoryRuntime(resource, {
20
14
  context: "${option:namespace|snake} repository",
@@ -22,10 +16,6 @@ const repositoryRuntime = createCrudRepositoryRuntime(resource, {
22
16
  });
23
17
 
24
18
  function createRepository(knex, options = {}) {
25
- if (typeof knex !== "function") {
26
- throw new TypeError("crudRepository requires knex.");
27
- }
28
-
29
19
  async function list(query = {}, callOptions = {}) {
30
20
  return crudRepositoryList(repositoryRuntime, knex, query, options, callOptions);
31
21
  }
@@ -38,17 +28,6 @@ function createRepository(knex, options = {}) {
38
28
  return crudRepositoryListByIds(repositoryRuntime, knex, ids, options, callOptions);
39
29
  }
40
30
 
41
- async function listByForeignIds(ids = [], foreignKey = "", callOptions = {}) {
42
- return crudRepositoryListByForeignIds(
43
- repositoryRuntime,
44
- knex,
45
- ids,
46
- foreignKey,
47
- options,
48
- callOptions
49
- );
50
- }
51
-
52
31
  async function create(payload = {}, callOptions = {}) {
53
32
  return crudRepositoryCreate(repositoryRuntime, knex, payload, options, callOptions);
54
33
  }
@@ -65,7 +44,6 @@ function createRepository(knex, options = {}) {
65
44
  list,
66
45
  findById,
67
46
  listByIds,
68
- listByForeignIds,
69
47
  create,
70
48
  updateById,
71
49
  deleteById
@@ -1,12 +1,18 @@
1
- import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
1
  import { createCrudServiceEvents } from "@jskit-ai/crud-core/server/serviceEvents";
3
- import { createCrudFieldAccessRuntime } from "@jskit-ai/crud-core/server/fieldAccess";
2
+ import {
3
+ createCrudServiceRuntime,
4
+ crudServiceListRecords,
5
+ crudServiceGetRecord,
6
+ crudServiceCreateRecord,
7
+ crudServiceUpdateRecord,
8
+ crudServiceDeleteRecord
9
+ } from "@jskit-ai/crud-core/server/serviceMethods";
4
10
  import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
5
11
 
6
- const baseServiceEvents = createCrudServiceEvents(resource, {
12
+ const serviceRuntime = createCrudServiceRuntime(resource, {
7
13
  context: "${option:namespace|camel}Service"
8
14
  });
9
- const fieldAccessRuntime = createCrudFieldAccessRuntime(resource, {
15
+ const baseServiceEvents = createCrudServiceEvents(resource, {
10
16
  context: "${option:namespace|camel}Service"
11
17
  });
12
18
 
@@ -37,77 +43,24 @@ const DEFAULT_FIELD_ACCESS = Object.freeze({
37
43
  });
38
44
 
39
45
  function createService({ ${option:namespace|camel}Repository, fieldAccess = DEFAULT_FIELD_ACCESS } = {}) {
40
- if (!${option:namespace|camel}Repository) {
41
- throw new Error("${option:namespace|camel}Service requires ${option:namespace|camel}Repository.");
42
- }
43
-
44
46
  async function listRecords(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
- });
47
+ return crudServiceListRecords(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, query, options);
52
48
  }
53
49
 
54
50
  async function getRecord(recordId, options = {}) {
55
- const record = await ${option:namespace|camel}Repository.findById(recordId, options);
56
- if (!record) {
57
- throw new AppError(404, "Record not found.");
58
- }
59
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
60
- action: "view",
61
- recordId,
62
- options,
63
- context: options?.context
64
- });
51
+ return crudServiceGetRecord(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, recordId, options);
65
52
  }
66
53
 
67
54
  async function createRecord(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);
75
- if (!record) {
76
- throw new Error("${option:namespace|camel}Service could not load the created record.");
77
- }
78
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
79
- action: "create",
80
- options,
81
- context: options?.context
82
- });
55
+ return crudServiceCreateRecord(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, payload, options);
83
56
  }
84
57
 
85
58
  async function updateRecord(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);
94
- if (!record) {
95
- throw new AppError(404, "Record not found.");
96
- }
97
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
98
- action: "update",
99
- recordId,
100
- options,
101
- context: options?.context
102
- });
59
+ return crudServiceUpdateRecord(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, recordId, payload, options);
103
60
  }
104
61
 
105
62
  async function deleteRecord(recordId, options = {}) {
106
- const deleted = await ${option:namespace|camel}Repository.deleteById(recordId, options);
107
- if (!deleted) {
108
- throw new AppError(404, "Record not found.");
109
- }
110
- return deleted;
63
+ return crudServiceDeleteRecord(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, recordId, options);
111
64
  }
112
65
 
113
66
  return Object.freeze({
@@ -1,9 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  __JSKIT_CRUD_RESOURCE_DATABASE_RUNTIME_IMPORT__
3
- import {
4
- normalizeObjectInput,
5
- createCursorListValidator
6
- } from "@jskit-ai/kernel/shared/validators";
3
+ __JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__
7
4
  __JSKIT_CRUD_RESOURCE_NORMALIZE_SUPPORT_IMPORT__
8
5
  __JSKIT_CRUD_RESOURCE_JSON_IMPORT__
9
6
 
@@ -9,8 +9,30 @@ import { buildTemplateContext, __testables } from "../src/server/buildTemplateCo
9
9
  function createSnapshot({
10
10
  tableName = "contacts",
11
11
  hasWorkspaceOwnerColumn = true,
12
- hasUserOwnerColumn = true
12
+ hasUserOwnerColumn = true,
13
+ hasCreatedAtColumn = true
13
14
  } = {}) {
15
+ const createdAtColumn = hasCreatedAtColumn
16
+ ? [
17
+ Object.freeze({
18
+ name: "created_at",
19
+ key: "createdAt",
20
+ dataType: "datetime",
21
+ columnType: "datetime",
22
+ typeKind: "datetime",
23
+ nullable: false,
24
+ hasDefault: true,
25
+ defaultValue: "CURRENT_TIMESTAMP",
26
+ autoIncrement: false,
27
+ unsigned: false,
28
+ extra: "",
29
+ maxLength: null,
30
+ numericPrecision: null,
31
+ numericScale: null,
32
+ enumValues: Object.freeze([])
33
+ })
34
+ ]
35
+ : [];
14
36
  return Object.freeze({
15
37
  tableName,
16
38
  idColumn: "id",
@@ -86,6 +108,7 @@ function createSnapshot({
86
108
  numericScale: null,
87
109
  enumValues: Object.freeze([])
88
110
  }),
111
+ ...createdAtColumn,
89
112
  Object.freeze({
90
113
  name: "updated_at",
91
114
  key: "updatedAt",
@@ -223,6 +246,14 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
223
246
  replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__,
224
247
  /firstName: normalizeIfPresent\(source\.firstName, normalizeText\),/
225
248
  );
249
+ assert.match(
250
+ replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__,
251
+ /orderBy: \[\s+{\s+column: "created_at",\s+direction: "desc"\s+}\s+\]/s
252
+ );
253
+ assert.match(
254
+ replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__,
255
+ /\/\/ searchColumns: \["name"\],\s+orderBy:/s
256
+ );
226
257
  assert.doesNotMatch(
227
258
  replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__,
228
259
  /== null \?/
@@ -231,6 +262,20 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
231
262
  assert.equal(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, "");
232
263
  });
233
264
 
265
+ test("buildReplacementsFromSnapshot omits default list ordering when created_at is absent", () => {
266
+ const snapshot = createSnapshot({
267
+ hasCreatedAtColumn: false
268
+ });
269
+ const replacements = __testables.buildReplacementsFromSnapshot({
270
+ namespace: "contacts",
271
+ snapshot,
272
+ resolvedOwnershipFilter: "workspace_user"
273
+ });
274
+
275
+ assert.doesNotMatch(replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__, /orderBy/);
276
+ assert.match(replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__, /searchColumns/);
277
+ });
278
+
234
279
  test("buildReplacementsFromSnapshot renders append-only field meta entries from foreign keys", () => {
235
280
  const snapshot = {
236
281
  ...createSnapshot(),
@@ -444,6 +489,7 @@ test("crud repository template defines explicit one-line CRUD methods over repos
444
489
  templateSource,
445
490
  /from "@jskit-ai\/crud-core\/server\/repositoryMethods";/
446
491
  );
492
+ assert.match(templateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
447
493
  assert.match(templateSource, /const repositoryRuntime = createCrudRepositoryRuntime\(/);
448
494
  assert.match(templateSource, /return crudRepositoryList\(repositoryRuntime, knex, query, options, callOptions\);/);
449
495
  assert.match(templateSource, /return crudRepositoryFindById\(repositoryRuntime, knex, recordId, options, callOptions\);/);
@@ -451,9 +497,31 @@ test("crud repository template defines explicit one-line CRUD methods over repos
451
497
  assert.match(templateSource, /return crudRepositoryCreate\(repositoryRuntime, knex, payload, options, callOptions\);/);
452
498
  assert.match(templateSource, /return crudRepositoryUpdateById\(repositoryRuntime, knex, recordId, patch, options, callOptions\);/);
453
499
  assert.match(templateSource, /return crudRepositoryDeleteById\(repositoryRuntime, knex, recordId, options, callOptions\);/);
500
+ assert.doesNotMatch(templateSource, /listByForeignIds/);
501
+ assert.doesNotMatch(templateSource, /crudRepository requires knex/);
454
502
  });
455
503
 
456
- test("crud service template defines explicit service methods and semi-explicit default events", async () => {
504
+ test("crud actions and routes templates share LIST_CONFIG for cursor validation", async () => {
505
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
506
+ const actionsTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "actions.js");
507
+ const registerRoutesTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "registerRoutes.js");
508
+ const listConfigTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "listConfig.js");
509
+
510
+ const actionsTemplateSource = await readFile(actionsTemplatePath, "utf8");
511
+ const registerRoutesTemplateSource = await readFile(registerRoutesTemplatePath, "utf8");
512
+ const listConfigTemplateSource = await readFile(listConfigTemplatePath, "utf8");
513
+
514
+ assert.match(actionsTemplateSource, /createCrudCursorPaginationQueryValidator/);
515
+ assert.match(actionsTemplateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
516
+ assert.match(actionsTemplateSource, /const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator\(LIST_CONFIG\);/);
517
+ assert.match(registerRoutesTemplateSource, /createCrudCursorPaginationQueryValidator/);
518
+ assert.match(registerRoutesTemplateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
519
+ assert.match(registerRoutesTemplateSource, /const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator\(LIST_CONFIG\);/);
520
+ assert.match(listConfigTemplateSource, /const LIST_CONFIG = Object\.freeze\(\{/);
521
+ assert.match(listConfigTemplateSource, /__JSKIT_CRUD_LIST_CONFIG_LINES__/);
522
+ });
523
+
524
+ test("crud service template defines explicit service methods over shared service primitives and preserves overridable default events", async () => {
457
525
  const testDirectory = path.dirname(fileURLToPath(import.meta.url));
458
526
  const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "service.js");
459
527
  const templateSource = await readFile(templatePath, "utf8");
@@ -464,16 +532,81 @@ test("crud service template defines explicit service methods and semi-explicit d
464
532
  );
465
533
  assert.match(
466
534
  templateSource,
467
- /from "@jskit-ai\/crud-core\/server\/fieldAccess";/
535
+ /from "@jskit-ai\/crud-core\/server\/serviceMethods";/
468
536
  );
469
- assert.match(templateSource, /const baseServiceEvents = createCrudServiceEvents\(/);
470
- assert.match(templateSource, /const fieldAccessRuntime = createCrudFieldAccessRuntime\(/);
537
+ assert.match(templateSource, /const serviceRuntime = createCrudServiceRuntime\(resource,/);
538
+ assert.match(templateSource, /const baseServiceEvents = createCrudServiceEvents\(resource,/);
471
539
  assert.match(templateSource, /const serviceEvents = Object\.freeze\(\{/);
472
540
  assert.match(templateSource, /createRecord: \[\.\.\.baseServiceEvents\.createRecord\],/);
541
+ assert.match(templateSource, /function createService\(\{ \$\{option:namespace\|camel\}Repository, fieldAccess = DEFAULT_FIELD_ACCESS \} = \{\}\)/);
473
542
  assert.match(templateSource, /async function listRecords\(query = \{\}, options = \{\}\)/);
474
- assert.match(templateSource, /return fieldAccessRuntime\.filterReadableListResult\(result, fieldAccess, \{/);
475
- assert.match(templateSource, /const writablePayload = await fieldAccessRuntime\.enforceWritablePayload\(payload, fieldAccess, \{/);
476
- assert.match(templateSource, /throw new AppError\(404, "Record not found\."\);/);
543
+ assert.match(templateSource, /return crudServiceListRecords\(serviceRuntime, \$\{option:namespace\|camel\}Repository, fieldAccess, query, options\);/);
544
+ assert.match(templateSource, /async function updateRecord\(recordId, payload = \{\}, options = \{\}\)/);
545
+ assert.match(templateSource, /return crudServiceUpdateRecord\(serviceRuntime, \$\{option:namespace\|camel\}Repository, fieldAccess, recordId, payload, options\);/);
546
+ assert.match(templateSource, /return Object\.freeze\(\{/);
547
+ });
548
+
549
+ test("crud generator renders time columns with html-time-compatible schemas", async () => {
550
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
551
+ const templatePath = path.resolve(testDirectory, "..", "src", "server", "buildTemplateContext.js");
552
+ const templateSource = await readFile(templatePath, "utf8");
553
+
554
+ assert.match(
555
+ templateSource,
556
+ /NULLABLE_HTML_TIME_STRING_SCHEMA/
557
+ );
558
+ assert.match(
559
+ templateSource,
560
+ /HTML_TIME_STRING_SCHEMA/
561
+ );
562
+ assert.doesNotMatch(templateSource, /format: "time"/);
563
+ });
564
+
565
+ test("buildReplacementsFromSnapshot uses shared framework time schemas in generated resources", () => {
566
+ const snapshot = createSnapshot({
567
+ tableName: "opening_hours"
568
+ });
569
+ const timeColumn = Object.freeze({
570
+ name: "from_time",
571
+ key: "fromTime",
572
+ dataType: "time",
573
+ columnType: "time",
574
+ typeKind: "time",
575
+ nullable: true,
576
+ hasDefault: false,
577
+ defaultValue: null,
578
+ autoIncrement: false,
579
+ unsigned: false,
580
+ extra: "",
581
+ maxLength: null,
582
+ numericPrecision: null,
583
+ numericScale: null,
584
+ enumValues: Object.freeze([])
585
+ });
586
+ const replacements = __testables.buildReplacementsFromSnapshot({
587
+ snapshot: {
588
+ ...snapshot,
589
+ columns: Object.freeze([...snapshot.columns, timeColumn])
590
+ },
591
+ resolvedOwnershipFilter: "workspace_user"
592
+ });
593
+
594
+ assert.match(
595
+ replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__,
596
+ /NULLABLE_HTML_TIME_STRING_SCHEMA/
597
+ );
598
+ assert.match(
599
+ replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
600
+ /fromTime: NULLABLE_HTML_TIME_STRING_SCHEMA/
601
+ );
602
+ assert.match(
603
+ replacements.__JSKIT_CRUD_RESOURCE_CREATE_SCHEMA_PROPERTIES__,
604
+ /fromTime: NULLABLE_HTML_TIME_STRING_SCHEMA/
605
+ );
606
+ assert.doesNotMatch(
607
+ replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
608
+ /Type\.String\(\{ pattern:/
609
+ );
477
610
  });
478
611
 
479
612
  test("crud provider template uses shared lookup provider helpers instead of inline wiring", async () => {
@@ -1,6 +1,7 @@
1
1
  import test, { after } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { createTemplateServerFixture } from "../test-support/templateServerFixture.js";
4
+ import descriptor from "../package.descriptor.mjs";
4
5
 
5
6
  const fixture = await createTemplateServerFixture();
6
7
  const { createActions } = await fixture.importServerModule("actions.js");
@@ -53,3 +54,37 @@ test("template createActions requires explicit surface", () => {
53
54
  /requires a non-empty surface/
54
55
  );
55
56
  });
57
+
58
+ test("template createActions requires namespaced CRUD permissions by default", () => {
59
+ const actions = createActions({ surface: "admin" });
60
+
61
+ assert.deepEqual(
62
+ actions.map((action) => action.permission),
63
+ [
64
+ { require: "all", permissions: ["crud.customers.list"] },
65
+ { require: "all", permissions: ["crud.customers.view"] },
66
+ { require: "all", permissions: ["crud.customers.create"] },
67
+ { require: "all", permissions: ["crud.customers.update"] },
68
+ { require: "all", permissions: ["crud.customers.delete"] }
69
+ ]
70
+ );
71
+ });
72
+
73
+ test("crud generator appends member role grants for generated CRUD permissions", () => {
74
+ assert.deepEqual(
75
+ descriptor.mutations.text,
76
+ [
77
+ {
78
+ op: "append-text",
79
+ file: "config/roles.js",
80
+ position: "bottom",
81
+ skipIfContains: "\"crud.${option:namespace|snake}.list\"",
82
+ value:
83
+ "\nroleCatalog.roles.member.permissions.push(\n \"crud.${option:namespace|snake}.list\",\n \"crud.${option:namespace|snake}.view\",\n \"crud.${option:namespace|snake}.create\",\n \"crud.${option:namespace|snake}.update\",\n \"crud.${option:namespace|snake}.delete\"\n);\n",
84
+ reason: "Grant generated CRUD action permissions to the default member role in the app-owned role catalog.",
85
+ category: "crud",
86
+ id: "crud-role-catalog-permissions-${option:namespace|snake}"
87
+ }
88
+ ]
89
+ );
90
+ });
@@ -47,6 +47,7 @@ test("crudService delegates CRUD operations to the repository", async () => {
47
47
  ["list", { limit: 10 }],
48
48
  ["findById", 3],
49
49
  ["create", { textField: "Example", dateField: "2026-03-11", numberField: 3 }],
50
+ ["findById", 4],
50
51
  ["updateById", 4, { textField: "Changed" }],
51
52
  ["deleteById", 5]
52
53
  ]);
@@ -95,6 +96,48 @@ test("crudService exports default realtime events for create/update/delete", ()
95
96
  assert.equal(serviceEvents.deleteRecord[0].realtime.event, "customers.record.changed");
96
97
  });
97
98
 
99
+ test("crudService passes existing records into patch normalization via the shared CRUD service", async () => {
100
+ const calls = [];
101
+ const service = createService({
102
+ customersRepository: {
103
+ async list() {
104
+ return { items: [], nextCursor: null };
105
+ },
106
+ async findById(recordId) {
107
+ calls.push(["findById", recordId]);
108
+ return {
109
+ id: recordId,
110
+ textField: "Existing",
111
+ dateField: "2026-03-11T00:00:00.000Z",
112
+ numberField: 3
113
+ };
114
+ },
115
+ async create(payload) {
116
+ return { id: 1, ...payload };
117
+ },
118
+ async updateById(recordId, payload) {
119
+ calls.push(["updateById", recordId, payload]);
120
+ return {
121
+ id: recordId,
122
+ textField: payload.textField || "",
123
+ dateField: "2026-03-11T00:00:00.000Z",
124
+ numberField: payload.numberField ?? 0
125
+ };
126
+ },
127
+ async deleteById(recordId) {
128
+ return { id: recordId, deleted: true };
129
+ }
130
+ }
131
+ });
132
+
133
+ await service.updateRecord(4, { textField: "Changed" }, {});
134
+
135
+ assert.deepEqual(calls, [
136
+ ["findById", 4],
137
+ ["updateById", 4, { textField: "Changed" }]
138
+ ]);
139
+ });
140
+
98
141
  test("crudService supports optional fieldAccess hooks for writable filtering", async () => {
99
142
  const calls = [];
100
143
  const service = createService({
@@ -18,7 +18,15 @@ const TEMPLATE_REPLACEMENTS = Object.freeze([
18
18
  ["${option:namespace|camel}", CRUD_NAMESPACE.camel],
19
19
  ["${option:namespace|singular|camel}", CRUD_NAMESPACE.singularCamel],
20
20
  ["${option:namespace|pascal}", CRUD_NAMESPACE.pascal],
21
- ["__JSKIT_CRUD_ID_COLUMN__", JSON.stringify("id")]
21
+ ["__JSKIT_CRUD_ID_COLUMN__", JSON.stringify("id")],
22
+ [
23
+ "__JSKIT_CRUD_LIST_CONFIG_LINES__",
24
+ [
25
+ " // defaultLimit: 20,",
26
+ " // maxLimit: 100,",
27
+ " // searchColumns: [\"name\"],"
28
+ ].join("\n")
29
+ ]
22
30
  ]);
23
31
 
24
32
  function applyTemplateReplacements(sourceText = "") {
@@ -145,7 +153,7 @@ async function createTemplateServerFixture() {
145
153
  );
146
154
  await writeFile(path.join(sharedRoot, "customerResource.js"), buildResourceStubSource(), "utf8");
147
155
 
148
- for (const fileName of ["actionIds.js", "actions.js", "registerRoutes.js", "repository.js", "service.js"]) {
156
+ for (const fileName of ["actionIds.js", "actions.js", "listConfig.js", "registerRoutes.js", "repository.js", "service.js"]) {
149
157
  await renderServerTemplateFile(serverRoot, fileName);
150
158
  }
151
159