@jskit-ai/json-rest-api-core 0.1.90 → 0.1.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/json-rest-api-core",
4
- version: "0.1.90",
4
+ version: "0.1.92",
5
5
  kind: "runtime",
6
6
  description: "Shared internal json-rest-api host runtime with autofilter, query-projection, and row-policy support.",
7
7
  dependsOn: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/json-rest-api-core",
3
- "version": "0.1.90",
3
+ "version": "0.1.92",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,6 +11,6 @@
11
11
  "dependencies": {
12
12
  "hooked-api": "1.x.x",
13
13
  "json-rest-api": "^1.0.27",
14
- "@jskit-ai/kernel": "0.1.146"
14
+ "@jskit-ai/kernel": "0.1.147"
15
15
  }
16
16
  }
@@ -53,6 +53,9 @@ const JSON_REST_RESERVED_QUERY_KEYS = Object.freeze(new Set([
53
53
  "sort",
54
54
  "fields"
55
55
  ]));
56
+ const JSON_REST_CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
57
+ 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;
58
+ 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;
56
59
 
57
60
  function isPlainJsonRestObject(value) {
58
61
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -105,6 +108,132 @@ function cloneJsonRestResourceValue(value, { writeSerializers = {} } = {}) {
105
108
  return next;
106
109
  }
107
110
 
111
+ function resolveCanonicalCalendarDate(value) {
112
+ if (value instanceof Date) {
113
+ return Number.isNaN(value.getTime())
114
+ ? ""
115
+ : value.toISOString().slice(0, 10);
116
+ }
117
+
118
+ const normalized = typeof value === "string" ? value.trim() : "";
119
+ const candidate = normalized.match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/u)?.[1] || "";
120
+ if (!JSON_REST_CALENDAR_DATE_PATTERN.test(candidate)) {
121
+ return "";
122
+ }
123
+
124
+ const parsed = new Date(`${candidate}T00:00:00.000Z`);
125
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== candidate) {
126
+ return "";
127
+ }
128
+
129
+ return candidate;
130
+ }
131
+
132
+ function serializeJsonRestCalendarDate(value) {
133
+ if (value == null) {
134
+ return value;
135
+ }
136
+
137
+ const canonical = resolveCanonicalCalendarDate(value);
138
+ if (!canonical) {
139
+ throw new TypeError("json-rest-api calendar date must be a valid YYYY-MM-DD value.");
140
+ }
141
+ return canonical;
142
+ }
143
+
144
+ function resolveCanonicalDateTime(value) {
145
+ if (value instanceof Date) {
146
+ return Number.isNaN(value.getTime()) ? "" : value.toISOString();
147
+ }
148
+
149
+ const normalized = typeof value === "string" ? value.trim() : "";
150
+ if (JSON_REST_DATE_TIME_PATTERN.test(normalized)) {
151
+ return normalized;
152
+ }
153
+
154
+ const databaseMatch = JSON_REST_DATABASE_UTC_DATE_TIME_PATTERN.exec(normalized);
155
+ return databaseMatch ? `${databaseMatch[1]}T${databaseMatch[2]}Z` : "";
156
+ }
157
+
158
+ function applyJsonRestCalendarDateWriteSerializers(scopeOptions = {}) {
159
+ const schema = normalizeJsonRestObject(scopeOptions.schema);
160
+ for (const fieldDefinition of Object.values(schema)) {
161
+ if (normalizeJsonRestText(fieldDefinition?.type).toLowerCase() !== "date") {
162
+ continue;
163
+ }
164
+ if (fieldDefinition?.virtual === true || fieldDefinition?.storage?.virtual === true) {
165
+ continue;
166
+ }
167
+
168
+ const storage = normalizeJsonRestObject(fieldDefinition.storage);
169
+ if (typeof storage.serialize === "function") {
170
+ continue;
171
+ }
172
+ fieldDefinition.storage = {
173
+ ...storage,
174
+ serialize: serializeJsonRestCalendarDate
175
+ };
176
+ }
177
+ }
178
+
179
+ function normalizeJsonRestTemporalEntry(entry = null, scopes = {}) {
180
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
181
+ return entry;
182
+ }
183
+
184
+ const schema = normalizeJsonRestObject(
185
+ scopes?.[normalizeJsonRestText(entry.type)]?.vars?.schemaInfo?.schemaStructure
186
+ );
187
+ const attributes = normalizeJsonRestObject(entry.attributes);
188
+ for (const [fieldName, definition] of Object.entries(schema)) {
189
+ if (!Object.hasOwn(attributes, fieldName) || attributes[fieldName] == null) {
190
+ continue;
191
+ }
192
+
193
+ const fieldType = normalizeJsonRestText(definition?.type).toLowerCase();
194
+ const canonical = fieldType === "date"
195
+ ? resolveCanonicalCalendarDate(attributes[fieldName])
196
+ : fieldType === "datetime"
197
+ ? resolveCanonicalDateTime(attributes[fieldName])
198
+ : "";
199
+ if (canonical) {
200
+ attributes[fieldName] = canonical;
201
+ }
202
+ }
203
+
204
+ return entry;
205
+ }
206
+
207
+ function normalizeJsonRestTemporalDocument(document = null, scopes = {}) {
208
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
209
+ return document;
210
+ }
211
+
212
+ const data = Array.isArray(document.data) ? document.data : [document.data];
213
+ for (const entry of data) {
214
+ normalizeJsonRestTemporalEntry(entry, scopes);
215
+ }
216
+ for (const entry of Array.isArray(document.included) ? document.included : []) {
217
+ normalizeJsonRestTemporalEntry(entry, scopes);
218
+ }
219
+ return document;
220
+ }
221
+
222
+ const JsonRestTemporalPlugin = Object.freeze({
223
+ name: "jskit-temporal",
224
+ dependencies: ["rest-api"],
225
+ install({ addHook, scopes }) {
226
+ addHook("finish", "normalizeCalendarDateDocuments", {}, ({ context }) => {
227
+ if (context?.record) {
228
+ normalizeJsonRestTemporalDocument(context.record, scopes);
229
+ }
230
+ if (context?.responseRecord) {
231
+ normalizeJsonRestTemporalDocument(context.responseRecord, scopes);
232
+ }
233
+ });
234
+ }
235
+ });
236
+
108
237
  async function addResourceIfMissing(api, scopeName, resourceConfig) {
109
238
  if (api?.resources?.[scopeName]) {
110
239
  return api.resources[scopeName];
@@ -548,6 +677,7 @@ function createJsonRestResourceScopeOptions(
548
677
  const scopeOptions = cloneJsonRestResourceValue(resource, {
549
678
  writeSerializers: normalizeJsonRestObject(writeSerializers)
550
679
  });
680
+ applyJsonRestCalendarDateWriteSerializers(scopeOptions);
551
681
  if (isPlainJsonRestObject(searchSchema)) {
552
682
  scopeOptions.searchSchema = {
553
683
  ...normalizeJsonRestObject(scopeOptions.searchSchema),
@@ -702,6 +832,7 @@ async function createJsonRestApiHost({ knex }) {
702
832
  },
703
833
  presets: JSON_REST_AUTOFILTER_PRESETS
704
834
  });
835
+ await api.use(JsonRestTemporalPlugin);
705
836
 
706
837
  return api;
707
838
  }
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import test from "node:test";
4
5
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
@@ -363,6 +364,14 @@ test("createJsonRestResourceScopeOptions clones canonical resource metadata and
363
364
  })
364
365
  })
