@jskit-ai/crud-core 0.1.196 → 0.1.198

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.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.196",
3
+ "version": "0.1.198",
4
4
  "type": "module",
5
+ "engines": {
6
+ "node": ">=24.0.0"
7
+ },
5
8
  "scripts": {
6
9
  "test": "node --test"
7
10
  },
@@ -23,10 +26,11 @@
23
26
  "./server/routeContracts": "./src/server/routeContracts.js"
24
27
  },
25
28
  "dependencies": {
26
- "@jskit-ai/database-runtime": "0.1.185",
27
- "@jskit-ai/json-rest-api-core": "0.1.129",
28
- "@jskit-ai/resource-crud-core": "0.1.127",
29
- "json-rest-schema": "^1.0.17"
29
+ "@jskit-ai/database-runtime": "0.1.187",
30
+ "@jskit-ai/json-rest-api-core": "0.1.131",
31
+ "@jskit-ai/resource-crud-core": "0.1.129",
32
+ "json-rest-schema": "^1.0.17",
33
+ "json-rest-api": "2.0.0"
30
34
  },
31
35
  "description": "Shared server-side CRUD service, repository, route, and query helpers.",
32
36
  "jskit": {
@@ -45,7 +49,7 @@
45
49
  }
46
50
  },
47
51
  "peerDependencies": {
48
- "@jskit-ai/http-runtime": "0.1.183",
49
- "@jskit-ai/kernel": "0.1.185"
52
+ "@jskit-ai/http-runtime": "0.1.185",
53
+ "@jskit-ai/kernel": "0.1.187"
50
54
  }
51
55
  }
@@ -8,8 +8,8 @@
8
8
  "./shared": "./src/shared/index.js"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/crud-core": "0.1.196",
12
- "@jskit-ai/resource-crud-core": "0.1.127"
11
+ "@jskit-ai/crud-core": "0.1.198",
12
+ "@jskit-ai/resource-crud-core": "0.1.129"
13
13
  },
