@jskit-ai/crud-core 0.1.30 → 0.1.31

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.
@@ -2,70 +2,120 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { createCrudRepositoryFromResource } from "../src/server/createCrudRepositoryFromResource.js";
4
4
 
5
- function createListKnexDouble(rows = []) {
5
+ function createListKnexDouble(
6
+ rows = [],
7
+ {
8
+ insertResult = [1],
9
+ updateResult = 1,
10
+ deleteResult = 1
11
+ } = {}
12
+ ) {
6
13
  const calls = [];
7
- let firstMode = false;
8
- const whereGroup = {
9
- where(...args) {
10
- calls.push(["where", ...args]);
11
- return whereGroup;
12
- },
13
- orWhere(...args) {
14
- calls.push(["orWhere", ...args]);
15
- return whereGroup;
16
- }
14
+ const state = {
15
+ rows: Array.isArray(rows) ? rows : [],
16
+ insertPayloads: [],
17
+ updatePayloads: [],
18
+ deleteCount: 0
17
19
  };
18
20
 
19
- const query = {
20
- select(...args) {
21
- calls.push(["select", ...args]);
22
- return query;
23
- },
24
- where(...args) {
25
- if (args.length === 1 && typeof args[0] === "function") {
26
- calls.push(["whereCallback"]);
27
- args[0](whereGroup);
21
+ function createQueryBuilder() {
22
+ let firstMode = false;
23
+ const whereGroup = {
24
+ where(...args) {
25
+ calls.push(["where", ...args]);
26
+ return whereGroup;
27
+ },
28
+ orWhere(...args) {
29
+ calls.push(["orWhere", ...args]);
30
+ return whereGroup;
31
+ },
32
+ whereRaw(...args) {
33
+ calls.push(["whereRaw", ...args]);
34
+ return whereGroup;
35
+ }
36
+ };
37
+
38
+ const query = {
39
+ select(...args) {
40
+ calls.push(["select", ...args]);
41
+ return query;
42
+ },
43
+ where(...args) {
44
+ if (args.length === 1 && typeof args[0] === "function") {
45
+ calls.push(["whereCallback"]);
46
+ args[0](whereGroup);
47
+ return query;
48
+ }
49
+ calls.push(["where", ...args]);
50
+ return query;
51
+ },
52
+ whereRaw(...args) {
53
+ calls.push(["whereRaw", ...args]);
54
+ return query;
55
+ },
56
+ orderBy(...args) {
57
+ calls.push(["orderBy", ...args]);
58
+ return query;
59
+ },
60
+ clearOrder() {
61
+ calls.push(["clearOrder"]);
28
62
  return query;
63
+ },
64
+ clear(...args) {
65
+ calls.push(["clear", ...args]);
66
+ return query;
67
+ },
68
+ limit(value) {
69
+ calls.push(["limit", value]);
70
+ return query;
71
+ },
72
+ modify(callback) {
73
+ calls.push(["modify"]);
74
+ callback(query);
75
+ return query;
76
+ },
77
+ whereIn(...args) {
78
+ calls.push(["whereIn", ...args]);
79
+ return query;
80
+ },
81
+ first() {
82
+ calls.push(["first"]);
83
+ firstMode = true;
84
+ return query;
85
+ },
86
+ insert(payload) {
87
+ calls.push(["insert", payload]);
88
+ state.insertPayloads.push(payload);
89
+ return Promise.resolve(insertResult);
90
+ },
91
+ update(payload) {
92
+ calls.push(["update", payload]);
93
+ state.updatePayloads.push(payload);
94
+ return Promise.resolve(updateResult);
95
+ },
96
+ delete() {
97
+ calls.push(["delete"]);
98
+ state.deleteCount += 1;
99
+ return Promise.resolve(deleteResult);
100
+ },
101
+ then(resolve, reject) {
102
+ const payload = firstMode ? state.rows[0] || null : state.rows;
103
+ return Promise.resolve(payload).then(resolve, reject);
29
104
  }
30
- calls.push(["where", ...args]);
31
- return query;
32
- },
33
- orderBy(...args) {
34
- calls.push(["orderBy", ...args]);
35
- return query;
36
- },
37
- limit(value) {
38
- calls.push(["limit", value]);
39
- return query;
40
- },
41
- modify(callback) {
42
- calls.push(["modify"]);
43
- callback(query);
44
- return query;
45
- },
46
- whereIn(...args) {
47
- calls.push(["whereIn", ...args]);
48
- return query;
49
- },
50
- first() {
51
- calls.push(["first"]);
52
- firstMode = true;
53
- return query;
54
- },
55
- then(resolve, reject) {
56
- const payload = firstMode ? rows[0] || null : rows;
57
- return Promise.resolve(payload).then(resolve, reject);
58
- }
59
- };
105
+ };
106
+
107
+ return query;
108
+ }
60
109
 
61
110
  const knex = (tableName) => {
62
111
  calls.push(["table", tableName]);
63
- return query;
112
+ return createQueryBuilder();
64
113
  };
65
114
 
66
115
  return {
67
116
  knex,
68
- calls
117
+ calls,
118
+ state
69
119
  };
70
120
  }
71
121
 
@@ -235,6 +285,105 @@ function createLookupResourceWithCustomContainerKeyFixture() {
235
285
  };
236
286
  }
237
287
 
288
+ function createCollectionLookupResourceFixture() {
289
+ return {
290
+ resource: "contacts",
291
+ tableName: "contacts_table",
292
+ idColumn: "contact_id",
293
+ operations: {
294
+ view: {
295
+ outputValidator: {
296
+ schema: {
297
+ type: "object",
298
+ properties: {
299
+ id: { type: "integer" },
300
+ firstName: { type: "string" },
301
+ lookups: {
302
+ type: "object"
303
+ }
304
+ }
305
+ }
306
+ }
307
+ },
308
+ create: {
309
+ bodyValidator: {
310
+ schema: {
311
+ type: "object",
312
+ properties: {
313
+ firstName: { type: "string" }
314
+ }
315
+ }
316
+ }
317
+ }
318
+ },
319
+ fieldMeta: [
320
+ {
321
+ key: "id",
322
+ dbColumn: "contact_id"
323
+ },
324
+ {
325
+ key: "pets",
326
+ relation: {
327
+ kind: "collection",
328
+ namespace: "pets",
329
+ foreignKey: "customerId",
330
+ parentValueKey: "id"
331
+ }
332
+ }
333
+ ]
334
+ };
335
+ }
336
+
337
+ function createPetsLookupBackToContactsResourceFixture() {
338
+ return {
339
+ resource: "pets",
340
+ tableName: "pets_table",
341
+ idColumn: "pet_id",
342
+ operations: {
343
+ view: {
344
+ outputValidator: {
345
+ schema: {
346
+ type: "object",
347
+ properties: {
348
+ id: { type: "integer" },
349
+ name: { type: "string" },
350
+ customerId: { type: "integer" },
351
+ lookups: {
352
+ type: "object"
353
+ }
354
+ }
355
+ }
356
+ }
357
+ },
358
+ create: {
359
+ bodyValidator: {
360
+ schema: {
361
+ type: "object",
362
+ properties: {
363
+ name: { type: "string" }
364
+ }
365
+ }
366
+ }
367
+ }
368
+ },
369
+ fieldMeta: [
370
+ {
371
+ key: "id",
372
+ dbColumn: "pet_id"
373
+ },
374
+ {
375
+ key: "customerId",
376
+ dbColumn: "customer_id",
377
+ relation: {
378
+ kind: "lookup",
379
+ namespace: "contacts",
380
+ valueKey: "id"
381
+ }
382
+ }
383
+ ]
384
+ };
385
+ }
386
+
238
387
  function createNormalizedResourceFixture() {
239
388
  return {
240
389
  resource: "contacts",
@@ -279,6 +428,58 @@ function createNormalizedResourceFixture() {
279
428
  };
280
429
  }
281
430
 
431
+ function createWritableHookResourceFixture() {
432
+ return {
433
+ resource: "contacts",
434
+ tableName: "contacts_table",
435
+ idColumn: "contact_id",
436
+ operations: {
437
+ view: {
438
+ outputValidator: {
439
+ schema: {
440
+ type: "object",
441
+ properties: {
442
+ id: { type: "integer" },
443
+ firstName: { type: "string" },
444
+ createdAt: { type: "string" },
445
+ updatedAt: { type: "string" }
446
+ },
447
+ required: ["id", "firstName", "createdAt", "updatedAt"]
448
+ }
449
+ }
450
+ },
451
+ create: {
452
+ bodyValidator: {
453
+ schema: {
454
+ type: "object",
455
+ properties: {
456
+ firstName: { type: "string" }
457
+ }
458
+ }
459
+ }
460
+ }
461
+ },
462
+ fieldMeta: [
463
+ {
464
+ key: "id",
465
+ dbColumn: "contact_id"
466
+ },
467
+ {
468
+ key: "firstName",
469
+ dbColumn: "first_name"
470
+ },
471
+ {
472
+ key: "createdAt",
473
+ dbColumn: "created_at"
474
+ },
475
+ {
476
+ key: "updatedAt",
477
+ dbColumn: "updated_at"
478
+ }
479
+ ]
480
+ };
481
+ }
482
+
282
483
  test("createCrudRepositoryFromResource requires table metadata from resource", () => {
283
484
  assert.throws(
284
485
  () =>
@@ -389,6 +590,42 @@ test("createCrudRepositoryFromResource exposes listByIds for lookup providers",
389
590
  assert.ok(calls.some((call) => call[0] === "whereIn" && call[1] === "contact_id"));
390
591
  });
391
592
 
593
+ test("createCrudRepositoryFromResource exposes listByForeignIds for arbitrary output keys", async () => {
594
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
595
+ const { knex, calls } = createListKnexDouble([
596
+ {
597
+ contact_id: 3,
598
+ first_name: "Tony"
599
+ }
600
+ ]);
601
+ const repository = createRepository(knex);
602
+
603
+ const records = await repository.listByForeignIds(["Tony"], "firstName");
604
+
605
+ assert.equal(records.length, 1);
606
+ assert.deepEqual(records[0], {
607
+ id: 3,
608
+ firstName: "Tony"
609
+ });
610
+ assert.ok(calls.some((call) => call[0] === "whereIn" && call[1] === "first_name"));
611
+ });
612
+
613
+ test("createCrudRepositoryFromResource listByForeignIds requires a foreignKey", async () => {
614
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
615
+ const { knex } = createListKnexDouble([
616
+ {
617
+ contact_id: 3,
618
+ first_name: "Tony"
619
+ }
620
+ ]);
621
+ const repository = createRepository(knex);
622
+
623
+ await assert.rejects(
624
+ () => repository.listByForeignIds([3], ""),
625
+ /listByForeignIds requires foreignKey/
626
+ );
627
+ });
628
+
392
629
  test("createCrudRepositoryFromResource listByIds fails fast when valueKey is not in output schema", async () => {
393
630
  const createRepository = createCrudRepositoryFromResource(createResourceFixture());
394
631
  const { knex } = createListKnexDouble([
@@ -649,14 +886,8 @@ test("createCrudRepositoryFromResource forwards wildcard nested include paths to
649
886
  assert.equal(lookupCalls[0].options.include, "*");
650
887
  });
651
888
 
652
- test("createCrudRepositoryFromResource forwards configured lookup maxDepth to child repositories", async () => {
653
- const resource = createLookupResourceFixture();
654
- resource.contract = {
655
- lookup: {
656
- maxDepth: 5
657
- }
658
- };
659
- const createRepository = createCrudRepositoryFromResource(resource);
889
+ test("createCrudRepositoryFromResource remaps child lookup visibility for public child providers", async () => {
890
+ const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
660
891
  const { knex } = createListKnexDouble([
661
892
  {
662
893
  contact_id: 3,
@@ -670,6 +901,7 @@ test("createCrudRepositoryFromResource forwards configured lookup maxDepth to ch
670
901
  const repository = createRepository(knex, {
671
902
  resolveLookupProvider() {
672
903
  return {
904
+ ownershipFilter: "public",
673
905
  async listByIds(ids = [], options = {}) {
674
906
  lookupCalls.push({
675
907
  ids,
@@ -681,12 +913,20 @@ test("createCrudRepositoryFromResource forwards configured lookup maxDepth to ch
681
913
  }
682
914
  });
683
915
 
684
- await repository.list({});
916
+ await repository.list({}, {
917
+ visibilityContext: {
918
+ visibility: "workspace",
919
+ scopeOwnerId: "workspace-1"
920
+ }
921
+ });
922
+
685
923
  assert.equal(lookupCalls.length, 1);
686
- assert.equal(lookupCalls[0].options.lookupMaxDepth, 5);
924
+ assert.deepEqual(lookupCalls[0].options.visibilityContext, {
925
+ visibility: "public"
926
+ });
687
927
  });
688
928
 
689
- test("createCrudRepositoryFromResource throws when include requires lookups and resolver is missing", async () => {
929
+ test("createCrudRepositoryFromResource remaps child lookup visibility for workspace child providers", async () => {
690
930
  const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
691
931
  const { knex } = createListKnexDouble([
692
932
  {
@@ -696,15 +936,39 @@ test("createCrudRepositoryFromResource throws when include requires lookups and
696
936
  secondary_vet_id: 12
697
937
  }
698
938
  ]);
699
- const repository = createRepository(knex);
700
939
 
701
- await assert.rejects(
702
- () => repository.list({}),
703
- /requires resolveLookupProvider/
704
- );
940
+ const lookupCalls = [];
941
+ const repository = createRepository(knex, {
942
+ resolveLookupProvider() {
943
+ return {
944
+ ownershipFilter: "workspace",
945
+ async listByIds(ids = [], options = {}) {
946
+ lookupCalls.push({
947
+ ids,
948
+ options
949
+ });
950
+ return [{ id: 10, name: "Vet A" }, { id: 12, name: "Vet B" }];
951
+ }
952
+ };
953
+ }
954
+ });
955
+
956
+ await repository.list({}, {
957
+ visibilityContext: {
958
+ visibility: "workspace_user",
959
+ scopeOwnerId: "workspace-1",
960
+ userOwnerId: "user-1"
961
+ }
962
+ });
963
+
964
+ assert.equal(lookupCalls.length, 1);
965
+ assert.deepEqual(lookupCalls[0].options.visibilityContext, {
966
+ visibility: "workspace",
967
+ scopeOwnerId: "workspace-1"
968
+ });
705
969
  });
706
970
 
707
- test("createCrudRepositoryFromResource throws when include references unknown lookup field", async () => {
971
+ test("createCrudRepositoryFromResource requires child lookup ownershipFilter under non-public visibility", async () => {
708
972
  const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
709
973
  const { knex } = createListKnexDouble([
710
974
  {
@@ -725,7 +989,763 @@ test("createCrudRepositoryFromResource throws when include references unknown lo
725
989
  });
726
990
 
727
991
  await assert.rejects(
728
- () => repository.list({ include: "unknownLookupKey" }),
729
- /unknown lookup key/
992
+ () =>
993
+ repository.list({}, {
994
+ visibilityContext: {
995
+ visibility: "workspace",
996
+ scopeOwnerId: "workspace-1"
997
+ }
998
+ }),
999
+ /must declare ownershipFilter/
730
1000
  );
731
1001
  });
1002
+
1003
+ test("createCrudRepositoryFromResource hydrates collection relations through listByIds valueKey", async () => {
1004
+ const createRepository = createCrudRepositoryFromResource(createCollectionLookupResourceFixture());
1005
+ const { knex } = createListKnexDouble([
1006
+ {
1007
+ contact_id: 3,
1008
+ first_name: "Tony"
1009
+ },
1010
+ {
1011
+ contact_id: 4,
1012
+ first_name: "Sara"
1013
+ }
1014
+ ]);
1015
+
1016
+ const lookupCalls = [];
1017
+ const repository = createRepository(knex, {
1018
+ resolveLookupProvider(relation = {}) {
1019
+ assert.equal(relation.namespace, "pets");
1020
+ return {
1021
+ async listByIds(ids = [], options = {}) {
1022
+ lookupCalls.push({
1023
+ ids,
1024
+ options
1025
+ });
1026
+ return [
1027
+ { id: 11, name: "Milo", customerId: 3 },
1028
+ { id: 12, name: "Luna", customerId: 3 },
1029
+ { id: 20, name: "Ruby", customerId: 4 }
1030
+ ];
1031
+ }
1032
+ };
1033
+ }
1034
+ });
1035
+
1036
+ const result = await repository.list({
1037
+ include: "pets"
1038
+ });
1039
+
1040
+ assert.equal(lookupCalls.length, 1);
1041
+ assert.deepEqual(lookupCalls[0].ids, [3, 4]);
1042
+ assert.equal(lookupCalls[0].options.include, "none");
1043
+ assert.equal(lookupCalls[0].options.valueKey, "customerId");
1044
+ assert.deepEqual(result.items[0].lookups?.pets?.map((item) => item.name), ["Milo", "Luna"]);
1045
+ assert.deepEqual(result.items[1].lookups?.pets?.map((item) => item.name), ["Ruby"]);
1046
+ });
1047
+
1048
+ test("createCrudRepositoryFromResource blocks recursive lookup hydration across collection back-references", async () => {
1049
+ const createContactsRepository = createCrudRepositoryFromResource(createCollectionLookupResourceFixture());
1050
+ const createPetsRepository = createCrudRepositoryFromResource(createPetsLookupBackToContactsResourceFixture());
1051
+ const { knex: contactsKnex } = createListKnexDouble([
1052
+ {
1053
+ contact_id: 3,
1054
+ first_name: "Tony"
1055
+ }
1056
+ ]);
1057
+ const { knex: petsKnex } = createListKnexDouble([
1058
+ {
1059
+ pet_id: 11,
1060
+ name: "Milo",
1061
+ customer_id: 3
1062
+ }
1063
+ ]);
1064
+
1065
+ let contactsRepository = null;
1066
+ let petsRepository = null;
1067
+ let contactsLookupCallCount = 0;
1068
+ let receivedPetsLookupOptions = null;
1069
+
1070
+ petsRepository = createPetsRepository(petsKnex, {
1071
+ resolveLookupProvider(relation = {}) {
1072
+ if (relation.namespace !== "contacts") {
1073
+ throw new Error(`unexpected relation namespace: ${relation.namespace}`);
1074
+ }
1075
+ contactsLookupCallCount += 1;
1076
+ return contactsRepository;
1077
+ }
1078
+ });
1079
+
1080
+ contactsRepository = createContactsRepository(contactsKnex, {
1081
+ resolveLookupProvider(relation = {}) {
1082
+ if (relation.namespace !== "pets") {
1083
+ throw new Error(`unexpected relation namespace: ${relation.namespace}`);
1084
+ }
1085
+ return {
1086
+ async listByIds(ids = [], options = {}) {
1087
+ receivedPetsLookupOptions = options;
1088
+ return petsRepository.listByIds(ids, options);
1089
+ }
1090
+ };
1091
+ }
1092
+ });
1093
+
1094
+ const result = await contactsRepository.list({
1095
+ include: "pets.*"
1096
+ });
1097
+
1098
+ assert.equal(contactsLookupCallCount, 0);
1099
+ assert.deepEqual(result.items[0].lookups?.pets?.map((item) => item.name), ["Milo"]);
1100
+ assert.ok(Array.isArray(receivedPetsLookupOptions?.lookupVisitedNamespaces));
1101
+ assert.ok(receivedPetsLookupOptions.lookupVisitedNamespaces.includes("contacts"));
1102
+ });
1103
+
1104
+ test("createCrudRepositoryFromResource forwards configured lookup maxDepth to child repositories", async () => {
1105
+ const resource = createLookupResourceFixture();
1106
+ resource.contract = {
1107
+ lookup: {
1108
+ maxDepth: 5
1109
+ }
1110
+ };
1111
+ const createRepository = createCrudRepositoryFromResource(resource);
1112
+ const { knex } = createListKnexDouble([
1113
+ {
1114
+ contact_id: 3,
1115
+ first_name: "Tony",
1116
+ primary_vet_id: 10,
1117
+ secondary_vet_id: 12
1118
+ }
1119
+ ]);
1120
+
1121
+ const lookupCalls = [];
1122
+ const repository = createRepository(knex, {
1123
+ resolveLookupProvider() {
1124
+ return {
1125
+ async listByIds(ids = [], options = {}) {
1126
+ lookupCalls.push({
1127
+ ids,
1128
+ options
1129
+ });
1130
+ return [{ id: 10, name: "Vet A" }, { id: 12, name: "Vet B" }];
1131
+ }
1132
+ };
1133
+ }
1134
+ });
1135
+
1136
+ await repository.list({});
1137
+ assert.equal(lookupCalls.length, 1);
1138
+ assert.equal(lookupCalls[0].options.lookupMaxDepth, 5);
1139
+ });
1140
+
1141
+ test("createCrudRepositoryFromResource throws when include requires lookups and resolver is missing", async () => {
1142
+ const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
1143
+ const { knex } = createListKnexDouble([
1144
+ {
1145
+ contact_id: 3,
1146
+ first_name: "Tony",
1147
+ primary_vet_id: 10,
1148
+ secondary_vet_id: 12
1149
+ }
1150
+ ]);
1151
+ const repository = createRepository(knex);
1152
+
1153
+ await assert.rejects(
1154
+ () => repository.list({}),
1155
+ /requires resolveLookupProvider/
1156
+ );
1157
+ });
1158
+
1159
+ test("createCrudRepositoryFromResource throws when include references unknown lookup field", async () => {
1160
+ const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
1161
+ const { knex } = createListKnexDouble([
1162
+ {
1163
+ contact_id: 3,
1164
+ first_name: "Tony",
1165
+ primary_vet_id: 10,
1166
+ secondary_vet_id: 12
1167
+ }
1168
+ ]);
1169
+ const repository = createRepository(knex, {
1170
+ resolveLookupProvider() {
1171
+ return {
1172
+ async listByIds() {
1173
+ return [];
1174
+ }
1175
+ };
1176
+ }
1177
+ });
1178
+
1179
+ await assert.rejects(
1180
+ () => repository.list({ include: "unknownLookupKey" }),
1181
+ /unknown lookup key/
1182
+ );
1183
+ });
1184
+
1185
+ test("createCrudRepositoryFromResource list hooks reject invalid types and unsupported keys", async () => {
1186
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1187
+ const { knex } = createListKnexDouble([
1188
+ {
1189
+ contact_id: 3,
1190
+ first_name: "Tony"
1191
+ }
1192
+ ]);
1193
+ const repository = createRepository(knex);
1194
+
1195
+ await assert.rejects(
1196
+ () => repository.list({}, {}, {
1197
+ modifyQuery: "not-a-function"
1198
+ }),
1199
+ /hooks\.modifyQuery must be a function/
1200
+ );
1201
+
1202
+ await assert.rejects(
1203
+ () => repository.list({}, {}, {
1204
+ unsupported() {}
1205
+ }),
1206
+ /does not support hooks\.unsupported/
1207
+ );
1208
+ });
1209
+
1210
+ test("createCrudRepositoryFromResource list hooks cannot replace query builders", async () => {
1211
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1212
+ const { knex } = createListKnexDouble([
1213
+ {
1214
+ contact_id: 3,
1215
+ first_name: "Tony"
1216
+ }
1217
+ ]);
1218
+ const repository = createRepository(knex);
1219
+
1220
+ await assert.rejects(
1221
+ () => repository.list({}, {}, {
1222
+ modifyQuery() {
1223
+ return {
1224
+ where() {}
1225
+ };
1226
+ }
1227
+ }),
1228
+ /must mutate the provided query builder/
1229
+ );
1230
+ });
1231
+
1232
+ test("createCrudRepositoryFromResource list hooks share state across afterQuery/transformReturnedRecord/finalizeOutput", async () => {
1233
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1234
+ const { knex } = createListKnexDouble([
1235
+ {
1236
+ contact_id: 3,
1237
+ first_name: "Tony"
1238
+ },
1239
+ {
1240
+ contact_id: 4,
1241
+ first_name: "Sam"
1242
+ }
1243
+ ]);
1244
+ const repository = createRepository(knex);
1245
+
1246
+ const result = await repository.list({}, {}, {
1247
+ afterQuery(items = [], context = {}) {
1248
+ const labels = {};
1249
+ for (const item of items) {
1250
+ labels[item.id] = `contact-${item.id}`;
1251
+ }
1252
+ context.state.labels = labels;
1253
+ },
1254
+ transformReturnedRecord(record = {}, context = {}) {
1255
+ return {
1256
+ ...record,
1257
+ label: context.state.labels?.[record.id] || null
1258
+ };
1259
+ },
1260
+ finalizeOutput(resultPayload = {}) {
1261
+ return {
1262
+ ...resultPayload,
1263
+ itemCount: Array.isArray(resultPayload.items) ? resultPayload.items.length : 0
1264
+ };
1265
+ }
1266
+ });
1267
+
1268
+ assert.equal(result.itemCount, 2);
1269
+ assert.deepEqual(result.items.map((item) => item.label), ["contact-3", "contact-4"]);
1270
+ });
1271
+
1272
+ test("createCrudRepositoryFromResource list finalizeOutput requires canonical result keys", async () => {
1273
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1274
+ const { knex } = createListKnexDouble([
1275
+ {
1276
+ contact_id: 3,
1277
+ first_name: "Tony"
1278
+ }
1279
+ ]);
1280
+ const repository = createRepository(knex);
1281
+
1282
+ await assert.rejects(
1283
+ () => repository.list({}, {}, {
1284
+ finalizeOutput() {
1285
+ return {
1286
+ items: []
1287
+ };
1288
+ }
1289
+ }),
1290
+ /required key "nextCursor"/
1291
+ );
1292
+ });
1293
+
1294
+ test("createCrudRepositoryFromResource list hooks reject async return values that replace builder identity", async () => {
1295
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1296
+ const { knex } = createListKnexDouble([
1297
+ {
1298
+ contact_id: 3,
1299
+ first_name: "Tony"
1300
+ }
1301
+ ]);
1302
+ const repository = createRepository(knex);
1303
+
1304
+ await assert.rejects(
1305
+ () => repository.list({}, {}, {
1306
+ async modifyQuery(dbQuery) {
1307
+ dbQuery.where("vip", 1);
1308
+ return dbQuery;
1309
+ }
1310
+ }),
1311
+ /must mutate the provided query builder/
1312
+ );
1313
+ });
1314
+
1315
+ test("createCrudRepositoryFromResource list hooks keep visibility and canonical list controls", async () => {
1316
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1317
+ const { knex, calls } = createListKnexDouble([
1318
+ {
1319
+ contact_id: 3,
1320
+ first_name: "Tony"
1321
+ }
1322
+ ]);
1323
+ const repository = createRepository(knex);
1324
+
1325
+ await repository.list({
1326
+ cursor: 2,
1327
+ limit: 5
1328
+ }, {
1329
+ visibilityContext: {
1330
+ visibility: "workspace",
1331
+ scopeOwnerId: "workspace-1"
1332
+ }
1333
+ }, {
1334
+ modifyQuery(dbQuery) {
1335
+ dbQuery.orderBy("first_name", "desc").limit(99);
1336
+ return dbQuery;
1337
+ }
1338
+ });
1339
+
1340
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "contact_id" && call[2] === ">" && call[3] === 2));
1341
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "workspace_owner_id" && call[2] === "workspace-1"));
1342
+ assert.ok(calls.some((call) => call[0] === "clearOrder"));
1343
+ assert.ok(calls.some((call) => call[0] === "clear" && call[1] === "limit"));
1344
+ assert.ok(calls.some((call) => call[0] === "orderBy" && call[1] === "contact_id" && call[2] === "asc"));
1345
+ assert.ok(calls.some((call) => call[0] === "limit" && call[1] === 6));
1346
+ });
1347
+
1348
+ test("createCrudRepositoryFromResource findById hooks keep visibility and id predicates", async () => {
1349
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1350
+ const { knex, calls } = createListKnexDouble([
1351
+ {
1352
+ contact_id: 7,
1353
+ first_name: "Tony"
1354
+ }
1355
+ ]);
1356
+ const repository = createRepository(knex);
1357
+
1358
+ await repository.findById(7, {
1359
+ visibilityContext: {
1360
+ visibility: "workspace",
1361
+ scopeOwnerId: "workspace-1"
1362
+ }
1363
+ }, {
1364
+ modifyQuery(dbQuery) {
1365
+ dbQuery.where("contact_id", 999);
1366
+ return dbQuery;
1367
+ }
1368
+ });
1369
+
1370
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "contact_id" && call[2] === 999));
1371
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "workspace_owner_id" && call[2] === "workspace-1"));
1372
+ assert.ok(calls.some((call) => call[0] === "where" && call[1]?.contact_id === 7));
1373
+ });
1374
+
1375
+ test("createCrudRepositoryFromResource findById hooks share state between query and transformReturnedRecord", async () => {
1376
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1377
+ const { knex } = createListKnexDouble([
1378
+ {
1379
+ contact_id: 7,
1380
+ first_name: "Tony"
1381
+ }
1382
+ ]);
1383
+ const repository = createRepository(knex);
1384
+
1385
+ const record = await repository.findById(7, {}, {
1386
+ modifyQuery(_dbQuery, context = {}) {
1387
+ context.state.recordTag = "from-state";
1388
+ },
1389
+ afterQuery(records = [], context = {}) {
1390
+ if (records.length > 0) {
1391
+ context.state.afterQuerySeen = true;
1392
+ }
1393
+ },
1394
+ transformReturnedRecord(resultRecord = {}, context = {}) {
1395
+ return {
1396
+ ...resultRecord,
1397
+ recordTag: context.state.recordTag,
1398
+ afterQuerySeen: context.state.afterQuerySeen || false
1399
+ };
1400
+ }
1401
+ });
1402
+
1403
+ assert.equal(record.recordTag, "from-state");
1404
+ assert.equal(record.afterQuerySeen, true);
1405
+ });
1406
+
1407
+ test("createCrudRepositoryFromResource listByIds hooks support afterQuery/transformReturnedRecord/finalizeOutput", async () => {
1408
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1409
+ const { knex } = createListKnexDouble([
1410
+ {
1411
+ contact_id: 3,
1412
+ first_name: "Tony"
1413
+ },
1414
+ {
1415
+ contact_id: 4,
1416
+ first_name: "Sam"
1417
+ }
1418
+ ]);
1419
+ const repository = createRepository(knex);
1420
+
1421
+ const items = await repository.listByIds([3, 4], {}, {
1422
+ afterQuery(records = [], context = {}) {
1423
+ const namesById = {};
1424
+ for (const record of records) {
1425
+ namesById[record.id] = record.firstName.toUpperCase();
1426
+ }
1427
+ context.state.namesById = namesById;
1428
+ },
1429
+ transformReturnedRecord(record = {}, context = {}) {
1430
+ return {
1431
+ ...record,
1432
+ nameTag: context.state.namesById?.[record.id] || null
1433
+ };
1434
+ },
1435
+ finalizeOutput(records = []) {
1436
+ return [...records].reverse();
1437
+ }
1438
+ });
1439
+
1440
+ assert.deepEqual(items.map((item) => item.id), [4, 3]);
1441
+ assert.deepEqual(items.map((item) => item.nameTag), ["SAM", "TONY"]);
1442
+ });
1443
+
1444
+ test("createCrudRepositoryFromResource create hooks keep write-key filtering and ownership stamping", async () => {
1445
+ const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
1446
+ const { knex, state } = createListKnexDouble([
1447
+ {
1448
+ contact_id: 11,
1449
+ first_name: "Tony",
1450
+ created_at: "2026-01-01 00:00:00",
1451
+ updated_at: "2026-01-01 00:00:00"
1452
+ }
1453
+ ], {
1454
+ insertResult: [11]
1455
+ });
1456
+ const repository = createRepository(knex);
1457
+
1458
+ await repository.create({
1459
+ firstName: "Tony"
1460
+ }, {
1461
+ visibilityContext: {
1462
+ visibility: "workspace",
1463
+ scopeOwnerId: "workspace-1"
1464
+ }
1465
+ }, {
1466
+ modifyPayload(payload = {}) {
1467
+ return {
1468
+ ...payload,
1469
+ unexpectedField: "blocked",
1470
+ workspaceOwnerId: "blocked"
1471
+ };
1472
+ },
1473
+ modifyQuery(_dbQuery, context = {}) {
1474
+ if (context.payload && typeof context.payload === "object") {
1475
+ context.payload.unexpectedFieldFromQuery = "blocked";
1476
+ }
1477
+ }
1478
+ });
1479
+
1480
+ assert.equal(state.insertPayloads.length, 1);
1481
+ assert.deepEqual(state.insertPayloads[0].first_name, "Tony");
1482
+ assert.equal(Object.hasOwn(state.insertPayloads[0], "unexpectedField"), false);
1483
+ assert.equal(Object.hasOwn(state.insertPayloads[0], "unexpectedFieldFromQuery"), false);
1484
+ assert.equal(Object.hasOwn(state.insertPayloads[0], "workspaceOwnerId"), false);
1485
+ assert.equal(state.insertPayloads[0].workspace_owner_id, "workspace-1");
1486
+ assert.ok(state.insertPayloads[0].created_at);
1487
+ assert.ok(state.insertPayloads[0].updated_at);
1488
+ });
1489
+
1490
+ test("createCrudRepositoryFromResource create hooks reject read-phase hook keys", async () => {
1491
+ const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
1492
+ const { knex } = createListKnexDouble([
1493
+ {
1494
+ contact_id: 11,
1495
+ first_name: "Tony",
1496
+ created_at: "2026-01-01 00:00:00",
1497
+ updated_at: "2026-01-01 00:00:00"
1498
+ }
1499
+ ], {
1500
+ insertResult: [11]
1501
+ });
1502
+ const repository = createRepository(knex);
1503
+
1504
+ await assert.rejects(
1505
+ () => repository.create({
1506
+ firstName: "Tony"
1507
+ }, {}, {
1508
+ transformReturnedRecord(record = {}) {
1509
+ return record;
1510
+ }
1511
+ }),
1512
+ /does not support hooks\./
1513
+ );
1514
+ });
1515
+
1516
+ test("createCrudRepositoryFromResource create hooks support afterWrite and canonical findById output pipeline", async () => {
1517
+ const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
1518
+ const { knex } = createListKnexDouble([
1519
+ {
1520
+ contact_id: 11,
1521
+ first_name: "Tony",
1522
+ created_at: "2026-01-01 00:00:00",
1523
+ updated_at: "2026-01-01 00:00:00"
1524
+ }
1525
+ ], {
1526
+ insertResult: [11]
1527
+ });
1528
+ const repository = createRepository(knex);
1529
+ const afterWriteCalls = [];
1530
+
1531
+ const record = await repository.create({
1532
+ firstName: "Tony"
1533
+ }, {}, {
1534
+ modifyPayload(payload = {}, context = {}) {
1535
+ context.state.createdName = payload.firstName;
1536
+ return payload;
1537
+ },
1538
+ afterWrite(meta = {}, context = {}) {
1539
+ afterWriteCalls.push({
1540
+ operation: meta.operation,
1541
+ createdName: context.state.createdName,
1542
+ recordId: meta.recordId
1543
+ });
1544
+ }
1545
+ });
1546
+
1547
+ assert.equal(record.firstName, "Tony");
1548
+ assert.equal(Object.hasOwn(record, "createdName"), false);
1549
+ assert.equal(afterWriteCalls.length, 1);
1550
+ assert.equal(afterWriteCalls[0].operation, "create");
1551
+ assert.equal(afterWriteCalls[0].createdName, "Tony");
1552
+ assert.equal(afterWriteCalls[0].recordId, 11);
1553
+ });
1554
+
1555
+ test("createCrudRepositoryFromResource update hooks keep write-key filtering and by-id visibility constraints", async () => {
1556
+ const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
1557
+ const { knex, calls, state } = createListKnexDouble([
1558
+ {
1559
+ contact_id: 11,
1560
+ first_name: "Tony",
1561
+ created_at: "2026-01-01 00:00:00",
1562
+ updated_at: "2026-01-01 00:00:00"
1563
+ }
1564
+ ]);
1565
+ const repository = createRepository(knex);
1566
+
1567
+ await repository.updateById(11, {
1568
+ firstName: "Tony"
1569
+ }, {
1570
+ visibilityContext: {
1571
+ visibility: "workspace",
1572
+ scopeOwnerId: "workspace-1"
1573
+ }
1574
+ }, {
1575
+ modifyPatch(patch = {}) {
1576
+ return {
1577
+ ...patch,
1578
+ unexpectedField: "blocked"
1579
+ };
1580
+ },
1581
+ modifyQuery(dbQuery, context = {}) {
1582
+ if (context.patch && typeof context.patch === "object") {
1583
+ context.patch.unexpectedFieldFromQuery = "blocked";
1584
+ }
1585
+ dbQuery.where("vip", 1);
1586
+ return dbQuery;
1587
+ }
1588
+ });
1589
+
1590
+ assert.equal(state.updatePayloads.length, 1);
1591
+ assert.deepEqual(state.updatePayloads[0].first_name, "Tony");
1592
+ assert.equal(Object.hasOwn(state.updatePayloads[0], "unexpectedField"), false);
1593
+ assert.equal(Object.hasOwn(state.updatePayloads[0], "unexpectedFieldFromQuery"), false);
1594
+ assert.ok(state.updatePayloads[0].updated_at);
1595
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "vip" && call[2] === 1));
1596
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "workspace_owner_id" && call[2] === "workspace-1"));
1597
+ assert.ok(calls.some((call) => call[0] === "where" && call[1]?.contact_id === 11));
1598
+ });
1599
+
1600
+ test("createCrudRepositoryFromResource update hooks reject read-phase hook keys", async () => {
1601
+ const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
1602
+ const { knex } = createListKnexDouble([
1603
+ {
1604
+ contact_id: 11,
1605
+ first_name: "Tony",
1606
+ created_at: "2026-01-01 00:00:00",
1607
+ updated_at: "2026-01-01 00:00:00"
1608
+ }
1609
+ ]);
1610
+ const repository = createRepository(knex);
1611
+
1612
+ await assert.rejects(
1613
+ () => repository.updateById(11, {
1614
+ firstName: "Tony"
1615
+ }, {}, {
1616
+ transformReturnedRecord(record = {}) {
1617
+ return record;
1618
+ }
1619
+ }),
1620
+ /does not support hooks\./
1621
+ );
1622
+ });
1623
+
1624
+ test("createCrudRepositoryFromResource update hooks support afterWrite and canonical findById output pipeline", async () => {
1625
+ const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
1626
+ const { knex } = createListKnexDouble([
1627
+ {
1628
+ contact_id: 11,
1629
+ first_name: "Tony",
1630
+ created_at: "2026-01-01 00:00:00",
1631
+ updated_at: "2026-01-01 00:00:00"
1632
+ }
1633
+ ]);
1634
+ const repository = createRepository(knex);
1635
+ const afterWriteCalls = [];
1636
+
1637
+ const record = await repository.updateById(11, {
1638
+ firstName: "Tony"
1639
+ }, {}, {
1640
+ modifyPatch(patch = {}, context = {}) {
1641
+ context.state.patchKeys = Object.keys(patch);
1642
+ return patch;
1643
+ },
1644
+ afterWrite(meta = {}, context = {}) {
1645
+ afterWriteCalls.push({
1646
+ operation: meta.operation,
1647
+ patchKeys: context.state.patchKeys || [],
1648
+ recordId: meta.recordId
1649
+ });
1650
+ }
1651
+ });
1652
+
1653
+ assert.equal(record.firstName, "Tony");
1654
+ assert.equal(Object.hasOwn(record, "patchKeys"), false);
1655
+ assert.equal(afterWriteCalls.length, 1);
1656
+ assert.equal(afterWriteCalls[0].operation, "update");
1657
+ assert.deepEqual(afterWriteCalls[0].patchKeys, ["firstName"]);
1658
+ assert.equal(afterWriteCalls[0].recordId, 11);
1659
+ });
1660
+
1661
+ test("createCrudRepositoryFromResource delete hooks run through callOptions.trx client", async () => {
1662
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1663
+ const baseKnex = createListKnexDouble([
1664
+ {
1665
+ contact_id: 3,
1666
+ first_name: "Tony"
1667
+ }
1668
+ ]);
1669
+ const trxKnex = createListKnexDouble([
1670
+ {
1671
+ contact_id: 3,
1672
+ first_name: "Tony"
1673
+ }
1674
+ ]);
1675
+ const repository = createRepository(baseKnex.knex);
1676
+
1677
+ await repository.deleteById(3, {
1678
+ trx: trxKnex.knex,
1679
+ visibilityContext: {
1680
+ visibility: "workspace",
1681
+ scopeOwnerId: "workspace-1"
1682
+ }
1683
+ }, {
1684
+ modifyQuery(dbQuery) {
1685
+ dbQuery.where("vip", 1);
1686
+ return dbQuery;
1687
+ }
1688
+ });
1689
+
1690
+ assert.equal(baseKnex.calls.some((call) => call[0] === "table"), false);
1691
+ assert.ok(trxKnex.calls.some((call) => call[0] === "table" && call[1] === "contacts_table"));
1692
+ assert.ok(trxKnex.calls.some((call) => call[0] === "where" && call[1] === "vip" && call[2] === 1));
1693
+ assert.ok(trxKnex.calls.some((call) => call[0] === "delete"));
1694
+ });
1695
+
1696
+ test("createCrudRepositoryFromResource delete hooks support afterWrite", async () => {
1697
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1698
+ const { knex } = createListKnexDouble([
1699
+ {
1700
+ contact_id: 3,
1701
+ first_name: "Tony"
1702
+ }
1703
+ ]);
1704
+ const repository = createRepository(knex);
1705
+ const afterWriteCalls = [];
1706
+
1707
+ const result = await repository.deleteById(3, {}, {
1708
+ afterWrite(meta = {}, context = {}) {
1709
+ context.state.deletedId = meta?.output?.id || null;
1710
+ afterWriteCalls.push({
1711
+ operation: meta.operation,
1712
+ deletedId: context.state.deletedId
1713
+ });
1714
+ }
1715
+ });
1716
+
1717
+ assert.equal(result.deleted, true);
1718
+ assert.equal(afterWriteCalls.length, 1);
1719
+ assert.equal(afterWriteCalls[0].operation, "delete");
1720
+ assert.equal(afterWriteCalls[0].deletedId, 3);
1721
+ });
1722
+
1723
+ test("createCrudRepositoryFromResource delete hooks support finalizeOutput for record and null flows", async () => {
1724
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture());
1725
+ const presentKnex = createListKnexDouble([
1726
+ {
1727
+ contact_id: 3,
1728
+ first_name: "Tony"
1729
+ }
1730
+ ]);
1731
+ const missingKnex = createListKnexDouble([]);
1732
+ const presentRepository = createRepository(presentKnex.knex);
1733
+ const missingRepository = createRepository(missingKnex.knex);
1734
+
1735
+ const deletedOutput = await presentRepository.deleteById(3, {}, {
1736
+ finalizeOutput(output) {
1737
+ return output
1738
+ ? { ...output, status: "deleted" }
1739
+ : output;
1740
+ }
1741
+ });
1742
+
1743
+ const missingOutput = await missingRepository.deleteById(3, {}, {
1744
+ finalizeOutput(output) {
1745
+ return output === null ? { deleted: false } : output;
1746
+ }
1747
+ });
1748
+
1749
+ assert.equal(deletedOutput.status, "deleted");
1750
+ assert.deepEqual(missingOutput, { deleted: false });
1751
+ });