@jskit-ai/crud-server-generator 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,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,10 +72,11 @@ 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
- outputValidator: ${option:namespace|singular|camel}Resource.operations.view.outputValidator,
79
+ outputValidator: resource.operations.view.outputValidator,
69
80
  idempotency: "none",
70
81
  audit: {
71
82
  actionName: actionIds.view
@@ -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
@@ -8,12 +8,7 @@ import {
8
8
  crudRepositoryDeleteById
9
9
  } from "@jskit-ai/crud-core/server/repositoryMethods";
10
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
- });
11
+ import { LIST_CONFIG } from "./listConfig.js";
17
12
 
18
13
  const repositoryRuntime = createCrudRepositoryRuntime(resource, {
19
14
  context: "${option:namespace|snake} repository",
@@ -21,10 +16,6 @@ const repositoryRuntime = createCrudRepositoryRuntime(resource, {
21
16
  });
22
17
 
23
18
  function createRepository(knex, options = {}) {
24
- if (typeof knex !== "function") {
25
- throw new TypeError("crudRepository requires knex.");
26
- }
27
-
28
19
  async function list(query = {}, callOptions = {}) {
29
20
  return crudRepositoryList(repositoryRuntime, knex, query, options, callOptions);
30
21
  }
@@ -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
 
@@ -138,4 +135,21 @@ export { resource };
138
135
  // @jskit-contract crud.resource.field-meta.${option:namespace|snake}.v1
139
136
  void RESOURCE_FIELD_META;
140
137
 
138
+ // Example 1:n collection hydration:
139
+ // RESOURCE_FIELD_META.push({
140
+ // key: "pets",
141
+ // relation: {
142
+ // kind: "collection",
143
+ // namespace: "pets",
144
+ // foreignKey: "customerId",
145
+ // parentValueKey: "id",
146
+ // hydrateOnList: false, // list: opt-in with include=pets
147
+ // hydrateOnView: true // view: hydrated by default
148
+ // }
149
+ // });
150
+ //
151
+ // To hydrate child lookups too, request nested include paths:
152
+ // - include=pets
153
+ // - include=pets,pets.breedId
154
+
141
155
  __JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__
@@ -1,3 +1,3 @@
1
1
  export {
2
- ${option:namespace|singular|camel}Resource
2
+ resource
3
3
  } from "./${option:namespace|singular|camel}Resource.js";
@@ -86,7 +86,7 @@ export { resource };
86
86
  `;
87
87
 
88
88
  async function withTempApp(run) {
89
- const appRoot = await mkdtemp(path.join(tmpdir(), "crud-server-add-field-"));
89
+ const appRoot = await mkdtemp(path.join(tmpdir(), "crud-server-scaffold-field-"));
90
90
  try {
91
91
  await run(appRoot);
92
92
  } finally {
@@ -129,14 +129,14 @@ function createSnapshot() {
129
129
  };
130
130
  }
131
131
 
132
- test("add-field patches CRUD resource file using DB snapshot metadata", async () => {
132
+ test("scaffold-field patches CRUD resource file using DB snapshot metadata", async () => {
133
133
  await withTempApp(async (appRoot) => {
134
134
  const resourceFile = "packages/contacts/src/shared/contactResource.js";
135
135
  await writeAppFile(appRoot, resourceFile, RESOURCE_SOURCE);
136
136
 
137
137
  const result = await runGeneratorSubcommand({
138
138
  appRoot,
139
- subcommand: "add-field",
139
+ subcommand: "scaffold-field",
140
140
  args: ["categoryId", resourceFile],
141
141
  options: {},
142
142
  resolveSnapshot: async () => createSnapshot()
@@ -157,7 +157,7 @@ test("add-field patches CRUD resource file using DB snapshot metadata", async ()
157
157
 
158
158
  const secondRun = await runGeneratorSubcommand({
159
159
  appRoot,
160
- subcommand: "add-field",
160
+ subcommand: "scaffold-field",
161
161
  args: ["categoryId", resourceFile],
162
162
  options: {},
163
163
  resolveSnapshot: async () => createSnapshot()
@@ -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(),
@@ -285,6 +330,47 @@ test("buildReplacementsFromSnapshot renders append-only field meta entries from
285
330
  assert.match(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, /table\.foreign\(\["vet_id"\]/);
286
331
  });
287
332
 
333
+ test("buildReplacementsFromSnapshot renders enum field meta options as select controls", () => {
334
+ const baseSnapshot = createSnapshot({
335
+ hasWorkspaceOwnerColumn: false,
336
+ hasUserOwnerColumn: false
337
+ });
338
+ const snapshot = {
339
+ ...baseSnapshot,
340
+ columns: Object.freeze([
341
+ ...baseSnapshot.columns,
342
+ Object.freeze({
343
+ name: "temperament",
344
+ key: "temperament",
345
+ dataType: "enum",
346
+ columnType: "enum('relaxed','friendly_excitable','unknown')",
347
+ typeKind: "string",
348
+ nullable: false,
349
+ hasDefault: false,
350
+ defaultValue: null,
351
+ autoIncrement: false,
352
+ unsigned: false,
353
+ extra: "",
354
+ maxLength: null,
355
+ numericPrecision: null,
356
+ numericScale: null,
357
+ enumValues: Object.freeze(["relaxed", "friendly_excitable", "unknown"])
358
+ })
359
+ ])
360
+ };
361
+
362
+ const replacements = __testables.buildReplacementsFromSnapshot({
363
+ snapshot,
364
+ resolvedOwnershipFilter: "public"
365
+ });
366
+
367
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /key: "temperament"/);
368
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /formControl: "select"/);
369
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /options: \[/);
370
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /"value": "friendly_excitable"/);
371
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /"label": "Friendly Excitable"/);
372
+ });
373
+
288
374
  test("renderMigrationColumnLine ignores SQL NULL string defaults", () => {
289
375
  const line = __testables.renderMigrationColumnLine(
290
376
  {
@@ -403,6 +489,7 @@ test("crud repository template defines explicit one-line CRUD methods over repos
403
489
  templateSource,
404
490
  /from "@jskit-ai\/crud-core\/server\/repositoryMethods";/
405
491
  );
492
+ assert.match(templateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
406
493
  assert.match(templateSource, /const repositoryRuntime = createCrudRepositoryRuntime\(/);
407
494
  assert.match(templateSource, /return crudRepositoryList\(repositoryRuntime, knex, query, options, callOptions\);/);
408
495
  assert.match(templateSource, /return crudRepositoryFindById\(repositoryRuntime, knex, recordId, options, callOptions\);/);
@@ -410,9 +497,31 @@ test("crud repository template defines explicit one-line CRUD methods over repos
410
497
  assert.match(templateSource, /return crudRepositoryCreate\(repositoryRuntime, knex, payload, options, callOptions\);/);
411
498
  assert.match(templateSource, /return crudRepositoryUpdateById\(repositoryRuntime, knex, recordId, patch, options, callOptions\);/);
412
499
  assert.match(templateSource, /return crudRepositoryDeleteById\(repositoryRuntime, knex, recordId, options, callOptions\);/);
500
+ assert.doesNotMatch(templateSource, /listByForeignIds/);
501
+ assert.doesNotMatch(templateSource, /crudRepository requires knex/);
413
502
  });
414
503
 
415
- 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 () => {
416
525
  const testDirectory = path.dirname(fileURLToPath(import.meta.url));
417
526
  const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "service.js");
418
527
  const templateSource = await readFile(templatePath, "utf8");
@@ -423,16 +532,81 @@ test("crud service template defines explicit service methods and semi-explicit d
423
532
  );
424
533
  assert.match(
425
534
  templateSource,
426
- /from "@jskit-ai\/crud-core\/server\/fieldAccess";/
535
+ /from "@jskit-ai\/crud-core\/server\/serviceMethods";/
427
536
  );
428
- assert.match(templateSource, /const baseServiceEvents = createCrudServiceEvents\(/);
429
- assert.match(templateSource, /const fieldAccessRuntime = createCrudFieldAccessRuntime\(/);
537
+ assert.match(templateSource, /const serviceRuntime = createCrudServiceRuntime\(resource,/);
538
+ assert.match(templateSource, /const baseServiceEvents = createCrudServiceEvents\(resource,/);
430
539
  assert.match(templateSource, /const serviceEvents = Object\.freeze\(\{/);
431
540
  assert.match(templateSource, /createRecord: \[\.\.\.baseServiceEvents\.createRecord\],/);
541
+ assert.match(templateSource, /function createService\(\{ \$\{option:namespace\|camel\}Repository, fieldAccess = DEFAULT_FIELD_ACCESS \} = \{\}\)/);
432
542
  assert.match(templateSource, /async function listRecords\(query = \{\}, options = \{\}\)/);
433
- assert.match(templateSource, /return fieldAccessRuntime\.filterReadableListResult\(result, fieldAccess, \{/);
434
- assert.match(templateSource, /const writablePayload = await fieldAccessRuntime\.enforceWritablePayload\(payload, fieldAccess, \{/);
435
- 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
+ );
436
610
  });
437
611
 
438
612
  test("crud provider template uses shared lookup provider helpers instead of inline wiring", async () => {
@@ -445,6 +619,9 @@ test("crud provider template uses shared lookup provider helpers instead of inli
445
619
  /from "@jskit-ai\/crud-core\/server\/lookupProviders";/
446
620
  );
447
621
  assert.match(templateSource, /resolveLookupProvider: createCrudLookupProviderResolver\(scope\)/);
448
- assert.match(templateSource, /return createCrudLookupProvider\(scope\.make\("repository\.\$\{option:namespace\|snake\}"\)\);/);
622
+ assert.match(
623
+ templateSource,
624
+ /return createCrudLookupProvider\(scope\.make\("repository\.\$\{option:namespace\|snake\}"\), \{\s*ownershipFilter: crudPolicy\.ownershipFilter\s*\}\);/
625
+ );
449
626
  assert.doesNotMatch(templateSource, /normalizePathname\(relation\.apiPath\)/);
450
627
  });
@@ -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
+ });