@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.
@@ -0,0 +1,167 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import path from "node:path";
4
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { runGeneratorSubcommand } from "../src/server/subcommands/addField.js";
7
+
8
+ const RESOURCE_SOURCE = `import { Type } from "typebox";
9
+ import { normalizeObjectInput, createCursorListValidator } from "@jskit-ai/kernel/shared/validators";
10
+ import { normalizeText, normalizeIfInSource, normalizeIfPresent, normalizeOrNull } from "@jskit-ai/kernel/shared/support/normalize";
11
+
12
+ const RESOURCE_LOOKUP_CONTAINER_KEY = "lookups";
13
+
14
+ const recordOutputSchema = Type.Object(
15
+ {
16
+ id: Type.Integer({ minimum: 1 }),
17
+ firstName: Type.Union([Type.String(), Type.Null()]),
18
+ [RESOURCE_LOOKUP_CONTAINER_KEY]: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
19
+ },
20
+ { additionalProperties: false }
21
+ );
22
+
23
+ const createBodySchema = Type.Object(
24
+ {
25
+ firstName: Type.Union([Type.String({ maxLength: 160 }), Type.Null()])
26
+ },
27
+ {
28
+ additionalProperties: false,
29
+ required: []
30
+ }
31
+ );
32
+
33
+ const patchBodySchema = Type.Partial(createBodySchema, { additionalProperties: false });
34
+
35
+ const recordOutputValidator = Object.freeze({
36
+ schema: recordOutputSchema,
37
+ normalize(payload = {}) {
38
+ const source = normalizeObjectInput(payload);
39
+ const normalized = {
40
+ id: normalizeIfPresent(source.id, Number),
41
+ firstName: normalizeOrNull(source.firstName, normalizeText)
42
+ };
43
+ const sourceLookupContainer = source[RESOURCE_LOOKUP_CONTAINER_KEY];
44
+ if (sourceLookupContainer && typeof sourceLookupContainer === "object" && !Array.isArray(sourceLookupContainer)) {
45
+ normalized[RESOURCE_LOOKUP_CONTAINER_KEY] = sourceLookupContainer;
46
+ }
47
+ return normalized;
48
+ }
49
+ });
50
+
51
+ const listOutputValidator = createCursorListValidator(recordOutputValidator);
52
+
53
+ const createBodyValidator = Object.freeze({
54
+ schema: createBodySchema,
55
+ normalize(payload = {}) {
56
+ const source = normalizeObjectInput(payload);
57
+ const normalized = {};
58
+
59
+ normalizeIfInSource(source, normalized, "firstName", normalizeText);
60
+
61
+ return normalized;
62
+ }
63
+ });
64
+
65
+ const patchBodyValidator = Object.freeze({
66
+ schema: patchBodySchema,
67
+ normalize: createBodyValidator.normalize
68
+ });
69
+
70
+ const RESOURCE_FIELD_META = [];
71
+
72
+ const resource = {
73
+ resource: "contacts",
74
+ tableName: "contacts",
75
+ idColumn: "id",
76
+ operations: {
77
+ list: { method: "GET", outputValidator: listOutputValidator },
78
+ view: { method: "GET", outputValidator: recordOutputValidator },
79
+ create: { method: "POST", bodyValidator: createBodyValidator, outputValidator: recordOutputValidator },
80
+ patch: { method: "PATCH", bodyValidator: patchBodyValidator, outputValidator: recordOutputValidator }
81
+ },
82
+ fieldMeta: RESOURCE_FIELD_META
83
+ };
84
+
85
+ export { resource };
86
+ `;
87
+
88
+ async function withTempApp(run) {
89
+ const appRoot = await mkdtemp(path.join(tmpdir(), "crud-server-add-field-"));
90
+ try {
91
+ await run(appRoot);
92
+ } finally {
93
+ await rm(appRoot, { recursive: true, force: true });
94
+ }
95
+ }
96
+
97
+ async function writeAppFile(appRoot, relativePath, source) {
98
+ const absolutePath = path.join(appRoot, relativePath);
99
+ await mkdir(path.dirname(absolutePath), { recursive: true });
100
+ await writeFile(absolutePath, source, "utf8");
101
+ return absolutePath;
102
+ }
103
+
104
+ function createSnapshot() {
105
+ return {
106
+ tableName: "contacts",
107
+ idColumn: "id",
108
+ columns: [
109
+ { name: "id", key: "id", typeKind: "integer", nullable: false, unsigned: true },
110
+ { name: "workspace_owner_id", key: "workspaceOwnerId", typeKind: "integer", nullable: true, unsigned: true },
111
+ { name: "user_owner_id", key: "userOwnerId", typeKind: "integer", nullable: true, unsigned: true },
112
+ { name: "created_at", key: "createdAt", typeKind: "datetime", nullable: false },
113
+ { name: "updated_at", key: "updatedAt", typeKind: "datetime", nullable: false },
114
+ { name: "first_name", key: "firstName", typeKind: "string", nullable: true, maxLength: 160 },
115
+ { name: "category_id", key: "categoryId", typeKind: "integer", nullable: true, unsigned: true }
116
+ ],
117
+ foreignKeys: [
118
+ {
119
+ name: "contacts_category_id_foreign",
120
+ referencedTableName: "customer_categories",
121
+ columns: [
122
+ {
123
+ name: "category_id",
124
+ referencedName: "id"
125
+ }
126
+ ]
127
+ }
128
+ ]
129
+ };
130
+ }
131
+
132
+ test("add-field patches CRUD resource file using DB snapshot metadata", async () => {
133
+ await withTempApp(async (appRoot) => {
134
+ const resourceFile = "packages/contacts/src/shared/contactResource.js";
135
+ await writeAppFile(appRoot, resourceFile, RESOURCE_SOURCE);
136
+
137
+ const result = await runGeneratorSubcommand({
138
+ appRoot,
139
+ subcommand: "add-field",
140
+ args: ["categoryId", resourceFile],
141
+ options: {},
142
+ resolveSnapshot: async () => createSnapshot()
143
+ });
144
+
145
+ assert.deepEqual(result.touchedFiles, [resourceFile]);
146
+
147
+ const content = await readFile(path.join(appRoot, resourceFile), "utf8");
148
+ assert.match(content, /categoryId: Type\.Union\(\[Type\.Integer\(\{ minimum: 0 \}\), Type\.Null\(\)\]\)/);
149
+ assert.match(content, /normalizeIfInSource\(source, normalized, "categoryId", normalizeFiniteInteger\);/);
150
+ assert.match(content, /categoryId: normalizeOrNull\(source\.categoryId, normalizeFiniteInteger\)/);
151
+ assert.match(content, /RESOURCE_FIELD_META\.push\(\{/);
152
+ assert.match(content, /key: "categoryId"/);
153
+ assert.match(content, /namespace: "customer-categories"/);
154
+ assert.match(content, /valueKey: "id"/);
155
+ assert.match(content, /formControl: "autocomplete" \/\/ or "select"/);
156
+ assert.match(content, /normalizeFiniteInteger/);
157
+
158
+ const secondRun = await runGeneratorSubcommand({
159
+ appRoot,
160
+ subcommand: "add-field",
161
+ args: ["categoryId", resourceFile],
162
+ options: {},
163
+ resolveSnapshot: async () => createSnapshot()
164
+ });
165
+ assert.deepEqual(secondRun.touchedFiles, []);
166
+ });
167
+ });
@@ -1,5 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
2
4
  import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
3
6
 
4
7
  import { buildTemplateContext, __testables } from "../src/server/buildTemplateContext.js";
5
8
 
@@ -101,7 +104,8 @@ function createSnapshot({
101
104
  enumValues: Object.freeze([])
102
105
  })
103
106
  ]),
