@jskit-ai/json-rest-api-core 0.1.129 → 0.1.130

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/json-rest-api-core",
3
- "version": "0.1.129",
3
+ "version": "0.1.130",
4
4
  "type": "module",
5
+ "engines": {
6
+ "node": ">=24.0.0"
7
+ },
5
8
  "scripts": {
6
9
  "test": "node --test"
7
10
  },
@@ -9,10 +12,9 @@
9
12
  "./server/jsonRestApiHost": "./src/server/jsonRestApiHost.js"
10
13
  },
11
14
  "dependencies": {
12
- "@jskit-ai/database-runtime": "0.1.185",
13
- "@jskit-ai/resource-crud-core": "0.1.127",
14
- "hooked-api": "1.x.x",
15
- "json-rest-api": "^1.0.29"
15
+ "@jskit-ai/database-runtime": "0.1.186",
16
+ "@jskit-ai/resource-crud-core": "0.1.128",
17
+ "json-rest-api": "git+https://github.com/mobily-enterprises/json-rest-api.git#f97dc859321aee41915f3b0e256a861c382bc1b3"
16
18
  },
17
19
  "description": "Shared internal json-rest-api host runtime with autofilter, query-projection, and row-policy support.",
18
20
  "jskit": {
@@ -50,6 +52,6 @@
50
52
  }
51
53
  },
52
54
  "peerDependencies": {
53
- "@jskit-ai/kernel": "0.1.185"
55
+ "@jskit-ai/kernel": "0.1.186"
54
56
  }
55
57
  }