14
14
  "jskit": {
15
15
  "kind": "runtime",
@@ -233,7 +233,6 @@ function defineCrudJsonApiFeature({
233
233
  );
234
234
  const defaultRepository = createCrudJsonApiRepository({
235
235
  api: jsonRestApi,
236
- knex: database.knex,
237
236
  resource,
238
237
  resourceScopeName
239
238
  });
@@ -3,6 +3,7 @@ import {
3
3
  createSchema,
4
4
  recordIdParamsValidator
5
5
  } from "@jskit-ai/kernel/shared/validators";
6
+ import { RestApiWriteError } from "json-rest-api";
6
7
  import { normalizeJsonApiFieldsets } from "@jskit-ai/kernel/shared/support/jsonApiFieldsets";
7
8
  import { createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
8
9
  import {
@@ -618,15 +619,22 @@ function createCrudJsonApiActions({
618
619
  }
619
620
  const result = mutation ? await repository.withTransaction(run) : await run();
620
621
  if (hooks.afterCommit) {
621
- await hooks.afterCommit(createLifecycleContext({
622
- operation,
623
- input: value,
624
- context,
625
- resource,
626
- repository,
627
- service,
628
- result
629
- }));
622
+ try {
623
+ await hooks.afterCommit(createLifecycleContext({
624
+ operation,
625
+ input: value,
626
+ context,
627
+ resource,
628
+ repository,
629
+ service,
630
+ result
631
+ }));
632
+ } catch (error) {
633
+ throw new RestApiWriteError(`CRUD ${operation} afterCommit hook failed.`, {
634
+ cause: error,
635
+ transactionOutcome: "committed"
636
+ });
637
+ }
630
638
  }
631
639
  return result;
632
640
  }
@@ -1,19 +1,14 @@
1
- import { createWithTransaction } from "@jskit-ai/database-runtime/shared";
2
1
  import {
3
2
  buildJsonRestQueryParams,
4
- createJsonApiInputRecord,
5
3
  createJsonRestContext,
6
4
  returnBadRequestWhenJsonRestFieldsetInvalid,
7
5
  returnNullWhenJsonRestResourceMissing
8
6
  } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
9
7
 
10
- function createCrudJsonApiRepository({ api, knex, resource, resourceScopeName } = {}) {
8
+ function createCrudJsonApiRepository({ api, resource, resourceScopeName } = {}) {
11
9
  if (!api) {
12
10
  throw new TypeError("createCrudJsonApiRepository requires api.");
13
11
  }
14
- if (!knex) {
15
- throw new TypeError("createCrudJsonApiRepository requires knex.");
16
- }
17
12
  if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
18
13
  throw new TypeError("createCrudJsonApiRepository requires resource.");
19
14
  }
@@ -22,7 +17,30 @@ function createCrudJsonApiRepository({ api, knex, resource, resourceScopeName }
22
17
  throw new TypeError("createCrudJsonApiRepository requires resourceScopeName.");
23
18
  }
24
19
 
25
- const withTransaction = createWithTransaction(knex);
20
+ function withTransaction(work) {
21
+ return api.transaction(work);
22
+ }
23
+
24
+ function inputData(payload) {
25
+ const data = { ...payload };
26
+ for (const [fieldName, definition] of Object.entries(resource.schema || {})) {
27
+ if (!definition?.belongsTo || !Object.hasOwn(data, fieldName)) {
28
+ continue;
29
+ }
30
+ const relationshipName = definition.as || fieldName;
31
+ if (relationshipName === fieldName) {
32
+ continue;
33
+ }
34
+ if (Object.hasOwn(data, relationshipName)) {
35
+ throw new TypeError(`Provide either "${fieldName}" or "${relationshipName}", not both.`);
36
+ }
37
+ if (data[fieldName] !== undefined) {
38
+ data[relationshipName] = data[fieldName];
39
+ }
40
+ delete data[fieldName];
41
+ }
42
+ return data;
43
+ }
26
44
 
27
45
  function requireApiResource() {
28
46
  const apiResource = api.resources?.[scopeName];
@@ -38,7 +56,7 @@ function createCrudJsonApiRepository({ api, knex, resource, resourceScopeName }
38
56
  {
39
57
  queryParams: buildJsonRestQueryParams(scopeName, query),
40
58
  transaction: options.trx || null,
41
- simplified: false
59
+ format: "jsonapi"
42
60
  },
43
61
  createJsonRestContext(options.context || null)
44
62
  )
@@ -53,7 +71,7 @@ function createCrudJsonApiRepository({ api, knex, resource, resourceScopeName }
53
71
  id: recordId,
54
72
  queryParams: buildJsonRestQueryParams(scopeName, query),
55
73
  transaction: options.trx || null,
56
- simplified: false
74
+ format: "jsonapi"
57
75
  },
58
76
  createJsonRestContext(options.context || null)
59
77
  )
@@ -64,9 +82,10 @@ function createCrudJsonApiRepository({ api, knex, resource, resourceScopeName }
64
82
  async function createDocument(payload = {}, options = {}) {
65
83
  return requireApiResource().post(
66
84
  {
67
- inputRecord: createJsonApiInputRecord(scopeName, payload, { resource }),
85
+ data: inputData(payload),
86
+ returning: "full",
68
87
  transaction: options.trx || null,
69
- simplified: false
88
+ format: "jsonapi"
70
89
  },
71
90
  createJsonRestContext(options.context || null)
72
91
  );
@@ -82,9 +101,10 @@ function createCrudJsonApiRepository({ api, knex, resource, resourceScopeName }
82
101
  requireApiResource().patch(
83
102
  {
84
103
  id: recordId,
85
- inputRecord: createJsonApiInputRecord(scopeName, sourcePatch, { resource }),
104
+ data: inputData(sourcePatch),
105
+ returning: "full",
86
106
  transaction: options.trx || null,
87
- simplified: false
107
+ format: "jsonapi"
88
108
  },
89
109
  createJsonRestContext(options.context || null)
90
110
  )
@@ -96,8 +116,9 @@ function createCrudJsonApiRepository({ api, knex, resource, resourceScopeName }
96
116
  await requireApiResource().delete(
97
117
  {
98
118
  id: recordId,
119
+ returning: "none",
99
120
  transaction: options.trx || null,
100
- simplified: false
121
+ format: "jsonapi"
101
122
  },
102
123
  createJsonRestContext(options.context || null)
103
124
  );
@@ -53,7 +53,7 @@ test("assistant list traverses real SQL pages with sparse fields and capped limi
53
53
  });
54
54
  const api = await createJsonRestApiHost({ knex });
55
55
  await addResourceIfMissing(api, "books", createJsonRestResourceScopeOptions(resource));
56
- const repository = createCrudJsonApiRepository({ api, knex, resource, resourceScopeName: "books" });
56
+ const repository = createCrudJsonApiRepository({ api, resource, resourceScopeName: "books" });
57
57
  const service = createCrudJsonApiService({ repository });
58
58
  const actions = createActionCatalogue();
59
59
  actions.register({
@@ -141,8 +141,9 @@ test("assistant list traverses real SQL pages with sparse fields and capped limi
141
141
 
142
142
  await t.test("plain writes and JSON:API repository writes preserve full records", async () => {
143
143
  const created = await api.resources.books.post({
144
- inputRecord: { id: "501", workspaceId: "1", name: "Plain record", createdAt: "2026-09-09T00:00:00.000Z" },
145
- format: "plain"
144
+ data: { id: "501", workspaceId: "1", name: "Plain record", createdAt: "2026-09-09T00:00:00.000Z" },
145
+ format: "plain",
146
+ returning: "full"
146
147
  }, createJsonRestContext(context));
147
148
  assert.equal(created.id, "501");
148
149
  assert.equal(created.name, "Plain record");
@@ -155,7 +156,7 @@ test("assistant list traverses real SQL pages with sparse fields and capped limi
155
156
  assert.equal(document.data.id, "502");
156
157
  assert.equal(document.data.attributes.name, "JSON:API record");
157
158
  assert.equal((await api.resources.books.get({ id: "502", format: "plain" }, createJsonRestContext(context))).name, "JSON:API record");
158
- await assert.rejects(knex.transaction(async (trx) => {
159
+ await assert.rejects(api.transaction(async (trx) => {
159
160
  await repository.patchDocumentById("502", { name: "Rolled back" }, { trx, context });
160
161
  throw new Error("Rollback proof");
161
162
  }), /Rollback proof/);
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createSchema } from "json-rest-schema";
4
+ import { RestApiWriteError } from "json-rest-api";
4
5
 
5
6
  import { createActionProvider } from "@jskit-ai/kernel/server/actions";
6
7
  import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
@@ -582,6 +583,80 @@ test("CRUD operation lifecycle does not run after or afterCommit when the transa
582
583
  assert.deepEqual(calls, ["transaction:begin", "before", "service"]);
583
584
  });
584
585
 
586
+ test("CRUD afterCommit failures preserve the original cause and acknowledged commit", async () => {
587
+ for (const failure of [new Error("notification failed"), Object.freeze(new Error("frozen failure")), null, undefined]) {
588
+ let committed = false;
589
+ const actions = createCrudJsonApiActions({
590
+ namespace: "books",
591
+ resource: createBookResource(),
592
+ repository: {
593
+ async withTransaction(work) {
594
+ const result = await work({ id: "transaction" });
595
+ committed = true;
596
+ return result;
597
+ }
598
+ },
599
+ service: {
600
+ patchDocumentById(id, attributes) { return { data: { id, attributes } }; }
601
+ },
602
+ surface: "app",
603
+ operations: ["update"],
604
+ permissionForOperation: () => ({ require: "authenticated" }),
605
+ operationLifecycle: {
606
+ update: { afterCommit() { assert.equal(committed, true); throw failure; } }
607
+ }
608
+ });
609
+ await assert.rejects(
610
+ () => actions[0].execute({ recordId: "42", title: "Kindred" }, {}),
611
+ (error) => {
612
+ assert.ok(error instanceof RestApiWriteError);
613
+ assert.equal(error.transactionOutcome, "committed");
614
+ assert.equal(error.cause, failure);
615
+ return true;
616
+ }
617
+ );
618
+ }
619
+ });
620
+
621
+ test("CRUD failures before acknowledged commit are not marked committed", async () => {
622
+ for (const phase of ["before", "after", "commit"]) {
623
+ const failure = Object.freeze(new Error(`${phase} failed`));
624
+ let committed = false;
625
+ let afterCommitCalled = false;
626
+ const actions = createCrudJsonApiActions({
627
+ namespace: "books",
628
+ resource: createBookResource(),
629
+ repository: {
630
+ async withTransaction(work) {
631
+ const result = await work({ id: "transaction" });
632
+ if (phase === "commit") throw failure;
633
+ committed = true;
634
+ return result;
635
+ }
636
+ },
637
+ service: {
638
+ patchDocumentById(id, attributes) { return { data: { id, attributes } }; }
639
+ },
640
+ surface: "app",
641
+ operations: ["update"],
642
+ permissionForOperation: () => ({ require: "authenticated" }),
643
+ operationLifecycle: {
644
+ update: {
645
+ before() { if (phase === "before") throw failure; },
646
+ after() { if (phase === "after") throw failure; },
647
+ afterCommit() { afterCommitCalled = true; }
648
+ }
649
+ }
650
+ });
651
+ await assert.rejects(
652
+ () => actions[0].execute({ recordId: "42", title: "Kindred" }, {}),
653
+ (error) => error === failure && error.transactionOutcome === undefined
654
+ );
655
+ assert.equal(committed, false);
656
+ assert.equal(afterCommitCalled, false);
657
+ }
658
+ });
659
+
585
660
  test("defineCrudJsonApiFeature exposes custom service methods as actions and optional routes", async () => {
586
661
  const routes = [];
587
662
  const calls = [];
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import {
5
+ createJsonRestApiHost,
6
+ createJsonRestResourceScopeOptions
7
+ } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
8
+ import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
9
+ import { createCrudJsonApiActions } from "../src/server/jsonApiModule/actions.js";
10
+ import { createCrudJsonApiRepository } from "../src/server/jsonApiModule/repository.js";
11
+ import { createCrudJsonApiService } from "../src/server/jsonApiModule/service.js";
12
+
13
+ function createRepositoryRecorder() {
14
+ const calls = [];
15
+ const response = { data: { type: "books", id: "7", attributes: { title: "Dune" } } };
16
+ const resource = {
17
+ schema: {
18
+ title: { type: "string" },
19
+ authorId: { type: "id", belongsTo: "authors", as: "author" }
20
+ }
21
+ };
22
+ const api = { resources: { books: {} }, transaction: (work) => work("managed") };
23
+ for (const method of ["get", "query", "post", "patch", "delete"]) {
24
+ api.resources.books[method] = async (params, context) => {
25
+ calls.push({ method, params, context });
26
+ return response;
27
+ };
28
+ }
29
+ return {
30
+ calls,
31
+ response,
32
+ repository: createCrudJsonApiRepository({ api, resource, resourceScopeName: "books" })
33
+ };
34
+ }
35
+
36
+ test("JSON:API repositories use plain input with explicit document responses", async () => {
37
+ const { repository, calls, response } = createRepositoryRecorder();
38
+ const context = Object.freeze({ scopeValues: Object.freeze({ workspaceId: "2" }) });
39
+ const payload = Object.freeze({ title: "Dune", authorId: 9, data: { edition: 1 } });
40
+ assert.equal(await repository.createDocument(payload, { trx: "managed", context }), response);
41
+ assert.deepEqual(calls[0].params, {
42
+ data: { title: "Dune", author: 9, data: { edition: 1 } },
43
+ returning: "full",
44
+ transaction: "managed",
45
+ format: "jsonapi"
46
+ });
47
+ assert.notEqual(calls[0].context, context);
48
+ assert.notEqual(calls[0].context.scopeValues, context.scopeValues);
49
+ assert.equal(payload.authorId, 9);
50
+ assert.equal(await repository.patchDocumentById("7", { authorId: null }), response);
51
+ assert.deepEqual(calls[1].params, {
52
+ id: "7",
53
+ data: { author: null },
54
+ returning: "full",
55
+ transaction: null,
56
+ format: "jsonapi"
57
+ });
58
+ await assert.rejects(
59
+ () => repository.createDocument({ authorId: "9", author: "10" }),
60
+ /Provide either "authorId" or "author", not both/
61
+ );
62
+ assert.equal(calls.length, 2);
63
+ });
64
+
65
+ test("JSON:API repository reads and empty patches retain their document contract", async () => {
66
+ const { repository, calls, response } = createRepositoryRecorder();
67
+ assert.equal(await repository.queryDocuments({ title: "Dune" }), response);
68
+ assert.equal(await repository.patchDocumentById("7", {}), response);
69
+ assert.equal(await repository.deleteDocumentById("7"), null);
70
+ assert.deepEqual(calls.map(({ method, params }) => ({ method, params })), [
71
+ { method: "query", params: { queryParams: { filters: { title: "Dune" } }, transaction: null, format: "jsonapi" } },
72
+ { method: "get", params: { id: "7", queryParams: {}, transaction: null, format: "jsonapi" } },
73
+ { method: "delete", params: { id: "7", returning: "none", transaction: null, format: "jsonapi" } }
74
+ ]);
75
+ assert.equal(await repository.withTransaction((trx) => trx), "managed");
76
+ });
77
+
78
+ async function createDatabaseRepository(t) {
79
+ const knex = knexLib({
80
+ client: "better-sqlite3",
81
+ connection: { filename: ":memory:" },
82
+ useNullAsDefault: true
83
+ });
84
+ t.after(() => knex.destroy());
85
+ await knex.schema.createTable("books", (table) => {
86
+ table.increments("id");
87
+ table.string("title").notNullable();
88
+ });
89
+ await knex.schema.createTable("audit_entries", (table) => {
90
+ table.increments("id");
91
+ table.string("message").notNullable();
92
+ });
93
+ const api = await createJsonRestApiHost({ knex });
94
+ const resource = { tableName: "books", schema: { title: { type: "string", required: true } } };
95
+ await api.addResource("books", createJsonRestResourceScopeOptions(resource));
96
+ const repository = createCrudJsonApiRepository({ api, resource, resourceScopeName: "books" });
97
+ return { api, knex, repository };
98
+ }
99
+
100
+ test("repository transactions commit resource writes and raw SQL before completion hooks", async (t) => {
101
+ const { api, knex, repository } = await createDatabaseRepository(t);
102
+ const committed = [];
103
+ await api.customize({ hooks: { afterCommit: ({ context }) => committed.push(context.method) } });
104
+ const document = await repository.withTransaction(async (trx) => {
105
+ const first = await repository.createDocument({ title: "Dune" }, { trx });
106
+ await repository.createDocument({ title: "Kindred" }, { trx });
107
+ await trx("audit_entries").insert({ message: "Created books" });
108
+ assert.deepEqual(committed, []);
109
+ return first;
110
+ });
111
+ assert.equal(document.data.attributes.title, "Dune");
112
+ assert.deepEqual(await knex("books").orderBy("id").pluck("title"), ["Dune", "Kindred"]);
113
+ assert.deepEqual(await knex("audit_entries").pluck("message"), ["Created books"]);
114
+ assert.deepEqual(committed, ["post", "post"]);
115
+ });
116
+
117
+ test("repository transactions roll back resource writes and raw SQL after a rejected participant", async (t) => {
118
+ const { knex, repository } = await createDatabaseRepository(t);
119
+ await assert.rejects(
120
+ () => repository.withTransaction(async (trx) => {
121
+ await repository.createDocument({ title: "Dune" }, { trx });
122
+ await trx("audit_entries").insert({ message: "Pending" });
123
+ await repository.createDocument({}, { trx });
124
+ }),
125
+ (error) => error.transactionOutcome === "rolledBack" && error.code === "REST_API_VALIDATION"
126
+ );
127
+ assert.deepEqual(await knex("books"), []);
128
+ assert.deepEqual(await knex("audit_entries"), []);
129
+ });
130
+
131
+ test("a missing write inside a managed transaction cannot become a successful null result", async (t) => {
132
+ const { knex, repository } = await createDatabaseRepository(t);
133
+ await assert.rejects(
134
+ () => repository.withTransaction(async (trx) => {
135
+ await repository.createDocument({ title: "Dune" }, { trx });
136
+ return repository.patchDocumentById("999", { title: "Missing" }, { trx });
137
+ }),
138
+ (error) => error.transactionOutcome === "rolledBack" && error.subtype === "not_found"
139
+ );
140
+ assert.deepEqual(await knex("books"), []);
141
+ assert.equal(await repository.patchDocumentById("999", { title: "Missing" }), null);
142
+ });
143
+
144
+ test("repository writes reject a raw transaction owner without inserting data", async (t) => {
145
+ const { knex, repository } = await createDatabaseRepository(t);
146
+ await knex.transaction(async (trx) => {
147
+ await assert.rejects(
148
+ () => repository.createDocument({ title: "Dune" }, { trx }),
149
+ (error) => error.code === "REST_API_VALIDATION" && error.transactionOutcome === "none"
150
+ );
151
+ assert.deepEqual(await trx("books"), []);
152
+ });
153
+ assert.deepEqual(await knex("books"), []);
154
+ });
155
+
156
+ test("a failing CRUD afterCommit hook reports committed and leaves the actual write stored", async (t) => {
157
+ const { knex, repository } = await createDatabaseRepository(t);
158
+ const book = await repository.createDocument({ title: "Dune" });
159
+ const failure = Object.freeze(new Error("Notification failed"));
160
+ const actions = createCrudJsonApiActions({
161
+ namespace: "books",
162
+ resource: defineCrudResource({
163
+ namespace: "books",
164
+ tableName: "books",
165
+ autofilter: "public",
166
+ schema: {
167
+ title: {
168
+ type: "string",
169
+ required: true,
170
+ operations: { output: { required: true }, create: { required: true }, patch: { required: false } }
171
+ }
172
+ }
173
+ }),
174
+ repository,
175
+ service: createCrudJsonApiService({ repository }),
176
+ surface: "app",
177
+ operations: ["update"],
178
+ permissionForOperation: () => ({ require: "authenticated" }),
179
+ operationLifecycle: { update: { afterCommit() { throw failure; } } }
180
+ });
181
+ await assert.rejects(
182
+ () => actions[0].execute({ recordId: book.data.id, title: "Kindred" }, {}),
183
+ (error) => error.transactionOutcome === "committed" && error.cause === failure
184
+ );
185
+ assert.equal((await knex("books").where({ id: book.data.id }).first()).title, "Kindred");
186
+ });