104
- indexes: Object.freeze([])
107
+ indexes: Object.freeze([]),
108
+ foreignKeys: Object.freeze([])
105
109
  });
106
110
  }
107
111
 
@@ -196,9 +200,7 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
196
200
  assert.equal(replacements.__JSKIT_CRUD_RESOLVED_OWNERSHIP_FILTER__, "workspace_user");
197
201
  assert.match(replacements.__JSKIT_CRUD_MIGRATION_COLUMN_LINES__, /table\.increments\("id"\)/);
198
202
  assert.match(replacements.__JSKIT_CRUD_MIGRATION_COLUMN_LINES__, /table\.string\("first_name", 160\)/);
199
- assert.match(replacements.__JSKIT_CRUD_REPOSITORY_OUTPUT_KEYS__, /"firstName"/);
200
- assert.match(replacements.__JSKIT_CRUD_REPOSITORY_WRITE_KEYS__, /"firstName"/);
201
- assert.equal(replacements.__JSKIT_CRUD_REPOSITORY_COLUMN_OVERRIDES__, "{}");
203
+ assert.equal(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, "");
202
204
  assert.match(replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__, /updatedAt: Type\.String/);
203
205
  assert.match(
204
206
  replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
@@ -226,6 +228,61 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
226
228
  /== null \?/
227
229
  );
