@jskit-ai/crud-core 0.1.158 → 0.1.161

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.158",
3
+ "version": "0.1.161",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -27,19 +27,50 @@
27
27
  "./server/routeContracts": "./src/server/routeContracts.js"
28
28
  },
29
29
  "dependencies": {
30
- "@jskit-ai/database-runtime": "0.1.147",
31
- "@jskit-ai/http-runtime": "0.1.146",
32
- "@jskit-ai/kernel": "0.1.147",
33
- "json-rest-schema": "^1.0.17",
34
- "@jskit-ai/realtime": "0.1.144",
35
- "@jskit-ai/resource-crud-core": "0.1.90",
36
- "@jskit-ai/shell-web": "0.1.148",
37
- "@jskit-ai/users-core": "0.1.161",
38
- "@jskit-ai/users-web": "0.1.165"
30
+ "@jskit-ai/database-runtime": "0.1.150",
31
+ "@jskit-ai/http-runtime": "0.1.148",
32
+ "@jskit-ai/kernel": "0.1.150",
33
+ "@jskit-ai/realtime": "0.1.147",
34
+ "@jskit-ai/resource-crud-core": "0.1.92",
35
+ "@jskit-ai/shell-web": "0.1.154",
36
+ "@jskit-ai/users-core": "0.1.163",
37
+ "@jskit-ai/users-web": "0.1.168",
38
+ "json-rest-schema": "^1.0.17"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@tanstack/vue-query": "^5.90.5",
42
42
  "vue": "^3.5.13",
43
43
  "vue-router": "^5.0.4"
44
+ },
45
+ "description": "Shared CRUD helpers used by CRUD modules.",
46
+ "jskit": {
47
+ "kind": "runtime",
48
+ "capabilities": {
49
+ "provides": [
50
+ "crud.core"
51
+ ],
52
+ "requires": []
53
+ },
54
+ "runtime": {
55
+ "server": {
56
+ "providers": []
57
+ },
58
+ "client": {
59
+ "providers": []
60
+ }
61
+ },
62
+ "mutations": {
63
+ "dependencies": {
64
+ "runtime": {
65
+ "@jskit-ai/crud-core": "0.1.161"
66
+ },
67
+ "dev": {}
68
+ },
69
+ "packageJson": {
70
+ "scripts": {}
71
+ },
72
+ "procfile": {},
73
+ "files": []
74
+ }
44
75
  }
45
76
  }
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  normalizeDbRecordId,
3
- toDatabaseDateTimeUtc
3
+ toDatabaseDateTimeUtc,
4
+ toJsonDate,
5
+ toJsonTime,
6
+ toJsonDateTime
4
7
  } from "@jskit-ai/database-runtime/shared";
5
8
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
6
9
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
@@ -26,6 +29,11 @@ const MAX_LIST_LIMIT = 100;
26
29
  const CRUD_WRITE_SERIALIZERS = Object.freeze({
27
30
  [CRUD_FIELD_WRITE_SERIALIZER_DATETIME_UTC]: (value) => toDatabaseDateTimeUtc(value)
28
31
  });
32
+ const CRUD_OUTPUT_SERIALIZERS = Object.freeze({
33
+ date: toJsonDate,
34
+ time: toJsonTime,
35
+ datetime: toJsonDateTime
36
+ });
29
37
 