@@ -1,6 +1,6 @@
1
- import { Api } from "hooked-api";
2
1
  import {
3
2
  AutoFilterPlugin,
3
+ JsonRestApi,
4
4
  QueryProjectionsPlugin,
5
5
  REST_API_FIELDSET_ERROR_CODE,
6
6
  RestApiKnexPlugin,
@@ -51,9 +51,9 @@ const JSON_REST_RESERVED_QUERY_KEYS = Object.freeze(new Set([
51
51
  "sort",
52
52
  "fields"
53
53
  ]));
54
- const JSON_REST_CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
55
- const JSON_REST_DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/u;
56
- const JSON_REST_DATABASE_UTC_DATE_TIME_PATTERN = /^(\d{4}-\d{2}-\d{2}) ((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?)$/u;
54
+ const JSON_REST_DEFAULT_LOGGER = Object.freeze({
55
+ error: (...args) => console.error(...args)
56
+ });
57
57
 
58
58
  function isPlainJsonRestObject(value) {
59
59
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -106,132 +106,6 @@ function cloneJsonRestResourceValue(value, { writeSerializers = {} } = {}) {
106
106
  return next;
107
107
  }
108
108
 
109
- function resolveCanonicalCalendarDate(value) {
110
- if (value instanceof Date) {
111
- return Number.isNaN(value.getTime())
112
- ? ""
113
- : value.toISOString().slice(0, 10);
114
- }
115
-
116
- const normalized = typeof value === "string" ? value.trim() : "";
117
- const candidate = normalized.match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/u)?.[1] || "";
118
- if (!JSON_REST_CALENDAR_DATE_PATTERN.test(candidate)) {
119
- return "";
120
- }
121
-
122
- const parsed = new Date(`${candidate}T00:00:00.000Z`);
123
- if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== candidate) {
124
- return "";
125
- }
126
-
127
- return candidate;
128
- }
129
-
130
- function serializeJsonRestCalendarDate(value) {
131
- if (value == null) {
132
- return value;
133
- }
134
-
135
- const canonical = resolveCanonicalCalendarDate(value);
136
- if (!canonical) {
137
- throw new TypeError("json-rest-api calendar date must be a valid YYYY-MM-DD value.");
138
- }
139
- return canonical;
140
- }
141
-
142
- function resolveCanonicalDateTime(value) {
143
- if (value instanceof Date) {
144
- return Number.isNaN(value.getTime()) ? "" : value.toISOString();
145
- }
146
-
147
- const normalized = typeof value === "string" ? value.trim() : "";
148
- if (JSON_REST_DATE_TIME_PATTERN.test(normalized)) {
149
- return normalized;
150
- }
151
-
152
- const databaseMatch = JSON_REST_DATABASE_UTC_DATE_TIME_PATTERN.exec(normalized);
153
- return databaseMatch ? `${databaseMatch[1]}T${databaseMatch[2]}Z` : "";
154
- }
155
-
156
- function applyJsonRestCalendarDateWriteSerializers(scopeOptions = {}) {
157
- const schema = normalizeJsonRestObject(scopeOptions.schema);
158
- for (const fieldDefinition of Object.values(schema)) {
159
- if (normalizeJsonRestText(fieldDefinition?.type).toLowerCase() !== "date") {
160
- continue;
161
- }
162
- if (fieldDefinition?.virtual === true || fieldDefinition?.storage?.virtual === true) {
163
- continue;
164
- }
165
-
166
- const storage = normalizeJsonRestObject(fieldDefinition.storage);
167
- if (typeof storage.serialize === "function") {
168
- continue;
169
- }
170
- fieldDefinition.storage = {
171
- ...storage,
172
- serialize: serializeJsonRestCalendarDate
173
- };
174
- }
175
- }
176
-
177
- function normalizeJsonRestTemporalEntry(entry = null, scopes = {}) {
178
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
179
- return entry;
180
- }
181
-
182
- const schema = normalizeJsonRestObject(
183
- scopes?.[normalizeJsonRestText(entry.type)]?.vars?.schemaInfo?.schemaStructure
184
- );
185
- const attributes = normalizeJsonRestObject(entry.attributes);
186
- for (const [fieldName, definition] of Object.entries(schema)) {
187
- if (!Object.hasOwn(attributes, fieldName) || attributes[fieldName] == null) {
188
- continue;
189
- }
190
-
191
- const fieldType = normalizeJsonRestText(definition?.type).toLowerCase();
192
- const canonical = fieldType === "date"
193
- ? resolveCanonicalCalendarDate(attributes[fieldName])
194
- : fieldType === "datetime"
195
- ? resolveCanonicalDateTime(attributes[fieldName])
196
- : "";
197
- if (canonical) {
198
- attributes[fieldName] = canonical;
199
- }
200
- }
201
-
202
- return entry;
203
- }
204
-
205
- function normalizeJsonRestTemporalDocument(document = null, scopes = {}) {
206
- if (!document || typeof document !== "object" || Array.isArray(document)) {
207
- return document;
208
- }
209
-
210
- const data = Array.isArray(document.data) ? document.data : [document.data];
211
- for (const entry of data) {
212
- normalizeJsonRestTemporalEntry(entry, scopes);
213
- }
214
- for (const entry of Array.isArray(document.included) ? document.included : []) {
215
- normalizeJsonRestTemporalEntry(entry, scopes);
216
- }
217
- return document;
218
- }
219
-
220
- const JsonRestTemporalPlugin = Object.freeze({
221
- name: "jskit-temporal",
222
- dependencies: ["rest-api"],
223
- install({ addHook, scopes }) {
224
- addHook("finish", "normalizeCalendarDateDocuments", {}, ({ context }) => {
225
- if (context?.record) {
226
- normalizeJsonRestTemporalDocument(context.record, scopes);
227
- }
228
- if (context?.responseRecord) {
229
- normalizeJsonRestTemporalDocument(context.responseRecord, scopes);
230
- }
231
- });
232
- }
233
- });
234
-
235
109
  async function addResourceIfMissing(api, scopeName, resourceConfig) {
236
110
  if (api?.resources?.[scopeName]) {
237
111
  return api.resources[scopeName];
@@ -572,94 +446,11 @@ function buildJsonRestQueryParams(resourceType = "", query = {}, { include = und
572
446
  return queryParams;
573
447
  }
574
448
 
575
- function extractJsonRestCollectionRows(payload = null) {
576
- if (Array.isArray(payload)) {
577
- return payload;
449
+ function extractJsonRestCollectionRows(payload) {
450
+ if (!isPlainJsonRestObject(payload) || !Array.isArray(payload.data)) {
451
+ throw new TypeError("json-rest-api collection response must contain a data array.");
578
452
  }
579
-
580
- const source = normalizeJsonRestObject(payload);
581
- return Array.isArray(source.data) ? source.data : [];
582
- }
583
-
584
- function extractJsonApiInputRelationships(attributes = {}, resource = null, relationships = null) {
585
- const normalizedAttributes = {
586
- ...normalizeJsonRestObject(attributes)
587
- };
588
- const normalizedRelationships = {
589
- ...normalizeJsonRestObject(relationships)
590
- };
591
- const resourceSchema = normalizeJsonRestObject(resource?.schema);
592
-
593
- for (const [fieldName, fieldDefinition] of Object.entries(resourceSchema)) {
594
- if (!Object.hasOwn(normalizedAttributes, fieldName)) {
595
- continue;
596
- }
597
-
598
- const normalizedFieldDefinition = normalizeJsonRestObject(fieldDefinition);
599
- const relationshipType = normalizeJsonRestText(normalizedFieldDefinition.belongsTo);
600
- if (!relationshipType) {
601
- continue;
602
- }
603
-
604
- const relationshipName = normalizeJsonRestText(normalizedFieldDefinition.as, {
605
- fallback: fieldName
606
- });
607
- if (!relationshipName) {
608
- continue;
609
- }
610
-
611
- const relationshipValue = normalizedAttributes[fieldName];
612
- delete normalizedAttributes[fieldName];
613
-
614
- if (relationshipValue === undefined) {
615
- continue;
616
- }
617
-
618
- if (!Object.hasOwn(normalizedRelationships, relationshipName)) {
619
- normalizedRelationships[relationshipName] = createJsonApiRelationship(
620
- relationshipType,
621
- relationshipValue
622
- );
623
- }
624
- }
625
-
626
- return {
627
- attributes: normalizedAttributes,
628
- relationships: normalizedRelationships
629
- };
630
- }
631
-
632
- function createJsonApiInputRecord(
633
- resourceType = "",
634
- attributes = {},
635
- { id = null, relationships = null, resource = null } = {}
636
- ) {
637
- const normalizedInput = extractJsonApiInputRelationships(attributes, resource, relationships);
638
- return {
639
- data: {
640
- type: normalizeJsonRestText(resourceType),
641
- ...(id == null ? {} : { id: String(id) }),
642
- attributes: normalizedInput.attributes,
643
- ...(Object.keys(normalizedInput.relationships).length < 1
644
- ? {}
645
- : { relationships: normalizedInput.relationships })
646
- }
647
- };
648
- }
649
-
650
- function createJsonApiRelationship(resourceType = "", id = null) {
651
- if (id == null) {
652
- return {
653
- data: null
654
- };
655
- }
656
-
657
- return {
658
- data: {
659
- type: normalizeJsonRestText(resourceType),
660
- id: String(id)
661
- }
662
- };
453
+ return payload.data;
663
454
  }
664
455
 
665
456
  function createJsonRestResourceScopeOptions(
@@ -675,7 +466,6 @@ function createJsonRestResourceScopeOptions(
675
466
  const scopeOptions = cloneJsonRestResourceValue(resource, {
676
467
  writeSerializers: normalizeJsonRestObject(writeSerializers)
677
468
  });
678
- applyJsonRestCalendarDateWriteSerializers(scopeOptions);
679
469
  if (isPlainJsonRestObject(searchSchema)) {
680
470
  scopeOptions.searchSchema = {
681
471
  ...normalizeJsonRestObject(scopeOptions.searchSchema),
@@ -767,7 +557,8 @@ async function returnNullWhenJsonRestResourceMissing(run) {
767
557
  try {
768
558
  return await run();
769
559
  } catch (error) {
770
- if (isJsonRestResourceMissingError(error)) {
560
+ if (isJsonRestResourceMissingError(error) &&
561
+ (!error.transactionOutcome || ["none", "rolledBack"].includes(error.transactionOutcome))) {
771
562
  return null;
772
563
  }
773
564
 
@@ -791,32 +582,30 @@ async function returnBadRequestWhenJsonRestFieldsetInvalid(run) {
791
582
  throw error;
792
583
  }
793
584
 
794
- throw new AppError(400, error.message, {
585
+ const mapped = new AppError(400, error.message, {
795
586
  code: "JSON_API_FIELDSET_INVALID"
796
587
  });
588
+ Object.defineProperty(mapped, "cause", { value: error, configurable: true });
589
+ if (error.transactionOutcome) {
590
+ mapped.transactionOutcome = error.transactionOutcome;
591
+ }
592
+ throw mapped;
797
593
  }
798
594
  }
799
595
 
800
- async function createJsonRestApiHost({ knex }) {
596
+ async function createJsonRestApiHost({ knex, logger = JSON_REST_DEFAULT_LOGGER }) {
801
597
  if (typeof knex !== "function") {
802
598
  throw new TypeError("createJsonRestApiHost requires knex.");
803
599
  }
804
600
 
805
- const api = new Api({
601
+ const api = new JsonRestApi({
806
602
  name: "jskit-internal-json-rest-api",
807
- logging: {
808
- level: "error"
809
- }
603
+ logger
810
604
  });
811
605
 
812
606
  await api.use(RestApiPlugin, {
813
- simplifiedApi: true,
814
- simplifiedTransport: false,
815
- returnRecordApi: {
816
- post: "full",
817
- put: "full",
818
- patch: "full"
819
- },
607
+ format: "plain",
608
+ returning: "full",
820
609
  normalizeId: normalizeRecordId
821
610
  });
822
611
 
@@ -830,7 +619,6 @@ async function createJsonRestApiHost({ knex }) {
830
619
  },
831
620
  presets: JSON_REST_AUTOFILTER_PRESETS
832
621
  });
833
- await api.use(JsonRestTemporalPlugin);
834
622
 
835
623
  return api;
836
624
  }
@@ -839,8 +627,6 @@ export {
839
627
  JSON_REST_AUTOFILTER_PRESETS,
840
628
  addResourceIfMissing,
841
629
  buildJsonRestQueryParams,
842
- createJsonApiInputRecord,
843
- createJsonApiRelationship,
844
630
  createJsonRestResourceScopeOptions,
845
631
  createJsonRestContext,
846
632
  extractJsonRestCollectionRows,
@@ -2,14 +2,13 @@ import assert from "node:assert/strict";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import test from "node:test";
5
+ import knexLib from "knex";
5
6
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
6
- import { RestApiFieldsetError } from "json-rest-api";
7
+ import { JsonRestApi, RestApiFieldsetError, RestApiWriteError } from "json-rest-api";
7
8
 
8
9
  import {
9
10
  addResourceIfMissing,
10
11
  buildJsonRestQueryParams,
11
- createJsonApiInputRecord,
12
- createJsonApiRelationship,
13
12
  createJsonRestResourceScopeOptions,
14
13
  createJsonRestContext,
15
14
  createJsonRestApiHost,
@@ -27,19 +26,18 @@ test("package exports include explicit server jsonRestApiHost entrypoint only",
27
26
  const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
28
27
  assert.equal(exportsMap["./server/jsonRestApiHost"], "./src/server/jsonRestApiHost.js");
29
28
  assert.equal(exportsMap["./server"], undefined);
30
- assert.equal(packageJson.dependencies?.["json-rest-api"], "^1.0.29");
31
29
  });
32
30
 
33
31
  test("server jsonRestApiHost entrypoint exposes only the focused host API", async () => {
34
32
  const hostModule = await import("../src/server/jsonRestApiHost.js");
35
33
  assert.equal(Object.hasOwn(hostModule, "simplifyJsonApiDocument"), false);
34
+ assert.equal(Object.hasOwn(hostModule, "createJsonApiInputRecord"), false);
35
+ assert.equal(Object.hasOwn(hostModule, "createJsonApiRelationship"), false);
36
36
  });
37
37
 
38
38
  test("server entrypoint exports shared host helpers", () => {
39
39
  assert.equal(typeof addResourceIfMissing, "function");
40
40
  assert.equal(typeof buildJsonRestQueryParams, "function");
41
- assert.equal(typeof createJsonApiInputRecord, "function");
42
- assert.equal(typeof createJsonApiRelationship, "function");
43
41
  assert.equal(typeof createJsonRestResourceScopeOptions, "function");
44
42
  assert.equal(typeof createJsonRestContext, "function");
45
43
  assert.equal(typeof createJsonRestApiHost, "function");
@@ -95,7 +93,6 @@ test("createJsonRestContext returns an empty mutable object when source context
95
93
  test("extractJsonRestCollectionRows understands the internal collection-document contract", () => {
96
94
  const rows = [{ id: "1" }, { id: "2" }];
97
95
 
98
- assert.deepEqual(extractJsonRestCollectionRows(rows), rows);
99
96
  assert.deepEqual(
100
97
  extractJsonRestCollectionRows({
101
98
  data: rows,
@@ -105,8 +102,10 @@ test("extractJsonRestCollectionRows understands the internal collection-document
105
102
  }),
106
103
  rows
107
104
  );
108
- assert.deepEqual(extractJsonRestCollectionRows({ data: null }), []);
109
- assert.deepEqual(extractJsonRestCollectionRows(null), []);
105
+ assert.deepEqual(extractJsonRestCollectionRows({ data: [] }), []);
106
+ for (const malformed of [rows, { data: null }, {}, null, undefined]) {
107
+ assert.throws(() => extractJsonRestCollectionRows(malformed), /collection response must contain a data array/);
108
+ }
110
109
  });
111
110
 
112
111
  test("createJsonRestApiHost installs normalizeRecordId as the default resource id normalizer", async () => {
@@ -136,7 +135,7 @@ test("createJsonRestApiHost installs normalizeRecordId as the default resource i
136
135
  assert.equal(api.vars.normalizeId(7.5), null);
137
136
  });
138
137
 
139
- test("createJsonRestApiHost configures the internal json-rest logger at error level", async () => {
138
+ test("createJsonRestApiHost uses the v2 runtime, error-only default logging and an injected logger", async (t) => {
140
139
  const fakeKnex = Object.assign(() => {}, {
141
140
  client: {
142
141
  config: {
@@ -153,11 +152,25 @@ test("createJsonRestApiHost configures the internal json-rest logger at error le
153
152
  transaction() {}
154
153
  });
155
154
 
155
+ const messages = [];
156
156
  const api = await createJsonRestApiHost({
157
- knex: fakeKnex
157
+ knex: fakeKnex,
158
+ logger: { error: (...args) => messages.push(args) }
158
159
  });
159
160
 
160
- assert.equal(api.options.logging.level, "error");
161
+ assert.ok(api instanceof JsonRestApi);
162
+ assert.equal(api.vars.format, "plain");
163
+ assert.equal(api.vars.returning, "full");
164
+ assert.equal(api.options.logging, undefined);
165
+ api.log.error("failure", { code: "example" });
166
+ assert.deepEqual(messages, [["failure", { code: "example" }]]);
167
+
168
+ const errorSink = t.mock.method(console, "error", () => {});
169
+ const defaultApi = await createJsonRestApiHost({ knex: fakeKnex });
170
+ defaultApi.log.info("quiet");
171
+ defaultApi.log.error("default failure");
172
+ assert.equal(errorSink.mock.callCount(), 1);
173
+ assert.deepEqual(errorSink.mock.calls[0].arguments, ["default failure"]);
161
174
  });
162
175
 
163
176
  test("createJsonRestApiHost installs row policies before resources are added", async () => {
@@ -200,7 +213,7 @@ test("createJsonRestApiHost installs row policies before resources are added", a
200
213
  assert.equal(scopeOptions.rowPolicy, rowPolicy);
201
214
  });
202
215
 
203
- test("shared query/document helpers build json-rest-api request shapes", () => {
216
+ test("shared query helper builds json-rest-api request shapes", () => {
204
217
  assert.deepEqual(
205
218
  buildJsonRestQueryParams("contacts", {
206
219
  q: "Merc",
@@ -232,70 +245,6 @@ test("shared query/document helpers build json-rest-api request shapes", () => {
232
245
  }
233
246
  );
234
247
 
235
- assert.deepEqual(
236
- createJsonApiInputRecord("contacts", {
237
- name: "Merc"
238
- }, {
239
- id: 7,
240
- relationships: {
241
- workspace: createJsonApiRelationship("workspaces", 9)
242
- }
243
- }),
244
- {
245
- data: {
246
- type: "contacts",
247
- id: "7",
248
- attributes: {
249
- name: "Merc"
250
- },
251
- relationships: {
252
- workspace: {
253
- data: {
254
- type: "workspaces",
255
- id: "9"
256
- }
257
- }
258
- }
259
- }
260
- }
261
- );
262
-
263
- assert.deepEqual(
264
- createJsonApiInputRecord("products", {
265
- serviceId: "9",
266
- name: "Style Groom"
267
- }, {
268
- resource: {
269
- schema: {
270
- serviceId: {
271
- type: "id",
272
- belongsTo: "services",
273
- as: "service"
274
- },
275
- name: {
276
- type: "string"
277
- }
278
- }
279
- }
280
- }),
281
- {
282
- data: {
283
- type: "products",
284
- attributes: {
285
- name: "Style Groom"
286
- },
287
- relationships: {
288
- service: {
289
- data: {
290
- type: "services",
291
- id: "9"
292
- }
293
- }
294
- }
295
- }
296
- }
297
- );
298
-
299
248
  });
300
249
 
301
250
  test("buildJsonRestQueryParams preserves structured filter values", () => {
@@ -424,16 +373,7 @@ test("createJsonRestResourceScopeOptions clones canonical resource metadata and
424
373
  assert.equal(result.schema.createdAt.storage.serialize, serializer);
425
374
  assert.equal(result.schema.createdAt.storage.serialize(null), null);
426
375
  assert.equal(result.schema.createdAt.storage.writeSerializer, undefined);
427
- assert.equal(
428
- result.schema.publishedOn.storage.serialize(new Date("2024-02-29T00:00:00.000Z")),
429
- "2024-02-29"
430
- );
431
- assert.equal(result.schema.publishedOn.storage.serialize("2024-02-29"), "2024-02-29");
432
- assert.equal(result.schema.publishedOn.storage.serialize(null), null);
433
- assert.throws(
434
- () => result.schema.publishedOn.storage.serialize("2023-02-29"),
435
- /valid YYYY-MM-DD/
436
- );
376
+ assert.equal(result.schema.publishedOn.storage, undefined);
437
377
  assert.equal(result.schema.bookingSteps.virtual, true);
438
378
  assert.equal(result.schema.pets.virtual, true);
439
379
  assert.equal(result.normalizeId, normalizeId);
@@ -604,22 +544,18 @@ test("createJsonRestResourceScopeOptions rejects query field names for column-ba
604
544
  );
605
545
  });
606
546
 
607
- test("createJsonRestApiHost installs json-rest-api query projections", async () => {
608
- const fakeKnex = Object.assign(() => {}, {
609
- client: {
610
- config: {
611
- client: "sqlite3"
612
- }
613
- },
614
- raw(sql) {
615
- if (String(sql || "").includes("sqlite_version")) {
616
- return [{ version: "3.35.5" }];
617
- }
618
- return { sql };
619
- },
620
- async transaction() {}
547
+ test("createJsonRestApiHost installs json-rest-api query projections", async (t) => {
548
+ const knex = knexLib({
549
+ client: "better-sqlite3",
550
+ connection: { filename: ":memory:" },
551
+ useNullAsDefault: true
621
552
  });
622
- const api = await createJsonRestApiHost({ knex: fakeKnex });
553
+ t.after(() => knex.destroy());
554
+ await knex.schema.createTable("projection_contacts", (table) => {
555
+ table.increments("id");
556
+ });
557
+ await knex("projection_contacts").insert({ id: 1 });
558
+ const api = await createJsonRestApiHost({ knex });
623
559
 
624
560
  await api.addResource("projectionContacts", {
625
561
  tableName: "projection_contacts",
@@ -636,130 +572,119 @@ test("createJsonRestApiHost installs json-rest-api query projections", async ()
636
572
  }
637
573
  });
638
574
 
639
- assert.equal(typeof api.resources.projectionContacts.vars.queryFields.displayName.select, "function");
575
+ const result = await api.resources.projectionContacts.query({ format: "plain" });
576
+ assert.deepEqual(result.data, [{ id: "1", displayName: "Display" }]);
640
577
  });
641
578
 
642
- test("createJsonRestApiHost returns JSON-native temporal values from database records", async () => {
643
- const fakeKnex = Object.assign(() => {}, {
644
- client: {
645
- config: {
646
- client: "sqlite3"
647
- }
648
- },
649
- async raw() {
650
- return [{ version: "3.35.5" }];
651
- },
652
- transaction() {}
579
+ async function createTemporalFixture() {
580
+ const knex = knexLib({
581
+ client: "better-sqlite3",
582
+ connection: { filename: ":memory:" },
583
+ useNullAsDefault: true
653
584
  });
654
- const api = await createJsonRestApiHost({ knex: fakeKnex });
655
-
656
- await api.addResource("books", createJsonRestResourceScopeOptions({
657
- tableName: "books",
658
- schema: {
659
- id: { type: "id", primary: true },
660
- publishedOn: { type: "date" },
661
- scheduledAt: { type: "dateTime" },
662
- opensAt: { type: "time" }
663
- }
664
- }));
665
- await api.addResource("holidays", createJsonRestResourceScopeOptions({
666
- tableName: "holidays",
667
- schema: {
668
- id: { type: "id", primary: true },
669
- observedOn: { type: "date" }
670
- }
671
- }));
672
-
673
- const scheduledAt = new Date("2024-02-29T12:34:56.000Z");
674
- const record = {
675
- data: {
676
- type: "books",
677
- id: "1",
678
- attributes: {
679
- publishedOn: new Date("2024-02-29T00:00:00.000Z"),
680
- scheduledAt,
681
- opensAt: "09:45:00"
585
+ try {
586
+ await knex.schema.createTable("holidays", (table) => {
587
+ table.increments("id");
588
+ table.date("observed_on");
589
+ });
590
+ await knex.schema.createTable("books", (table) => {
591
+ table.increments("id");
592
+ table.date("published_on");
593
+ table.dateTime("scheduled_at");
594
+ table.time("opens_at");
595
+ table.integer("holiday_id").references("id").inTable("holidays");
596
+ });
597
+ const api = await createJsonRestApiHost({ knex });
598
+ await api.addResource("holidays", createJsonRestResourceScopeOptions({
599
+ tableName: "holidays",
600
+ schema: {
601
+ id: { type: "id", primary: true },
602
+ observedOn: { type: "date" }
682
603
  }
683
- },
684
- included: [{
685
- type: "holidays",
686
- id: "7",
687
- attributes: {
688
- observedOn: new Date("2024-03-01T00:00:00.000Z")
604
+ }));
605
+ await api.addResource("books", createJsonRestResourceScopeOptions({
606
+ tableName: "books",
607
+ schema: {
608
+ id: { type: "id", primary: true },
609
+ publishedOn: { type: "date" },
610
+ scheduledAt: { type: "dateTime" },
611
+ opensAt: { type: "time" },
612
+ holidayId: { type: "id", belongsTo: "holidays", as: "holiday", nullable: true }
689
613
  }
690
- }]
691
- };
614
+ }));
615
+ return { api, knex };
616
+ } catch (error) {
617
+ await knex.destroy();
618
+ throw error;
619
+ }
620
+ }
692
621
 
693
- await api.runHooks("finish", {
694
- record
622
+ test("createJsonRestApiHost uses core temporal normalization for writes, reads and includes", async (t) => {
623
+ const { api, knex } = await createTemporalFixture();
624
+ t.after(() => knex.destroy());
625
+ const holiday = await api.resources.holidays.post({ data: { observedOn: "2024-03-01" } });
626
+ const book = await api.resources.books.post({
627
+ data: {
628
+ publishedOn: "2024-02-29",
629
+ scheduledAt: "2024-02-29T20:34:56+08:00",
630
+ opensAt: "09:45",
631
+ holiday: holiday.id
632
+ }
695
633
  });
696
634
 
697
- assert.equal(record.data.attributes.publishedOn, "2024-02-29");
698
- assert.equal(record.data.attributes.scheduledAt, "2024-02-29T12:34:56.000Z");
699
- assert.equal(record.data.attributes.opensAt, "09:45:00");
700
- assert.equal(record.included[0].attributes.observedOn, "2024-03-01");
635
+ assert.equal(book.publishedOn, "2024-02-29");
636
+ assert.equal(book.scheduledAt, "2024-02-29T12:34:56.000Z");
637
+ assert.equal(book.opensAt, "09:45:00");
638
+ const document = await api.resources.books.get({
639
+ id: book.id,
640
+ format: "jsonapi",
641
+ queryParams: { include: ["holiday"] }
642
+ });
643
+ assert.equal(document.data.attributes.publishedOn, "2024-02-29");
644
+ assert.equal(document.data.attributes.scheduledAt, "2024-02-29T12:34:56.000Z");
645
+ assert.equal(document.included[0].attributes.observedOn, "2024-03-01");
646
+ await assert.rejects(
647
+ () => api.resources.books.patch({ id: book.id, data: { publishedOn: "2023-02-29" } }),
648
+ (error) => error.code === "REST_API_VALIDATION"
649
+ );
701
650
  });
702
651
 
703
652
  test("calendar date writes and responses keep leap day across server time zones", () => {
704
- const hostModuleUrl = new URL(
705
- "../src/server/jsonRestApiHost.js",
706
- import.meta.url
707
- ).href;
653
+ const hostModuleUrl = new URL("../src/server/jsonRestApiHost.js", import.meta.url).href;
708
654
  const script = `
709
- import {
710
- createJsonRestApiHost,
711
- createJsonRestResourceScopeOptions
712
- } from ${JSON.stringify(hostModuleUrl)};
713
- const fakeKnex = Object.assign(() => {}, {
714
- client: { config: { client: "sqlite3" } },
715
- async raw() { return [{ version: "3.35.5" }]; },
716
- transaction() {}
717
- });
718
- const api = await createJsonRestApiHost({ knex: fakeKnex });
719
- const scopeOptions = createJsonRestResourceScopeOptions({
720
- tableName: "books",
721
- schema: {
722
- id: { type: "id", primary: true },
723
- publishedOn: { type: "date" },
724
- scheduledAt: { type: "dateTime" },
725
- opensAt: { type: "time" }
726
- }
727
- });
728
- await api.addResource("books", scopeOptions);
729
- const record = {
730
- data: {
731
- type: "books",
732
- id: "1",
733
- attributes: {
734
- publishedOn: new Date("2024-02-29T00:00:00.000Z"),
735
- scheduledAt: new Date("2024-02-29T12:34:56.000Z"),
655
+ import knexLib from "knex";
656
+ import { createJsonRestApiHost, createJsonRestResourceScopeOptions } from ${JSON.stringify(hostModuleUrl)};
657
+ ${createTemporalFixture.toString()}
658
+ const { api, knex } = await createTemporalFixture();
659
+ try {
660
+ const book = await api.resources.books.post({
661
+ data: {
662
+ publishedOn: "2024-02-29",
663
+ scheduledAt: "2024-02-29T12:34:56.000Z",
736
664
  opensAt: "09:45:00"
737
665
  }
738
- }
739
- };
740
- await api.runHooks("finish", { record });
741
- process.stdout.write(JSON.stringify({
742
- write: scopeOptions.schema.publishedOn.storage.serialize(
743
- new Date("2024-02-29T00:00:00.000Z")
744
- ),
745
- publishedOn: record.data.attributes.publishedOn,
746
- scheduledAt: record.data.attributes.scheduledAt,
747
- opensAt: record.data.attributes.opensAt
748
- }));
666
+ });
667
+ const row = await knex("books").where({ id: book.id }).first();
668
+ process.stdout.write(JSON.stringify({
669
+ write: row.published_on,
670
+ publishedOn: book.publishedOn,
671
+ scheduledAt: book.scheduledAt,
672
+ opensAt: book.opensAt
673
+ }));
674
+ } finally {
675
+ await knex.destroy();
676
+ }
749
677
  `;
750
678
 
751
679
  for (const timezone of ["UTC", "Australia/Perth", "America/Los_Angeles", "Pacific/Kiritimati"]) {
752
680
  const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], {
753
681
  encoding: "utf8",
754
- env: {
755
- ...process.env,
756
- TZ: timezone
757
- }
682
+ env: { ...process.env, TZ: timezone }
758
683
  });
759
684
 
760
685
  assert.equal(result.status, 0, result.stderr);
761
686
  assert.deepEqual(JSON.parse(result.stdout), {
762
- write: "2024-02-29",
687
+ write: Date.parse("2024-02-29T00:00:00.000Z"),
763
688
  publishedOn: "2024-02-29",
764
689
  scheduledAt: "2024-02-29T12:34:56.000Z",
765
690
  opensAt: "09:45:00"
@@ -767,36 +692,17 @@ test("calendar date writes and responses keep leap day across server time zones"
767
692
  }
768
693
  });
769
694
 
770
- test("createJsonRestApiHost maps UTC database datetimes to RFC 3339 strings", async () => {
771
- const fakeKnex = Object.assign(() => {}, {
772
- client: { config: { client: "sqlite3" } },
773
- async raw() {
774
- return [{ version: "3.35.5" }];
775
- },
776
- transaction() {}
695
+ test("createJsonRestApiHost maps native database datetime strings through core reads", async (t) => {
696
+ const { api, knex } = await createTemporalFixture();
697
+ t.after(() => knex.destroy());
698
+ await knex("books").insert({
699
+ id: 1,
700
+ published_on: "2024-02-29",
701
+ scheduled_at: "2024-02-29 12:34:56.123",
702
+ opens_at: "09:45:00"
777
703
  });
778
- const api = await createJsonRestApiHost({ knex: fakeKnex });
779
-
780
- await api.addResource("jobs", createJsonRestResourceScopeOptions({
781
- tableName: "jobs",
782
- schema: {
783
- id: { type: "id", primary: true },
784
- scheduledAt: { type: "dateTime" }
785
- }
786
- }));
787
- const record = {
788
- data: {
789
- type: "jobs",
790
- id: "1",
791
- attributes: {
792
- scheduledAt: "2024-02-29 12:34:56.123"
793
- }
794
- }
795
- };
796
-
797
- await api.runHooks("finish", { record });
798
-
799
- assert.equal(record.data.attributes.scheduledAt, "2024-02-29T12:34:56.123Z");
704
+ const book = await api.resources.books.get({ id: "1" });
705
+ assert.equal(book.scheduledAt, "2024-02-29T12:34:56.123Z");
800
706
  });
801
707
 
802
708
  test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
@@ -841,6 +747,7 @@ test("typed invalid sparse fields become a stable 400 without message matching",
841
747
  assert.equal(error.status, 400);
842
748
  assert.equal(error.code, "JSON_API_FIELDSET_INVALID");
843
749
  assert.equal(error.message, sparseFieldError.message);
750
+ assert.equal(error.cause, sparseFieldError);
844
751
  return true;
845
752
  }
846
753
  );
@@ -862,6 +769,47 @@ test("typed invalid sparse fields become a stable 400 without message matching",
862
769
  );
863
770
  });
864
771
 
772
+ test("missing-resource mapping preserves failures from unresolved or committed writes", async () => {
773
+ const cause = Object.freeze({ code: "REST_API_RESOURCE", subtype: "not_found" });
774
+ for (const transactionOutcome of ["pending", "committed", "unknown"]) {
775
+ const failure = new RestApiWriteError("Missing resource", { cause, transactionOutcome });
776
+ await assert.rejects(
777
+ () => returnNullWhenJsonRestResourceMissing(() => Promise.reject(failure)),
778
+ (error) => error === failure
779
+ );
780
+ }
781
+ for (const transactionOutcome of ["none", "rolledBack"]) {
782
+ const failure = new RestApiWriteError("Missing resource", { cause, transactionOutcome });
783
+ assert.equal(await returnNullWhenJsonRestResourceMissing(() => Promise.reject(failure)), null);
784
+ }
785
+ });
786
+
787
+ test("fieldset mapping retains write outcome and the original error chain", async () => {
788
+ const cause = Object.freeze(new RestApiFieldsetError({ field: "privateNotes", resourceType: "books" }));
789
+ const failure = new RestApiWriteError(cause.message, { cause, transactionOutcome: "committed" });
790
+ await assert.rejects(
791
+ () => returnBadRequestWhenJsonRestFieldsetInvalid(() => Promise.reject(failure)),
792
+ (error) => {
793
+ assert.equal(error.code, "JSON_API_FIELDSET_INVALID");
794
+ assert.equal(error.transactionOutcome, "committed");
795
+ assert.equal(error.cause, failure);
796
+ assert.equal(error.cause.cause, cause);
797
+ return true;
798
+ }
799
+ );
800
+ });
801
+
802
+ test("unrelated primitive failures remain unchanged through host error mapping", async () => {
803
+ for (const failure of [null, undefined, "failure", 7]) {
804
+ for (const mapError of [returnNullWhenJsonRestResourceMissing, returnBadRequestWhenJsonRestFieldsetInvalid]) {
805
+ await assert.rejects(
806
+ () => mapError(() => Promise.reject(failure)),
807
+ (error) => error === failure
808
+ );
809
+ }
810
+ }
811
+ });
812
+
865
813
  test("scope resolvers understand explicit scopeValues and JSKIT visibilityContext", () => {
866
814
  assert.equal(resolveWorkspaceScopeValue({
867
815
  scopeValues: {