228
230
  assert.equal(replacements.__JSKIT_CRUD_RESOURCE_CREATE_REQUIRED_FIELDS__, "[\"firstName\"]");
231
+ assert.equal(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, "");
232
+ });
233
+
234
+ test("buildReplacementsFromSnapshot renders append-only field meta entries from foreign keys", () => {
235
+ const snapshot = {
236
+ ...createSnapshot(),
237
+ columns: Object.freeze([
238
+ ...createSnapshot().columns,
239
+ Object.freeze({
240
+ name: "vet_id",
241
+ key: "vetId",
242
+ dataType: "int",
243
+ columnType: "int unsigned",
244
+ typeKind: "integer",
245
+ nullable: true,
246
+ hasDefault: false,
247
+ defaultValue: null,
248
+ autoIncrement: false,
249
+ unsigned: true,
250
+ extra: "",
251
+ maxLength: null,
252
+ numericPrecision: 10,
253
+ numericScale: 0,
254
+ enumValues: Object.freeze([])
255
+ })
256
+ ]),
257
+ foreignKeys: Object.freeze([
258
+ Object.freeze({
259
+ name: "contacts_vet_id_foreign",
260
+ referencedTableName: "customer_categories",
261
+ updateRule: "CASCADE",
262
+ deleteRule: "SET NULL",
263
+ columns: Object.freeze([
264
+ Object.freeze({
265
+ name: "vet_id",
266
+ referencedName: "id"
267
+ })
268
+ ])
269
+ })
270
+ ])
271
+ };
272
+
273
+ const replacements = __testables.buildReplacementsFromSnapshot({
274
+ snapshot,
275
+ resolvedOwnershipFilter: "workspace_user"
276
+ });
277
+
278
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /RESOURCE_FIELD_META\.push\(\{/);
279
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /key: "vetId"/);
280
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /namespace: "customer-categories"/);
281
+ assert.match(
282
+ replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__,
283
+ /formControl: "autocomplete" \/\/ or "select"/
284
+ );
285
+ assert.match(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, /table\.foreign\(\["vet_id"\]/);
229
286
  });
230
287
 