30
38
  function normalizeCrudListCursor(cursor = null, { allowEmpty = true } = {}) {
31
39
  if (cursor === undefined || cursor === null) {
@@ -285,10 +293,20 @@ function deriveRepositoryMappingFromResource(resource = {}, { context = "crudRep
285
293
  }
286
294
 
287
295
  const outputRecordIdKeys = [];
296
+ const outputSerializerByKey = {};
288
297
  for (const [key, definition] of Object.entries(outputProperties)) {
289
298
  if (definitionIncludesRecordIdType(definition)) {
290
299
  outputRecordIdKeys.push(key);
291
300
  }
301
+ const fieldType = resolveFieldDefinitionType(definition);
302
+ if (Object.hasOwn(CRUD_OUTPUT_SERIALIZERS, fieldType)) {
303
+ outputSerializerByKey[key] = Object.freeze({
304
+ type: fieldType,
305
+ ...(Number.isInteger(definition?.temporalPrecision)
306
+ ? { temporalPrecision: definition.temporalPrecision }
307
+ : {})
308
+ });
309
+ }
292
310
  }
293
311
 
294
312
  for (const key of writeKeys) {
@@ -318,11 +336,17 @@ function deriveRepositoryMappingFromResource(resource = {}, { context = "crudRep
318
336
  virtualOutputKeys: Object.freeze(virtualOutputKeys),
319
337
  listSearchColumns: Object.freeze(listSearchColumns),
320
338
  parentFilterColumns: Object.freeze(parentFilterColumns),
321
- outputRecordIdKeys: Object.freeze(outputRecordIdKeys)
339
+ outputRecordIdKeys: Object.freeze(outputRecordIdKeys),
340
+ outputSerializerByKey: Object.freeze(outputSerializerByKey)
322
341
  });
323
342
  }
324
343
 
325
- function mapRecordRow(row, fieldKeys = [], overrides = {}, { recordIdKeys = [] } = {}) {
344
+ function mapRecordRow(
345
+ row,
346
+ fieldKeys = [],
347
+ overrides = {},
348
+ { recordIdKeys = [], serializerByKey = {} } = {}
349
+ ) {
326
350
  if (!row) {
327
351
  return null;
328
352
  }
@@ -333,6 +357,7 @@ function mapRecordRow(row, fieldKeys = [], overrides = {}, { recordIdKeys = [] }
333
357
  .filter(Boolean)
334
358
  );
335
359
  const mapped = {};
360
+ const normalizedSerializerByKey = normalizeObjectInput(serializerByKey);
336
361
  for (const key of fieldKeys) {
337
362
  const normalizedKey = String(key || "").trim();
338
363
  const columnName = resolveColumnName(normalizedKey, overrides);
@@ -351,6 +376,21 @@ function mapRecordRow(row, fieldKeys = [], overrides = {}, { recordIdKeys = [] }
351
376
  continue;
352
377
  }
353
378
 
379
+ const serializerConfig = normalizedSerializerByKey[normalizedKey];
380
+ const serializerId = normalizeText(
381
+ typeof serializerConfig === "string" ? serializerConfig : serializerConfig?.type
382
+ ).toLowerCase();
383
+ if (serializerId && rawValue != null) {
384
+ const serializer = CRUD_OUTPUT_SERIALIZERS[serializerId];
385
+ if (typeof serializer !== "function") {
386
+ throw new Error(`crudRepository output serializer "${serializerId}" is not supported.`);
387
+ }
388
+ mapped[normalizedKey] = serializer(rawValue, {
389
+ temporalPrecision: serializerConfig?.temporalPrecision
390
+ });
391
+ continue;
392
+ }
393
+
354
394
  mapped[normalizedKey] = rawValue;
355
395
  }
356
396
  return mapped;
@@ -925,7 +925,10 @@ async function listRecords(runtime, knex, query = {}, callOptions = {}) {
925
925
  row,
926
926
  runtime.mapping.outputKeys,
927
927
  runtime.mapping.columnOverrides,
928
- { recordIdKeys: runtime.mapping.outputRecordIdKeys }
928
+ {
929
+ recordIdKeys: runtime.mapping.outputRecordIdKeys,
930
+ serializerByKey: runtime.mapping.outputSerializerByKey
931
+ }
929
932
  );
930
933
  if (!mappedRecord) {
931
934
  continue;
@@ -1014,7 +1017,10 @@ async function findRecordById(runtime, knex, recordId, callOptions = {}) {
1014
1017
  row,
1015
1018
  runtime.mapping.outputKeys,
1016
1019
  runtime.mapping.columnOverrides,
1017
- { recordIdKeys: runtime.mapping.outputRecordIdKeys }
1020
+ {
1021
+ recordIdKeys: runtime.mapping.outputRecordIdKeys,
1022
+ serializerByKey: runtime.mapping.outputSerializerByKey
1023
+ }
1018
1024
  );
1019
1025
  if (!mappedRecord) {
1020
1026
  return null;
@@ -1115,7 +1121,10 @@ async function listRecordsByIds(runtime, knex, ids = [], callOptions = {}) {
1115
1121
  row,
1116
1122
  runtime.mapping.outputKeys,
1117
1123
  runtime.mapping.columnOverrides,
1118
- { recordIdKeys: runtime.mapping.outputRecordIdKeys }
1124
+ {
1125
+ recordIdKeys: runtime.mapping.outputRecordIdKeys,
1126
+ serializerByKey: runtime.mapping.outputSerializerByKey
1127
+ }
1119
1128
  );
1120
1129
  if (!mappedRecord) {
1121
1130
  continue;
@@ -138,6 +138,36 @@ test("mapRecordRow omits keys whose source column is absent", () => {
138
138
  });
139
139
  });
140
140
 
141
+ test("mapRecordRow serializes database temporal values for strict JSON resource output", () => {
142
+ const row = {
143
+ service_date: new Date("2026-08-13T23:59:58.123Z"),
144
+ opening_time: "07:08:09.123456",
145
+ scheduled_at: "2026-08-13 07:08:09.123456"
146
+ };
147
+ const mapped = mapRecordRow(
148
+ row,
149
+ ["serviceDate", "openingTime", "scheduledAt"],
150
+ {
151
+ serviceDate: "service_date",
152
+ openingTime: "opening_time",
153
+ scheduledAt: "scheduled_at"
154
+ },
155
+ {
156
+ serializerByKey: {
157
+ serviceDate: "date",
158
+ openingTime: "time",
159
+ scheduledAt: "datetime"
160
+ }
161
+ }
162
+ );
163
+
164
+ assert.deepEqual(mapped, {
165
+ serviceDate: "2026-08-13",
166
+ openingTime: "07:08:09.123456",
167
+ scheduledAt: "2026-08-13T07:08:09.123456Z"
168
+ });
169
+ });
170
+
141
171
  test("buildWritePayload respects defined keys", () => {
142
172
  const payload = buildWritePayload(
143
173
  { foo: "bar", missing: true },
@@ -574,6 +604,36 @@ test("deriveRepositoryMappingFromResource tracks writable column-backed write se
574
604
  scheduledAt: "datetime-utc",
575
605
  archivedAt: "datetime-utc"
576
606
  });
607
+ assert.deepEqual(mapping.outputSerializerByKey, {
608
+ scheduledAt: { type: "datetime" },
609
+ archivedAt: { type: "datetime" }
610
+ });
611
+ });
612
+
613
+ test("deriveRepositoryMappingFromResource tracks JSON temporal output serializers", () => {
614
+ const resource = {
615
+ operations: {
616
+ view: {
617
+ output: createOperationSchemaDefinition({
618
+ id: { type: "integer", required: true },
619
+ serviceDate: { type: "date", required: true },
620
+ openingTime: { type: "time", required: true },
621
+ scheduledAt: { type: "dateTime", temporalPrecision: 3, required: true }
622
+ })
623
+ },
624
+ create: {
625
+ body: createOperationSchemaDefinition({}, "create")
626
+ }
627
+ }
628
+ };
629
+
630
+ const mapping = deriveRepositoryMappingFromResource(resource);
631
+
632
+ assert.deepEqual(mapping.outputSerializerByKey, {
633
+ serviceDate: { type: "date" },
634
+ openingTime: { type: "time" },
635
+ scheduledAt: { type: "datetime", temporalPrecision: 3 }
636
+ });
577
637
  });
578
638
 
579
639
  test("deriveRepositoryMappingFromResource keeps explicit storage.writeSerializer metadata", () => {
@@ -499,6 +499,64 @@ test("list uses resource table and id defaults", async () => {
499
499
  assert.ok(calls.some((call) => call[0] === "where" && call[1] === "contact_id" && call[2] === ">" && call[3] === "2"));
500
500
  });
501
501
 
502
+ test("list converts database temporal values before strict resource validation", async () => {
503
+ const resource = {
504
+ namespace: "appointments",
505
+ tableName: "appointments",
506
+ operations: {
507
+ view: {
508
+ output: {
509
+ schema: createSchema({
510
+ id: {
511
+ ...recordIdSchema,
512
+ required: true
513
+ },
514
+ serviceDate: {
515
+ type: "date",
516
+ required: true
517
+ },
518
+ openingTime: {
519
+ type: "time",
520
+ required: true
521
+ },
522
+ scheduledAt: {
523
+ type: "dateTime",
524
+ required: true
525
+ }
526
+ }),
527
+ mode: "replace"
528
+ }
529
+ },
530
+ create: {
531
+ body: {
532
+ schema: createSchema({}),
533
+ mode: "create"
534
+ }
535
+ }
536
+ }
537
+ };
538
+ const { knex } = createKnexDouble([
539
+ {
540
+ id: 3,
541
+ service_date: new Date("2026-08-13T23:59:58.123Z"),
542
+ opening_time: "07:08:09.123456",
543
+ scheduled_at: "2026-08-13 07:08:09.123456"
544
+ }
545
+ ]);
546
+ const repository = createCrudResourceRuntime(resource, knex);
547
+
548
+ const result = await repository.list();
549
+
550
+ assert.deepEqual(result.items, [
551
+ {
552
+ id: "3",
553
+ serviceDate: "2026-08-13",
554
+ openingTime: "07:08:09.123456",
555
+ scheduledAt: "2026-08-13T07:08:09.123456Z"
556
+ }
557
+ ]);
558
+ });
559
+
502
560
  test("list respects bound list config", async () => {
503
561
  const { knex, calls } = createKnexDouble([
504
562
  { contact_id: 3, first_name: "Tony" },
@@ -1,39 +0,0 @@
1
- export default Object.freeze({
2
- packageVersion: 1,
3
- packageId: "@jskit-ai/crud-core",
4
- version: "0.1.158",
5
- kind: "runtime",
6
- description: "Shared CRUD helpers used by CRUD modules.",
7
- dependsOn: [
8
- "@jskit-ai/http-runtime",
9
- "@jskit-ai/kernel",
10
- "@jskit-ai/realtime",
11
- "@jskit-ai/resource-crud-core",
12
- "@jskit-ai/shell-web"
13
- ],
14
- capabilities: {
15
- provides: ["crud.core"],
16
- requires: []
17
- },
18
- runtime: {
19
- server: {
20
- providers: []
21
- },
22
- client: {
23
- providers: []
24
- }
25
- },
26
- mutations: {
27
- dependencies: {
28
- runtime: {
29
- "@jskit-ai/crud-core": "0.1.158"
30
- },
31
- dev: {}
32
- },
33
- packageJson: {
34
- scripts: {}
35
- },
36
- procfile: {},
37
- files: []
38
- }
39
- });