365
366
  }),
367
+ publishedOn: Object.freeze({
368
+ type: "date",
369
+ operations: Object.freeze({
370
+ output: Object.freeze({
371
+ required: true
372
+ })
373
+ })
374
+ }),
366
375
  bookingSteps: Object.freeze({
367
376
  type: "array",
368
377
  storage: Object.freeze({
@@ -417,6 +426,16 @@ test("createJsonRestResourceScopeOptions clones canonical resource metadata and
417
426
  assert.equal(result.schema.createdAt.storage.serialize, serializer);
418
427
  assert.equal(result.schema.createdAt.storage.serialize(null), null);
419
428
  assert.equal(result.schema.createdAt.storage.writeSerializer, undefined);
429
+ assert.equal(
430
+ result.schema.publishedOn.storage.serialize(new Date("2024-02-29T00:00:00.000Z")),
431
+ "2024-02-29"
432
+ );
433
+ assert.equal(result.schema.publishedOn.storage.serialize("2024-02-29"), "2024-02-29");
434
+ assert.equal(result.schema.publishedOn.storage.serialize(null), null);
435
+ assert.throws(
436
+ () => result.schema.publishedOn.storage.serialize("2023-02-29"),
437
+ /valid YYYY-MM-DD/
438
+ );
420
439
  assert.equal(result.schema.bookingSteps.virtual, true);
421
440
  assert.equal(result.schema.pets.virtual, true);
422
441
  assert.equal(result.normalizeId, normalizeId);
@@ -622,6 +641,166 @@ test("createJsonRestApiHost installs json-rest-api query projections", async ()
622
641
  assert.equal(typeof api.resources.projectionContacts.vars.queryFields.displayName.select, "function");
623
642
  });
624
643
 
644
+ test("createJsonRestApiHost returns JSON-native temporal values from database records", async () => {
645
+ const fakeKnex = Object.assign(() => {}, {
646
+ client: {
647
+ config: {
648
+ client: "sqlite3"
649
+ }
650
+ },
651
+ async raw() {
652
+ return [{ version: "3.35.5" }];
653
+ },
654
+ transaction() {}
655
+ });
656
+ const api = await createJsonRestApiHost({ knex: fakeKnex });
657
+
658
+ await api.addResource("books", createJsonRestResourceScopeOptions({
659
+ tableName: "books",
660
+ schema: {
661
+ id: { type: "id", primary: true },
662
+ publishedOn: { type: "date" },
663
+ scheduledAt: { type: "dateTime" },
664
+ opensAt: { type: "time" }
665
+ }
666
+ }));
667
+ await api.addResource("holidays", createJsonRestResourceScopeOptions({
668
+ tableName: "holidays",
669
+ schema: {
670
+ id: { type: "id", primary: true },
671
+ observedOn: { type: "date" }
672
+ }
673
+ }));
674
+
675
+ const scheduledAt = new Date("2024-02-29T12:34:56.000Z");
676
+ const record = {
677
+ data: {
678
+ type: "books",
679
+ id: "1",
680
+ attributes: {
681
+ publishedOn: new Date("2024-02-29T00:00:00.000Z"),
682
+ scheduledAt,
683
+ opensAt: "09:45:00"
684
+ }
685
+ },
686
+ included: [{
687
+ type: "holidays",
688
+ id: "7",
689
+ attributes: {
690
+ observedOn: new Date("2024-03-01T00:00:00.000Z")
691
+ }
692
+ }]
693
+ };
694
+
695
+ await api.runHooks("finish", {
696
+ record
697
+ });
698
+
699
+ assert.equal(record.data.attributes.publishedOn, "2024-02-29");
700
+ assert.equal(record.data.attributes.scheduledAt, "2024-02-29T12:34:56.000Z");
701
+ assert.equal(record.data.attributes.opensAt, "09:45:00");
702
+ assert.equal(record.included[0].attributes.observedOn, "2024-03-01");
703
+ });
704
+
705
+ test("calendar date writes and responses keep leap day across server time zones", () => {
706
+ const hostModuleUrl = new URL(
707
+ "../src/server/jsonRestApiHost.js",
708
+ import.meta.url
709
+ ).href;
710
+ const script = `
711
+ import {
712
+ createJsonRestApiHost,
713
+ createJsonRestResourceScopeOptions
714
+ } from ${JSON.stringify(hostModuleUrl)};
715
+ const fakeKnex = Object.assign(() => {}, {
716
+ client: { config: { client: "sqlite3" } },
717
+ async raw() { return [{ version: "3.35.5" }]; },
718
+ transaction() {}
719
+ });
720
+ const api = await createJsonRestApiHost({ knex: fakeKnex });
721
+ const scopeOptions = createJsonRestResourceScopeOptions({
722
+ tableName: "books",
723
+ schema: {
724
+ id: { type: "id", primary: true },
725
+ publishedOn: { type: "date" },
726
+ scheduledAt: { type: "dateTime" },
727
+ opensAt: { type: "time" }
728
+ }
729
+ });
730
+ await api.addResource("books", scopeOptions);
731
+ const record = {
732
+ data: {
733
+ type: "books",
734
+ id: "1",
735
+ attributes: {
736
+ publishedOn: new Date("2024-02-29T00:00:00.000Z"),
737
+ scheduledAt: new Date("2024-02-29T12:34:56.000Z"),
738
+ opensAt: "09:45:00"
739
+ }
740
+ }
741
+ };
742
+ await api.runHooks("finish", { record });
743
+ process.stdout.write(JSON.stringify({
744
+ write: scopeOptions.schema.publishedOn.storage.serialize(
745
+ new Date("2024-02-29T00:00:00.000Z")
746
+ ),
747
+ publishedOn: record.data.attributes.publishedOn,
748
+ scheduledAt: record.data.attributes.scheduledAt,
749
+ opensAt: record.data.attributes.opensAt
750
+ }));
751
+ `;
752
+
753
+ for (const timezone of ["UTC", "Australia/Perth", "America/Los_Angeles", "Pacific/Kiritimati"]) {
754
+ const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], {
755
+ encoding: "utf8",
756
+ env: {
757
+ ...process.env,
758
+ TZ: timezone
759
+ }
760
+ });
761
+
762
+ assert.equal(result.status, 0, result.stderr);
763
+ assert.deepEqual(JSON.parse(result.stdout), {
764
+ write: "2024-02-29",
765
+ publishedOn: "2024-02-29",
766
+ scheduledAt: "2024-02-29T12:34:56.000Z",
767
+ opensAt: "09:45:00"
768
+ }, timezone);
769
+ }
770
+ });
771
+
772
+ test("createJsonRestApiHost maps UTC database datetimes to RFC 3339 strings", async () => {
773
+ const fakeKnex = Object.assign(() => {}, {
774
+ client: { config: { client: "sqlite3" } },
775
+ async raw() {
776
+ return [{ version: "3.35.5" }];
777
+ },
778
+ transaction() {}
779
+ });
780
+ const api = await createJsonRestApiHost({ knex: fakeKnex });
781
+
782
+ await api.addResource("jobs", createJsonRestResourceScopeOptions({
783
+ tableName: "jobs",
784
+ schema: {
785
+ id: { type: "id", primary: true },
786
+ scheduledAt: { type: "dateTime" }
787
+ }
788
+ }));
789
+ const record = {
790
+ data: {
791
+ type: "jobs",
792
+ id: "1",
793
+ attributes: {
794
+ scheduledAt: "2024-02-29 12:34:56.123"
795
+ }
796
+ }
797
+ };
798
+
799
+ await api.runHooks("finish", { record });
800
+
801
+ assert.equal(record.data.attributes.scheduledAt, "2024-02-29T12:34:56.123Z");
802
+ });
803
+
625
804
  test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
626
805
  await assert.doesNotReject(async () => {
627
806
  const result = await returnNullWhenJsonRestResourceMissing(async () => {