@jskit-ai/crud-server-generator 0.1.63 → 0.1.64

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,6 +1,5 @@
1
1
  import test, { after } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { recordIdParamsValidator } from "@jskit-ai/kernel/shared/validators";
4
3
  import { createTemplateServerFixture } from "../test-support/templateServerFixture.js";
5
4
 
6
5
  const fixture = await createTemplateServerFixture();
@@ -10,6 +9,7 @@ const nonWorkspaceFixture = await createTemplateServerFixture({
10
9
  });
11
10
  const { createActions } = await fixture.importServerModule("actions.js");
12
11
  const { createRepository } = await fixture.importServerModule("repository.js");
12
+ const { createService } = await fixture.importServerModule("service.js");
13
13
  const { createActions: createNonWorkspaceActions } = await nonWorkspaceFixture.importServerModule("actions.js");
14
14
 
15
15
  after(async () => {
@@ -17,47 +17,125 @@ after(async () => {
17
17
  await nonWorkspaceFixture.cleanup();
18
18
  });
19
19
 
20
- test("template createRepository defaults tableName from resource metadata", () => {
21
- const query = {
22
- select() {
23
- return query;
24
- },
25
- where() {
26
- return query;
27
- },
28
- orderBy() {
29
- return query;
30
- },
31
- modify(callback) {
32
- if (typeof callback === "function") {
33
- callback(query);
20
+ test("template createRepository passes a mutable JSKIT context into json-rest-api", async () => {
21
+ const calls = [];
22
+ const api = {
23
+ resources: {
24
+ customers: {
25
+ async query(params, context) {
26
+ context.method = "query";
27
+ calls.push({ params, context });
28
+ return { data: [] };
29
+ }
34
30
  }
35
- return query;
31
+ }
32
+ };
33
+ const knex = {
34
+ async transaction(work) {
35
+ return work("trx");
36
+ }
37
+ };
38
+ const sourceContext = Object.freeze({
39
+ visibilityContext: Object.freeze({
40
+ visibility: "workspace",
41
+ scopeOwnerId: "7"
42
+ })
43
+ });
44
+
45
+ const repository = createRepository({ api, knex });
46
+ assert.equal(typeof repository.queryDocuments, "function");
47
+ await repository.queryDocuments(
48
+ {
49
+ q: "Merc",
50
+ cursor: "cursor_2",
51
+ limit: 10,
52
+ include: "workspace"
36
53
  },
37
- limit() {
38
- return query;
54
+ {
55
+ context: sourceContext
56
+ }
57
+ );
58
+
59
+ assert.deepEqual(calls[0].params, {
60
+ queryParams: {
61
+ filters: {
62
+ q: "Merc"
63
+ },
64
+ include: ["workspace"],
65
+ page: {
66
+ after: "cursor_2",
67
+ size: "10"
68
+ }
39
69
  },
40
- then(resolve) {
41
- return Promise.resolve([]).then(resolve);
70
+ transaction: null,
71
+ simplified: false
72
+ });
73
+ assert.notEqual(
74
+ calls[0].context,
75
+ sourceContext
76
+ );
77
+ assert.deepEqual(calls[0].context, {
78
+ method: "query",
79
+ visibilityContext: {
80
+ visibility: "workspace",
81
+ scopeOwnerId: "7"
82
+ }
83
+ });
84
+ });
85
+
86
+ test("template createRepository builds mutable JSON:API input documents for writes", async () => {
87
+ const calls = [];
88
+ const api = {
89
+ resources: {
90
+ customers: {
91
+ async post(params) {
92
+ calls.push(params);
93
+ return { data: { type: "customers", id: "1", attributes: { name: "Merc" } } };
94
+ }
95
+ }
42
96
  }
43
97
  };
44
- const tables = [];
45
- const knex = (tableName) => {
46
- tables.push(tableName);
47
- return query;
98
+ const knex = {
99
+ async transaction(work) {
100
+ return work("trx");
101
+ }
48
102
  };
49
103
 
50
- const repository = createRepository(knex, {});
51
- assert.equal(typeof repository.list, "function");
52
- return repository.list({}).then(() => {
53
- assert.equal(tables[0], "customers");
104
+ const repository = createRepository({ api, knex });
105
+ await repository.createDocument({ name: "Merc" }, {});
106
+
107
+ assert.equal(Object.isFrozen(calls[0].inputRecord), false);
108
+ assert.equal(Object.isFrozen(calls[0].inputRecord.data), false);
109
+ assert.equal(Object.isFrozen(calls[0].inputRecord.data.attributes), false);
110
+ assert.deepEqual(calls[0].inputRecord, {
111
+ data: {
112
+ type: "customers",
113
+ attributes: {
114
+ name: "Merc"
115
+ }
116
+ }
54
117
  });
55
118
  });
56
119
 
57
- test("template createActions requires explicit surface", () => {
58
- assert.throws(
59
- () => createActions({}),
60
- /requires a non-empty surface/
120
+ test("template createService turns missing resource records into 404 errors", async () => {
121
+ const service = createService({
122
+ customersRepository: {
123
+ async getDocumentById() {
124
+ return null;
125
+ },
126
+ async patchDocumentById() {
127
+ return null;
128
+ }
129
+ }
130
+ });
131
+
132
+ await assert.rejects(
133
+ () => service.getDocumentById("7", {}),
134
+ (error) => error?.status === 404 && error?.message === "Document not found."
135
+ );
136
+ await assert.rejects(
137
+ () => service.patchDocumentById("7", { name: "Merc" }, {}),
138
+ (error) => error?.status === 404 && error?.message === "Document not found."
61
139
  );
62
140
  });
63
141
 
@@ -76,18 +154,55 @@ test("template createActions requires namespaced CRUD permissions by default", (
76
154
  );
77
155
  });
78
156
 
157
+ test("template list action strips workspaceSlug before calling the service", async () => {
158
+ const actions = createActions({ surface: "admin" });
159
+ const listAction = actions.find((action) => action.id === "crud.customers.list");
160
+ const calls = [];
161
+
162
+ await listAction.execute(
163
+ {
164
+ workspaceSlug: "acme",
165
+ q: "Merc",
166
+ include: "workspace"
167
+ },
168
+ { visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } },
169
+ {
170
+ customersService: {
171
+ async queryDocuments(query, options) {
172
+ calls.push({ query, options });
173
+ return { kind: "document", value: { data: [] } };
174
+ }
175
+ }
176
+ }
177
+ );
178
+
179
+ assert.deepEqual(calls[0].query, {
180
+ q: "Merc",
181
+ include: "workspace"
182
+ });
183
+ assert.deepEqual(calls[0].options, {
184
+ context: {
185
+ visibilityContext: {
186
+ visibility: "workspace",
187
+ scopeOwnerId: "7"
188
+ }
189
+ }
190
+ });
191
+ });
192
+
79
193
  test("template createActions omits workspace validators for non-workspace generation", () => {
80
194
  const actions = createNonWorkspaceActions({ surface: "home" });
81
195
 
82
- assert.equal(Array.isArray(actions[0].inputValidator), true);
83
- assert.equal(actions[0].inputValidator.length, 4);
84
- assert.equal(Array.isArray(actions[1].inputValidator), true);
85
- assert.equal(actions[1].inputValidator.length, 2);
86
- assert.equal(actions[1].inputValidator[0], recordIdParamsValidator);
87
- assert.deepEqual(Object.keys(actions[2].inputValidator), ["payload"]);
88
- assert.equal(Array.isArray(actions[3].inputValidator), true);
89
- assert.equal(actions[3].inputValidator.length, 2);
90
- assert.equal(actions[3].inputValidator[0], recordIdParamsValidator);
91
- assert.equal(actions[4].inputValidator, recordIdParamsValidator);
196
+ assert.equal(Array.isArray(actions[0].input), false);
197
+ assert.deepEqual(Object.keys(actions[0].input.schema.getFieldDefinitions()).sort(), ["contactId", "cursor", "include", "limit", "q"]);
198
+ assert.equal(Array.isArray(actions[1].input), false);
199
+ assert.deepEqual(Object.keys(actions[1].input.schema.getFieldDefinitions()).sort(), ["include", "recordId"]);
200
+ assert.equal(Array.isArray(actions[2].input), false);
201
+ assert.deepEqual(Object.keys(actions[2].input.schema.getFieldDefinitions()).sort(), ["contactId", "name"]);
202
+ assert.equal(actions[2].input.mode, "create");
203
+ assert.equal(Array.isArray(actions[3].input), false);
204
+ assert.deepEqual(Object.keys(actions[3].input.schema.getFieldDefinitions()).sort(), ["contactId", "name", "recordId"]);
205
+ assert.equal(Array.isArray(actions[4].input), false);
206
+ assert.deepEqual(Object.keys(actions[4].input.schema.getFieldDefinitions()), ["recordId"]);
92
207
  assert.equal(actions[0].permission.require, "authenticated");
93
208
  });
@@ -3,219 +3,137 @@ import assert from "node:assert/strict";
3
3
  import { createTemplateServerFixture } from "../test-support/templateServerFixture.js";
4
4
 
5
5
  const fixture = await createTemplateServerFixture();
6
- const { createService, serviceEvents } = await fixture.importServerModule("service.js");
6
+ const { createService } = await fixture.importServerModule("service.js");
7
7
 
8
8
  after(async () => {
9
9
  await fixture.cleanup();
10
10
  });
11
11
 
12
- test("crudService delegates CRUD operations to the repository", async () => {
12
+ test("crudService exposes the explicit JSON:API CRUD service contract", async () => {
13
13
  const calls = [];
14
14
  const customersRepository = {
15
- async list(query) {
16
- calls.push(["list", query]);
17
- return { items: [], nextCursor: null };
15
+ async queryDocuments(query, options) {
16
+ calls.push(["queryDocuments", query, options]);
17
+ return { data: [] };
18
18
  },
19
- async findById(recordId) {
20
- calls.push(["findById", recordId]);
21
- return { id: recordId, textField: "Example", dateField: "2026-03-11T00:00:00.000Z", numberField: 3 };
19
+ async getDocumentById(recordId, options) {
20
+ calls.push(["getDocumentById", recordId, options]);
21
+ return { data: { id: String(recordId) } };
22
22
  },
23
- async create(payload) {
24
- calls.push(["create", payload]);
25
- return { id: 1, ...payload };
23
+ async createDocument(payload, options) {
24
+ calls.push(["createDocument", payload, options]);
25
+ return { data: { id: "1", attributes: payload } };
26
26
  },
27
- async updateById(recordId, payload) {
28
- calls.push(["updateById", recordId, payload]);
29
- return { id: recordId, ...payload };
27
+ async patchDocumentById(recordId, payload, options) {
28
+ calls.push(["patchDocumentById", recordId, payload, options]);
29
+ return { data: { id: String(recordId), attributes: payload } };
30
30
  },
31
- async deleteById(recordId) {
32
- calls.push(["deleteById", recordId]);
33
- return { id: recordId, deleted: true };
31
+ async deleteDocumentById(recordId, options) {
32
+ calls.push(["deleteDocumentById", recordId, options]);
33
+ return true;
34
34
  }
35
35
  };
36
36
 
37
37
  const service = createService({ customersRepository });
38
38
 
39
- const options = {};
40
- await service.listRecords({ limit: 10 }, options);
41
- await service.getRecord(3, options);
42
- await service.createRecord({ textField: "Example", dateField: "2026-03-11", numberField: 3 }, options);
43
- await service.updateRecord(4, { textField: "Changed" }, options);
44
- await service.deleteRecord(5, options);
39
+ assert.deepEqual(Object.keys(service), [
40
+ "queryDocuments",
41
+ "getDocumentById",
42
+ "createDocument",
43
+ "patchDocumentById",
44
+ "deleteDocumentById"
45
+ ]);
46
+ assert.equal(Object.isFrozen(service), true);
47
+
48
+ const options = {
49
+ trx: "trx-1",
50
+ context: { visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } },
51
+ include: ["workspace"]
52
+ };
53
+ const listResult = await service.queryDocuments({ limit: 10 }, options);
54
+ const recordResult = await service.getDocumentById(3, options);
55
+ const createResult = await service.createDocument({ textField: "Example", dateField: "2026-03-11", numberField: 3 }, options);
56
+ const updateResult = await service.patchDocumentById(4, { textField: "Changed" }, options);
57
+ const deleteResult = await service.deleteDocumentById(5, options);
45
58
 
46
59
  assert.deepEqual(calls, [
47
- ["list", { limit: 10 }],
48
- ["findById", 3],
49
- ["create", { textField: "Example", dateField: "2026-03-11", numberField: 3 }],
50
- ["findById", 4],
51
- ["updateById", 4, { textField: "Changed" }],
52
- ["deleteById", 5]
60
+ ["queryDocuments", { limit: 10 }, { trx: "trx-1", context: { visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } } }],
61
+ ["getDocumentById", 3, { trx: "trx-1", context: { visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } }, include: ["workspace"] }],
62
+ ["createDocument", { textField: "Example", dateField: "2026-03-11", numberField: 3 }, { trx: "trx-1", context: { visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } } }],
63
+ ["patchDocumentById", 4, { textField: "Changed" }, { trx: "trx-1", context: { visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } } }],
64
+ ["deleteDocumentById", 5, { trx: "trx-1", context: { visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } } }]
53
65
  ]);
66
+ assert.equal(listResult.__jskitJsonApiResult, true);
67
+ assert.equal(listResult.kind, "document");
68
+ assert.deepEqual(listResult.value, { data: [] });
69
+ assert.equal(recordResult.__jskitJsonApiResult, true);
70
+ assert.equal(recordResult.kind, "document");
71
+ assert.deepEqual(recordResult.value, { data: { id: "3" } });
72
+ assert.equal(createResult.__jskitJsonApiResult, true);
73
+ assert.equal(createResult.kind, "document");
74
+ assert.deepEqual(createResult.value, {
75
+ data: {
76
+ id: "1",
77
+ attributes: {
78
+ textField: "Example",
79
+ dateField: "2026-03-11",
80
+ numberField: 3
81
+ }
82
+ }
83
+ });
84
+ assert.equal(updateResult.__jskitJsonApiResult, true);
85
+ assert.equal(updateResult.kind, "document");
86
+ assert.deepEqual(updateResult.value, {
87
+ data: {
88
+ id: "4",
89
+ attributes: {
90
+ textField: "Changed"
91
+ }
92
+ }
93
+ });
94
+ assert.equal(deleteResult, null);
54
95
  });
55
96
 
56
- test("crudService throws 404 when a record is missing", async () => {
97
+ test("crudService throws immediately when the repository dependency is missing", () => {
98
+ assert.throws(
99
+ () => createService({}),
100
+ /createService requires customersRepository\./
101
+ );
102
+ });
103
+
104
+ test("crudService throws 404 when a document is missing", async () => {
57
105
  const service = createService({
58
106
  customersRepository: {
59
- async list() {
60
- return { items: [], nextCursor: null };
107
+ async queryDocuments() {
108
+ return { data: [] };
61
109
  },
62
- async findById() {
110
+ async getDocumentById() {
63
111
  return null;
64
112
  },
65
- async create(payload) {
66
- return { id: 1, ...payload };
113
+ async createDocument(payload) {
114
+ return { data: { id: "1", attributes: payload } };
67
115
  },
68
- async updateById() {
116
+ async patchDocumentById() {
69
117
  return null;
70
118
  },
71
- async deleteById() {
119
+ async deleteDocumentById() {
72
120
  return null;
73
121
  }
74
122
  }
75
123
  });
76
124
 
77
125
  await assert.rejects(
78
- () => service.getRecord(9, {}),
79
- (error) => error?.status === 404 && error?.message === "Record not found."
126
+ () => service.getDocumentById(9, {}),
127
+ (error) => error?.status === 404 && error?.message === "Document not found."
80
128
  );
81
129
 
82
130
  await assert.rejects(
83
- () => service.updateRecord(9, { textField: "Changed" }, {}),
84
- (error) => error?.status === 404 && error?.message === "Record not found."
131
+ () => service.patchDocumentById(9, { textField: "Changed" }, {}),
132
+ (error) => error?.status === 404 && error?.message === "Document not found."
85
133
  );
86
134
 
87
135
  await assert.rejects(
88
- () => service.deleteRecord(9, {}),
89
- (error) => error?.status === 404 && error?.message === "Record not found."
90
- );
91
- });
92
-
93
- test("crudService exports default realtime events for create/update/delete", () => {
94
- assert.equal(serviceEvents.createRecord[0].realtime.event, "customers.record.changed");
95
- assert.equal(serviceEvents.updateRecord[0].realtime.event, "customers.record.changed");
96
- assert.equal(serviceEvents.deleteRecord[0].realtime.event, "customers.record.changed");
97
- });
98
-
99
- test("crudService passes existing records into repository update options 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, options = {}) {
119
- calls.push(["updateById", recordId, payload, options]);
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
- existingRecord: {
139
- id: 4,
140
- textField: "Existing",
141
- dateField: "2026-03-11T00:00:00.000Z",
142
- numberField: 3
143
- }
144
- }]
145
- ]);
146
- });
147
-
148
- test("crudService supports optional fieldAccess hooks for writable filtering", async () => {
149
- const calls = [];
150
- const service = createService({
151
- customersRepository: {
152
- async list() {
153
- return {
154
- items: [
155
- {
156
- id: 1,
157
- textField: "A",
158
- dateField: "2026-03-11T00:00:00.000Z",
159
- numberField: 1
160
- }
161
- ],
162
- nextCursor: null
163
- };
164
- },
165
- async findById() {
166
- return {
167
- id: 1,
168
- textField: "A",
169
- dateField: "2026-03-11T00:00:00.000Z",
170
- numberField: 1
171
- };
172
- },
173
- async create(payload) {
174
- calls.push(payload);
175
- return {
176
- id: 1,
177
- textField: payload.textField || "",
178
- dateField: "2026-03-11T00:00:00.000Z",
179
- numberField: payload.numberField ?? 0
180
- };
181
- },
182
- async updateById(recordId, payload) {
183
- calls.push([recordId, payload]);
184
- return {
185
- id: recordId,
186
- textField: payload.textField || "",
187
- dateField: "2026-03-11T00:00:00.000Z",
188
- numberField: payload.numberField ?? 0
189
- };
190
- },
191
- async deleteById(recordId) {
192
- return { id: recordId, deleted: true };
193
- }
194
- },
195
- fieldAccess: {
196
- writable: () => ["textField"],
197
- writeMode: "strip"
198
- }
199
- });
200
-
201
- await service.createRecord(
202
- {
203
- textField: "Allowed",
204
- numberField: 99
205
- },
206
- {}
207
- );
208
- await service.updateRecord(
209
- 2,
210
- {
211
- textField: "Updated",
212
- numberField: 88
213
- },
214
- {}
136
+ () => service.deleteDocumentById(9, {}),
137
+ (error) => error?.status === 404 && error?.message === "Document not found."
215
138
  );
216
-
217
- assert.deepEqual(calls, [
218
- { textField: "Allowed" },
219
- [2, { textField: "Updated" }]
220
- ]);
221
139
  });
@@ -21,19 +21,11 @@ test("crud-server-generator surface option validates against enabled surface ids
21
21
  assert.equal(descriptor.metadata?.generatorSubcommands?.scaffold?.createTarget?.pathTemplate, "packages/${option:namespace|kebab}");
22
22
  });
23
23
 
24
- test("crud-server-generator installs listConfig alongside server templates", () => {
24
+ test("crud-server-generator no longer installs a separate jsonRestResource server template", () => {
25
25
  const files = descriptor.mutations?.files || [];
26
- const listConfigTemplate = files.find((entry) => entry.from === "templates/src/local-package/server/listConfig.js");
26
+ const jsonRestResourceTemplate = files.find((entry) => entry.from === "templates/src/local-package/server/jsonRestResource.js");
27
27
 
28
- assert.ok(listConfigTemplate);
29
- assert.equal(
30
- listConfigTemplate.to,
31
- "packages/${option:namespace|kebab}/src/server/listConfig.js"
32
- );
33
- assert.deepEqual(listConfigTemplate.templateContext, {
34
- entrypoint: "src/server/buildTemplateContext.js",
35
- export: "buildTemplateContext"
36
- });
28
+ assert.equal(jsonRestResourceTemplate, undefined);
37
29
  });
38
30
 
39
31
  test("crud-server-generator wires action and role mutations through template context", () => {