@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.
- package/package.descriptor.mjs +19 -14
- package/package.json +9 -7
- package/src/server/{CrudServiceProvider.js → CrudProvider.js} +2 -2
- package/src/server/buildTemplateContext.js +278 -32
- package/src/server/subcommands/addField.js +238 -0
- package/src/server/subcommands/resourceAst.js +632 -0
- package/src/shared/crud/crudResource.js +93 -98
- package/templates/migrations/crud_initial.cjs +1 -0
- package/templates/src/local-package/package.descriptor.mjs +2 -2
- package/templates/src/local-package/server/{CrudServiceProvider.js → CrudProvider.js} +13 -8
- package/templates/src/local-package/server/actions.js +24 -10
- package/templates/src/local-package/server/registerRoutes.js +32 -33
- package/templates/src/local-package/server/repository.js +33 -132
- package/templates/src/local-package/server/service.js +88 -47
- package/templates/src/local-package/shared/crudResource.js +77 -45
- package/test/addFieldSubcommand.test.js +167 -0
- package/test/buildTemplateContext.test.js +198 -4
- package/test/crudResource.test.js +6 -0
- package/test/crudServerGuards.test.js +43 -49
- package/test/crudService.test.js +93 -5
- package/test/routeInputContracts.test.js +144 -41
- package/test-support/templateServerFixture.js +169 -0
- package/src/server/actionIds.js +0 -22
- package/src/server/actions.js +0 -152
- package/src/server/registerRoutes.js +0 -234
- package/src/server/repository.js +0 -162
- package/src/server/service.js +0 -96
|
@@ -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.
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
/requires tableName/
|
|
23
|
-
);
|
|
9
|
+
after(async () => {
|
|
10
|
+
await fixture.cleanup();
|
|
24
11
|
});
|
|
25
12
|
|
|
26
|
-
test("
|
|
27
|
-
|
|
28
|
-
()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
});
|
package/test/crudService.test.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
+
});
|