@jskit-ai/json-rest-api-core 0.1.89 → 0.1.91

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.89",
4
+ version: "0.1.91",
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.89",
3
+ "version": "0.1.91",
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.145"
14
+ "@jskit-ai/kernel": "0.1.146"
15
15
  }
16
16
  }
@@ -53,6 +53,7 @@ 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;
56
57
 
57
58
  function isPlainJsonRestObject(value) {
58
59
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -105,6 +106,117 @@ function cloneJsonRestResourceValue(value, { writeSerializers = {} } = {}) {
105
106
  return next;
106
107
  }
107
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 applyJsonRestCalendarDateWriteSerializers(scopeOptions = {}) {
143
+ const schema = normalizeJsonRestObject(scopeOptions.schema);
144
+ for (const fieldDefinition of Object.values(schema)) {
145
+ if (normalizeJsonRestText(fieldDefinition?.type).toLowerCase() !== "date") {
146
+ continue;
147
+ }
148
+ if (fieldDefinition?.virtual === true || fieldDefinition?.storage?.virtual === true) {
149
+ continue;
150
+ }
151
+
152
+ const storage = normalizeJsonRestObject(fieldDefinition.storage);
153
+ if (typeof storage.serialize === "function") {
154
+ continue;
155
+ }
156
+ fieldDefinition.storage = {
157
+ ...storage,
158
+ serialize: serializeJsonRestCalendarDate
159
+ };
160
+ }
161
+ }
162
+
163
+ function normalizeJsonRestCalendarDateEntry(entry = null, scopes = {}) {
164
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
165
+ return entry;
166
+ }
167
+
168
+ const schema = normalizeJsonRestObject(
169
+ scopes?.[normalizeJsonRestText(entry.type)]?.vars?.schemaInfo?.schemaStructure
170
+ );
171
+ const attributes = normalizeJsonRestObject(entry.attributes);
172
+ for (const [fieldName, definition] of Object.entries(schema)) {
173
+ if (
174
+ normalizeJsonRestText(definition?.type).toLowerCase() !== "date" ||
175
+ !Object.hasOwn(attributes, fieldName) ||
176
+ attributes[fieldName] == null
177
+ ) {
178
+ continue;
179
+ }
180
+
181
+ const canonical = resolveCanonicalCalendarDate(attributes[fieldName]);
182
+ if (canonical) {
183
+ attributes[fieldName] = canonical;
184
+ }
185
+ }
186
+
187
+ return entry;
188
+ }
189
+
190
+ function normalizeJsonRestCalendarDateDocument(document = null, scopes = {}) {
191
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
192
+ return document;
193
+ }
194
+
195
+ const data = Array.isArray(document.data) ? document.data : [document.data];
196
+ for (const entry of data) {
197
+ normalizeJsonRestCalendarDateEntry(entry, scopes);
198
+ }
199
+ for (const entry of Array.isArray(document.included) ? document.included : []) {
200
+ normalizeJsonRestCalendarDateEntry(entry, scopes);
201
+ }
202
+ return document;
203
+ }
204
+
205
+ const JsonRestCalendarDatePlugin = Object.freeze({
206
+ name: "jskit-calendar-date",
207
+ dependencies: ["rest-api"],
208
+ install({ addHook, scopes }) {
209
+ addHook("finish", "normalizeCalendarDateDocuments", {}, ({ context }) => {
210
+ if (context?.record) {
211
+ normalizeJsonRestCalendarDateDocument(context.record, scopes);
212
+ }
213
+ if (context?.responseRecord) {
214
+ normalizeJsonRestCalendarDateDocument(context.responseRecord, scopes);
215
+ }
216
+ });
217
+ }
218
+ });
219
+
108
220
  async function addResourceIfMissing(api, scopeName, resourceConfig) {
109
221
  if (api?.resources?.[scopeName]) {
110
222
  return api.resources[scopeName];
@@ -548,6 +660,7 @@ function createJsonRestResourceScopeOptions(
548
660
  const scopeOptions = cloneJsonRestResourceValue(resource, {
549
661
  writeSerializers: normalizeJsonRestObject(writeSerializers)
550
662
  });
663
+ applyJsonRestCalendarDateWriteSerializers(scopeOptions);
551
664
  if (isPlainJsonRestObject(searchSchema)) {
552
665
  scopeOptions.searchSchema = {
553
666
  ...normalizeJsonRestObject(scopeOptions.searchSchema),
@@ -702,6 +815,7 @@ async function createJsonRestApiHost({ knex }) {
702
815
  },
703
816
  presets: JSON_REST_AUTOFILTER_PRESETS
704
817
  });
818
+ await api.use(JsonRestCalendarDatePlugin);
705
819
 
706
820
  return api;
707
821
  }
@@ -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,134 @@ 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 calendar dates as YYYY-MM-DD without changing date-time or time fields", 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, scheduledAt);
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
+
625
772
  test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
626
773
  await assert.doesNotReject(async () => {
627
774
  const result = await returnNullWhenJsonRestResourceMissing(async () => {