231
288
  test("renderMigrationColumnLine ignores SQL NULL string defaults", () => {
@@ -254,3 +311,140 @@ test("renderMigrationColumnLine ignores SQL NULL string defaults", () => {
254
311
 
255
312
  assert.equal(line.includes(".defaultTo("), false);
256
313
  });
314
+
315
+ test("buildReplacementsFromSnapshot normalizes nullable temporal inputs without invalid date errors", () => {
316
+ const snapshot = createSnapshot({
317
+ hasWorkspaceOwnerColumn: false,
318
+ hasUserOwnerColumn: false
319
+ });
320
+ const temporalColumns = [
321
+ ...snapshot.columns.filter((column) => column.key !== "updatedAt"),
322
+ Object.freeze({
323
+ name: "scheduled_at",
324
+ key: "scheduledAt",
325
+ dataType: "datetime",
326
+ columnType: "datetime",
327
+ typeKind: "datetime",
328
+ nullable: true,
329
+ hasDefault: false,
330
+ defaultValue: null,
331
+ autoIncrement: false,
332
+ unsigned: false,
333
+ extra: "",
334
+ maxLength: null,
335
+ numericPrecision: null,
336
+ numericScale: null,
337
+ enumValues: Object.freeze([])
338
+ }),
339
+ Object.freeze({
340
+ name: "birth_date",
341
+ key: "birthDate",
342
+ dataType: "date",
343
+ columnType: "date",
344
+ typeKind: "date",
345
+ nullable: true,
346
+ hasDefault: false,
347
+ defaultValue: null,
348
+ autoIncrement: false,
349
+ unsigned: false,
350
+ extra: "",
351
+ maxLength: null,
352
+ numericPrecision: null,
353
+ numericScale: null,
354
+ enumValues: Object.freeze([])
355
+ }),
356
+ Object.freeze({
357
+ name: "preferred_time",
358
+ key: "preferredTime",
359
+ dataType: "time",
360
+ columnType: "time",
361
+ typeKind: "time",
362
+ nullable: true,
363
+ hasDefault: false,
364
+ defaultValue: null,
365
+ autoIncrement: false,
366
+ unsigned: false,
367
+ extra: "",
368
+ maxLength: null,
369
+ numericPrecision: null,
370
+ numericScale: null,
371
+ enumValues: Object.freeze([])
372
+ })
373
+ ];
374
+
375
+ const replacements = __testables.buildReplacementsFromSnapshot({
376
+ namespace: "contacts",
377
+ snapshot: {
378
+ ...snapshot,
379
+ columns: Object.freeze(temporalColumns)
380
+ },
381
+ resolvedOwnershipFilter: "public"
382
+ });
383
+
384
+ assert.match(
385
+ replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
386
+ /normalizeIfInSource\(source, normalized, "scheduledAt", \(value\) => \{ const normalized = normalizeText\(value\); return normalized \? toDatabaseDateTimeUtc\(normalized\) : null; \}\);/
387
+ );
388
+ assert.match(
389
+ replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
390
+ /normalizeIfInSource\(source, normalized, "birthDate", \(value\) => \{ const normalized = normalizeText\(value\); return normalized \? toIsoString\(normalized\)\.slice\(0, 10\) : null; \}\);/
391
+ );
392
+ assert.match(
393
+ replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
394
+ /normalizeIfInSource\(source, normalized, "preferredTime", \(value\) => \{ const normalized = normalizeText\(value\); return normalized \|\| null; \}\);/
395
+ );
396
+ });
397
+
398
+ test("crud repository template defines explicit one-line CRUD methods over repository primitives", async () => {
399
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
400
+ const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "repository.js");
401
+ const templateSource = await readFile(templatePath, "utf8");
402
+ assert.match(
403
+ templateSource,
404
+ /from "@jskit-ai\/crud-core\/server\/repositoryMethods";/
405
+ );
406
+ assert.match(templateSource, /const repositoryRuntime = createCrudRepositoryRuntime\(/);
407
+ assert.match(templateSource, /return crudRepositoryList\(repositoryRuntime, knex, query, options, callOptions\);/);
408
+ assert.match(templateSource, /return crudRepositoryFindById\(repositoryRuntime, knex, recordId, options, callOptions\);/);
409
+ assert.match(templateSource, /return crudRepositoryListByIds\(repositoryRuntime, knex, ids, options, callOptions\);/);
410
+ assert.match(templateSource, /return crudRepositoryCreate\(repositoryRuntime, knex, payload, options, callOptions\);/);
411
+ assert.match(templateSource, /return crudRepositoryUpdateById\(repositoryRuntime, knex, recordId, patch, options, callOptions\);/);
412
+ assert.match(templateSource, /return crudRepositoryDeleteById\(repositoryRuntime, knex, recordId, options, callOptions\);/);
413
+ });
414
+
415
+ test("crud service template defines explicit service methods and semi-explicit default events", async () => {
416
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
417
+ const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "service.js");
418
+ const templateSource = await readFile(templatePath, "utf8");
419
+
420
+ assert.match(
421
+ templateSource,
422
+ /from "@jskit-ai\/crud-core\/server\/serviceEvents";/
423
+ );
424
+ assert.match(
425
+ templateSource,
426
+ /from "@jskit-ai\/crud-core\/server\/fieldAccess";/
427
+ );
428
+ assert.match(templateSource, /const baseServiceEvents = createCrudServiceEvents\(/);
429
+ assert.match(templateSource, /const fieldAccessRuntime = createCrudFieldAccessRuntime\(/);
430
+ assert.match(templateSource, /const serviceEvents = Object\.freeze\(\{/);
431
+ assert.match(templateSource, /createRecord: \[\.\.\.baseServiceEvents\.createRecord\],/);
432
+ 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\."\);/);
436
+ });
437
+
438
+ test("crud provider template uses shared lookup provider helpers instead of inline wiring", async () => {
439
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
440
+ const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "CrudProvider.js");
441
+ const templateSource = await readFile(templatePath, "utf8");
442
+
443
+ assert.match(
444
+ templateSource,
445
+ /from "@jskit-ai\/crud-core\/server\/lookupProviders";/
446
+ );
447
+ assert.match(templateSource, /resolveLookupProvider: createCrudLookupProviderResolver\(scope\)/);
448
+ assert.match(templateSource, /return createCrudLookupProvider\(scope\.make\("repository\.\$\{option:namespace\|snake\}"\)\);/);
449
+ assert.doesNotMatch(templateSource, /normalizePathname\(relation\.apiPath\)/);
450
+ });
@@ -39,3 +39,9 @@ test("crudResource normalizes list output", () => {
39
39
  assert.match(normalized.items[0].updatedAt, /T/);
40
40
  assert.equal(normalized.nextCursor, "8");
41
41
  });
42
+
43
+ test("crudResource list operation exposes output validator only", () => {
44
+ assert.equal(typeof crudResource.operations.list.outputValidator?.normalize, "function");
45
+ assert.equal(crudResource.operations.list.inputValidator, undefined);
46
+ assert.deepEqual(crudResource.operations.list.realtime?.events, ["crud.record.changed"]);
47
+ });
@@ -1,61 +1,55 @@
1
- import test from "node:test";
1
+ import test, { after } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { createActions } from "../src/server/actions.js";
4
- import { createActionIds } from "../src/server/actionIds.js";
5
- import { createRepository } from "../src/server/repository.js";
6
- import { registerRoutes } from "../src/server/registerRoutes.js";
3
+ import { createTemplateServerFixture } from "../test-support/templateServerFixture.js";
7
4
 
8
- test("createActionIds requires explicit actionIdPrefix", () => {
9
- assert.throws(
10
- () => createActionIds(""),
11
- /requires actionIdPrefix/
12
- );
13
- });
14
-
15
- test("createRepository requires explicit tableName", () => {
16
- const knex = () => {
17
- throw new Error("not expected");
18
- };
5
+ const fixture = await createTemplateServerFixture();
6
+ const { createActions } = await fixture.importServerModule("actions.js");
7
+ const { createRepository } = await fixture.importServerModule("repository.js");
19
8
 
20
- assert.throws(
21
- () => createRepository(knex, {}),
22
- /requires tableName/
23
- );
9
+ after(async () => {
10
+ await fixture.cleanup();
24
11
  });
25
12
 
26
- test("createActions requires explicit surface", () => {
27
- assert.throws(
28
- () =>
29
- createActions({
30
- actionIdPrefix: "crud.customers"
31
- }),
32
- /requires a non-empty surface/
33
- );
34
- });
35
-
36
- test("registerRoutes requires explicit routeRelativePath and actionIds", () => {
37
- const app = {
38
- make() {
39
- return {
40
- register() {}
41
- };
13
+ test("template createRepository defaults tableName from resource metadata", () => {
14
+ const query = {
15
+ select() {
16
+ return query;
17
+ },
18
+ where() {
19
+ return query;
20
+ },
21
+ orderBy() {
22
+ return query;
23
+ },
24
+ modify(callback) {
25
+ if (typeof callback === "function") {
26
+ callback(query);
27
+ }
28
+ return query;
29
+ },
30
+ limit() {
31
+ return query;
32
+ },
33
+ then(resolve) {
34
+ return Promise.resolve([]).then(resolve);
42
35
  }
43
36
  };
37
+ const tables = [];
38
+ const knex = (tableName) => {
39
+ tables.push(tableName);
40
+ return query;
41
+ };
44
42
 
45
- assert.throws(
46
- () => registerRoutes(app, {}),
47
- /requires routeRelativePath/
48
- );
43
+ const repository = createRepository(knex, {});
44
+ assert.equal(typeof repository.list, "function");
45
+ return repository.list({}).then(() => {
46
+ assert.equal(tables[0], "customers");
47
+ });
48
+ });
49
49
 
50
+ test("template createActions requires explicit surface", () => {
50
51
  assert.throws(
51
- () =>
52
- registerRoutes(app, {
53
- routeRelativePath: "/customers",
54
- routeSurfaceRequiresWorkspace: true,
55
- actionIds: {
56
- list: "crud.customers.list"
57
- }
58
- }),
59
- /requires actionIds.view/
52
+ () => createActions({}),
53
+ /requires a non-empty surface/
60
54
  );
61
55
  });
@@ -1,10 +1,17 @@
1
- import test from "node:test";
1
+ import test, { after } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { createService } from "../src/server/service.js";
3
+ import { createTemplateServerFixture } from "../test-support/templateServerFixture.js";
4
+
5
+ const fixture = await createTemplateServerFixture();
6
+ const { createService, serviceEvents } = await fixture.importServerModule("service.js");
7
+
8
+ after(async () => {
9
+ await fixture.cleanup();
10
+ });
4
11
 
5
12
  test("crudService delegates CRUD operations to the repository", async () => {
6
13
  const calls = [];
7
- const crudRepository = {
14
+ const customersRepository = {
8
15
  async list(query) {
9
16
  calls.push(["list", query]);
10
17
  return { items: [], nextCursor: null };
@@ -27,7 +34,7 @@ test("crudService delegates CRUD operations to the repository", async () => {
27
34
  }
28
35
  };
29
36
 
30
- const service = createService({ crudRepository });
37
+ const service = createService({ customersRepository });
31
38
 
32
39
  const options = {};
33
40
  await service.listRecords({ limit: 10 }, options);
@@ -47,7 +54,7 @@ test("crudService delegates CRUD operations to the repository", async () => {
47
54
 
48
55
  test("crudService throws 404 when a record is missing", async () => {
49
56
  const service = createService({
50
- crudRepository: {
57
+ customersRepository: {
51
58
  async list() {
52
59
  return { items: [], nextCursor: null };
53
60
  },
@@ -81,3 +88,84 @@ test("crudService throws 404 when a record is missing", async () => {
81
88
  (error) => error?.status === 404 && error?.message === "Record not found."
82
89
  );
83
90
  });
91
+
92
+ test("crudService exports default realtime events for create/update/delete", () => {
93
+ assert.equal(serviceEvents.createRecord[0].realtime.event, "customers.record.changed");
94
+ assert.equal(serviceEvents.updateRecord[0].realtime.event, "customers.record.changed");
95
+ assert.equal(serviceEvents.deleteRecord[0].realtime.event, "customers.record.changed");
96
+ });
97
+
98
+ test("crudService supports optional fieldAccess hooks for writable filtering", async () => {
99
+ const calls = [];
100
+ const service = createService({
101
+ customersRepository: {
102
+ async list() {
103
+ return {
104
+ items: [
105
+ {
106
+ id: 1,
107
+ textField: "A",
108
+ dateField: "2026-03-11T00:00:00.000Z",
109
+ numberField: 1
110
+ }
111
+ ],
112
+ nextCursor: null
113
+ };
114
+ },
115
+ async findById() {
116
+ return {
117
+ id: 1,
118
+ textField: "A",
119
+ dateField: "2026-03-11T00:00:00.000Z",
120
+ numberField: 1
121
+ };
122
+ },
123
+ async create(payload) {
124
+ calls.push(payload);
125
+ return {
126
+ id: 1,
127
+ textField: payload.textField || "",
128
+ dateField: "2026-03-11T00:00:00.000Z",
129
+ numberField: payload.numberField ?? 0
130
+ };
131
+ },
132
+ async updateById(recordId, payload) {
133
+ calls.push([recordId, payload]);
134
+ return {
135
+ id: recordId,
136
+ textField: payload.textField || "",
137
+ dateField: "2026-03-11T00:00:00.000Z",
138
+ numberField: payload.numberField ?? 0
139
+ };
140
+ },
141
+ async deleteById(recordId) {
142
+ return { id: recordId, deleted: true };
143
+ }
144
+ },
145
+ fieldAccess: {
146
+ writable: () => ["textField"],
147
+ writeMode: "strip"
148
+ }
149
+ });
150
+
151
+ await service.createRecord(
152
+ {
153
+ textField: "Allowed",
154
+ numberField: 99
155
+ },
156
+ {}
157
+ );
158
+ await service.updateRecord(
159
+ 2,
160
+ {
161
+ textField: "Updated",
162
+ numberField: 88
163
+ },
164
+ {}
165
+ );
166
+
167
+ assert.deepEqual(calls, [
168
+ { textField: "Allowed" },
169
+ [2, { textField: "Updated" }]
170
+ ]);
171
+ });