@jskit-ai/crud-core 0.1.196 → 0.1.197
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 +11 -7
- package/patterns/json-api-resource-package/example/packages/books/package.json +2 -2
- package/src/server/defineCrudJsonApiFeature.js +0 -1
- package/src/server/jsonApiModule/actions.js +17 -9
- package/src/server/jsonApiModule/repository.js +35 -14
- package/test/assistantPagination.integration.test.js +5 -4
- package/test/defineCrudJsonApiFeature.test.js +75 -0
- package/test/jsonApiRepository.test.js +186 -0
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/crud-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.197",
|
|
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.
|
|
27
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
28
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
29
|
-
"json-rest-schema": "^1.0.17"
|
|
29
|
+
"@jskit-ai/database-runtime": "0.1.186",
|
|
30
|
+
"@jskit-ai/json-rest-api-core": "0.1.130",
|
|
31
|
+
"@jskit-ai/resource-crud-core": "0.1.128",
|
|
32
|
+
"json-rest-schema": "^1.0.17",
|
|
33
|
+
"json-rest-api": "git+https://github.com/mobily-enterprises/json-rest-api.git#f97dc859321aee41915f3b0e256a861c382bc1b3"
|
|
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.
|
|
49
|
-
"@jskit-ai/kernel": "0.1.
|
|
52
|
+
"@jskit-ai/http-runtime": "0.1.184",
|
|
53
|
+
"@jskit-ai/kernel": "0.1.186"
|
|
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.
|
|
12
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
11
|
+
"@jskit-ai/crud-core": "0.1.197",
|
|
12
|
+
"@jskit-ai/resource-crud-core": "0.1.128"
|
|
13
13
|
},
|
|
14
14
|
"jskit": {
|
|
15
15
|
"kind": "runtime",
|
|
@@ -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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
data: inputData(payload),
|
|
86
|
+
returning: "full",
|
|
68
87
|
transaction: options.trx || null,
|
|
69
|
-
|
|
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
|
-
|
|
104
|
+
data: inputData(sourcePatch),
|
|
105
|
+
returning: "full",
|
|
86
106
|
transaction: options.trx || null,
|
|
87
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
+
});
|