@jskit-ai/crud-core 0.1.62 → 0.1.63

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/crud-core",
4
- version: "0.1.62",
4
+ version: "0.1.63",
5
5
  kind: "runtime",
6
6
  description: "Shared CRUD helpers used by CRUD modules.",
7
7
  dependsOn: [
@@ -26,7 +26,7 @@ export default Object.freeze({
26
26
  mutations: {
27
27
  dependencies: {
28
28
  runtime: {
29
- "@jskit-ai/crud-core": "0.1.62"
29
+ "@jskit-ai/crud-core": "0.1.63"
30
30
  },
31
31
  dev: {}
32
32
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -26,12 +26,12 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@tanstack/vue-query": "^5.90.5",
29
- "@jskit-ai/database-runtime": "0.1.54",
30
- "@jskit-ai/kernel": "0.1.54",
31
- "@jskit-ai/realtime": "0.1.53",
32
- "@jskit-ai/shell-web": "0.1.53",
33
- "@jskit-ai/users-core": "0.1.64",
34
- "@jskit-ai/users-web": "0.1.69",
29
+ "@jskit-ai/database-runtime": "0.1.55",
30
+ "@jskit-ai/kernel": "0.1.55",
31
+ "@jskit-ai/realtime": "0.1.54",
32
+ "@jskit-ai/shell-web": "0.1.54",
33
+ "@jskit-ai/users-core": "0.1.65",
34
+ "@jskit-ai/users-web": "0.1.70",
35
35
  "typebox": "^1.0.81"
36
36
  }
37
37
  }
@@ -1,4 +1,7 @@
1
- import { normalizeDbRecordId } from "@jskit-ai/database-runtime/shared";
1
+ import {
2
+ normalizeDbRecordId,
3
+ toDatabaseDateTimeUtc
4
+ } from "@jskit-ai/database-runtime/shared";
2
5
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
3
6
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
4
7
  import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
@@ -10,6 +13,7 @@ import {
10
13
  } from "@jskit-ai/kernel/shared/support/crudLookup";
11
14
  import {
12
15
  CRUD_FIELD_REPOSITORY_STORAGE_COLUMN,
16
+ CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC,
13
17
  CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL,
14
18
  isCrudRuntimeOutputOnlyFieldKey,
15
19
  normalizeCrudFieldRepositoryConfig
@@ -17,6 +21,9 @@ import {
17
21
 
18
22
  const DEFAULT_LIST_LIMIT = 20;
19
23
  const MAX_LIST_LIMIT = 100;
24
+ const CRUD_WRITE_SERIALIZERS = Object.freeze({
25
+ [CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC]: (value) => toDatabaseDateTimeUtc(value)
26
+ });
20
27
 
21
28
  function normalizeCrudListCursor(cursor = null, { allowEmpty = true } = {}) {
22
29
  if (cursor === undefined || cursor === null) {
@@ -170,6 +177,23 @@ function schemaIncludesStringType(schema = {}) {
170
177
  return variants.some((entry) => schemaIncludesStringType(entry));
171
178
  }
172
179
 
180
+ function schemaIncludesDateTimeFormat(schema = {}) {
181
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
182
+ return false;
183
+ }
184
+
185
+ if (normalizeText(schema.format).toLowerCase() === "date-time") {
186
+ return true;
187
+ }
188
+
189
+ const variants = Array.isArray(schema.anyOf)
190
+ ? schema.anyOf
191
+ : Array.isArray(schema.oneOf)
192
+ ? schema.oneOf
193
+ : [];
194
+ return variants.some((entry) => schemaIncludesDateTimeFormat(entry));
195
+ }
196
+
173
197
  function schemaIncludesRecordIdType(schema = {}) {
174
198
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
175
199
  return false;
@@ -233,6 +257,7 @@ function deriveRepositoryMappingFromResource(resource = {}, { context = "crudRep
233
257
 
234
258
  const fieldStorageByKey = {};
235
259
  const columnOverrides = {};
260
+ const writeSerializerByKey = {};
236
261
  for (const entry of normalizeResourceFieldMetaEntries(resource.fieldMeta)) {
237
262
  const key = normalizeText(entry.key);
238
263
  if (!key) {
@@ -246,6 +271,9 @@ function deriveRepositoryMappingFromResource(resource = {}, { context = "crudRep
246
271
  if (repositoryConfig.column) {
247
272
  columnOverrides[key] = repositoryConfig.column;
248
273
  }
274
+ if (repositoryConfig.writeSerializer) {
275
+ writeSerializerByKey[key] = repositoryConfig.writeSerializer;
276
+ }
249
277
  }
250
278
 
251
279
  for (const key of [...outputKeys, ...writeKeys]) {
@@ -313,9 +341,27 @@ function deriveRepositoryMappingFromResource(resource = {}, { context = "crudRep
313
341
  }
314
342
  }
315
343
 
344
+ for (const key of writeKeys) {
345
+ if ((fieldStorageByKey[key] || CRUD_FIELD_REPOSITORY_STORAGE_COLUMN) !== CRUD_FIELD_REPOSITORY_STORAGE_COLUMN) {
346
+ continue;
347
+ }
348
+
349
+ if (writeSerializerByKey[key]) {
350
+ continue;
351
+ }
352
+
353
+ const schema = writeProperties[key] || patchProperties[key];
354
+ if (!schemaIncludesDateTimeFormat(schema)) {
355
+ continue;
356
+ }
357
+
358
+ writeSerializerByKey[key] = CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC;
359
+ }
360
+
316
361
  return Object.freeze({
317
362
  outputKeys,
318
363
  writeKeys,
364
+ writeSerializerByKey: Object.freeze(writeSerializerByKey),
319
365
  fieldStorageByKey: Object.freeze(fieldStorageByKey),
320
366
  columnOverrides: Object.freeze(columnOverrides),
321
367
  columnBackedOutputKeys: Object.freeze(columnBackedOutputKeys),
@@ -431,8 +477,11 @@ function applyCrudListQueryFilters(
431
477
  return nextQuery;
432
478
  }
433
479
 
434
- function buildWritePayload(sourcePayload = {}, fieldKeys = [], overrides = {}) {
480
+ function buildWritePayload(sourcePayload = {}, fieldKeys = [], overrides = {}, { serializerByKey = {} } = {}) {
435
481
  const source = normalizeObjectInput(sourcePayload);
482
+ const normalizedSerializerByKey = serializerByKey && typeof serializerByKey === "object" && !Array.isArray(serializerByKey)
483
+ ? serializerByKey
484
+ : {};
436
485
  const payload = {};
437
486
  for (const key of fieldKeys) {
438
487
  const normalizedKey = String(key || "").trim();
@@ -443,7 +492,19 @@ function buildWritePayload(sourcePayload = {}, fieldKeys = [], overrides = {}) {
443
492
  if (!Object.hasOwn(source, normalizedKey)) {
444
493
  continue;
445
494
  }
446
- payload[columnName] = source[normalizedKey];
495
+ const value = source[normalizedKey];
496
+ const serializerId = normalizeText(normalizedSerializerByKey[normalizedKey]).toLowerCase();
497
+ if (value === null || value === undefined || !serializerId) {
498
+ payload[columnName] = value;
499
+ continue;
500
+ }
501
+
502
+ const serializer = CRUD_WRITE_SERIALIZERS[serializerId];
503
+ if (typeof serializer !== "function") {
504
+ throw new Error(`crudRepository write serializer "${serializerId}" is not supported.`);
505
+ }
506
+
507
+ payload[columnName] = serializer(value);
447
508
  }
448
509
  return payload;
449
510
  }
@@ -1186,7 +1186,14 @@ async function createRecord(runtime, knex, payload = {}, callOptions = {}) {
1186
1186
  }
1187
1187
  );
1188
1188
 
1189
- let insertPayload = buildWritePayload(sourcePayload, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
1189
+ let insertPayload = buildWritePayload(
1190
+ sourcePayload,
1191
+ runtime.mapping.writeKeys,
1192
+ runtime.mapping.columnOverrides,
1193
+ {
1194
+ serializerByKey: runtime.mapping.writeSerializerByKey
1195
+ }
1196
+ );
1190
1197
  const timestamp = toInsertDateTime();
1191
1198
  if (runtime.defaults.createdAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.createdAtColumn)) {
1192
1199
  insertPayload[runtime.defaults.createdAtColumn] = timestamp;
@@ -1276,7 +1283,14 @@ async function updateRecordById(runtime, knex, recordId, patch = {}, callOptions
1276
1283
  stageKey: "operations.updateById.preparePatch"
1277
1284
  }
1278
1285
  );
1279
- const dbPatch = buildWritePayload(sourcePatch, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
1286
+ const dbPatch = buildWritePayload(
1287
+ sourcePatch,
1288
+ runtime.mapping.writeKeys,
1289
+ runtime.mapping.columnOverrides,
1290
+ {
1291
+ serializerByKey: runtime.mapping.writeSerializerByKey
1292
+ }
1293
+ );
1280
1294
 
1281
1295
  if (runtime.defaults.updatedAtColumn) {
1282
1296
  dbPatch[runtime.defaults.updatedAtColumn] = toInsertDateTime();
@@ -9,6 +9,30 @@ const CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE = "autocomplete";
9
9
  const CRUD_LOOKUP_FORM_CONTROL_SELECT = "select";
10
10
  const CRUD_FIELD_REPOSITORY_STORAGE_COLUMN = "column";
11
11
  const CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL = "virtual";
12
+ const CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC = "datetime-utc";
13
+
14
+ function normalizeCrudFieldRepositoryWriteSerializer(
15
+ value,
16
+ {
17
+ context = "crud fieldMeta repository",
18
+ fieldKey = ""
19
+ } = {}
20
+ ) {
21
+ const normalizedFieldKey = normalizeText(fieldKey);
22
+ const normalizedValue = normalizeText(value).toLowerCase();
23
+ if (!normalizedValue) {
24
+ return "";
25
+ }
26
+
27
+ if (normalizedValue === CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC) {
28
+ return normalizedValue;
29
+ }
30
+
31
+ throw new Error(
32
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} repository.writeSerializer must be ` +
33
+ `"${CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC}" when provided.`
34
+ );
35
+ }
12
36
 
13
37
  function checkCrudLookupFormControl(
14
38
  value,
@@ -70,7 +94,7 @@ function normalizeCrudFieldRepositoryConfig(
70
94
 
71
95
  const repositoryKeys = Object.keys(repository);
72
96
  for (const repositoryKey of repositoryKeys) {
73
- if (repositoryKey !== "column" && repositoryKey !== "storage") {
97
+ if (repositoryKey !== "column" && repositoryKey !== "storage" && repositoryKey !== "writeSerializer") {
74
98
  throw new Error(
75
99
  `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} does not support repository.${repositoryKey}.`
76
100
  );
@@ -79,10 +103,14 @@ function normalizeCrudFieldRepositoryConfig(
79
103
 
80
104
  const column = normalizeText(repository.column);
81
105
  const storage = normalizeText(repository.storage).toLowerCase();
106
+ const writeSerializer = normalizeCrudFieldRepositoryWriteSerializer(repository.writeSerializer, {
107
+ context,
108
+ fieldKey: normalizedFieldKey
109
+ });
82
110
 
83
- if (!column && !storage) {
111
+ if (!column && !storage && !writeSerializer) {
84
112
  throw new Error(
85
- `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} requires repository.column or repository.storage.`
113
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} requires repository.column, repository.storage, or repository.writeSerializer.`
86
114
  );
87
115
  }
88
116
 
@@ -96,22 +124,30 @@ function normalizeCrudFieldRepositoryConfig(
96
124
  `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} repository.storage "virtual" cannot define repository.column.`
97
125
  );
98
126
  }
127
+ if (storage === CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL && writeSerializer) {
128
+ throw new Error(
129
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} repository.storage "virtual" cannot define repository.writeSerializer.`
130
+ );
131
+ }
99
132
 
100
133
  return Object.freeze({
101
134
  storage: storage === CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL
102
135
  ? CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL
103
136
  : CRUD_FIELD_REPOSITORY_STORAGE_COLUMN,
104
- column
137
+ column,
138
+ writeSerializer
105
139
  });
106
140
  }
107
141
 
108
142
  export {
109
143
  CRUD_FIELD_REPOSITORY_STORAGE_COLUMN,
110
144
  CRUD_FIELD_REPOSITORY_STORAGE_VIRTUAL,
145
+ CRUD_FIELD_REPOSITORY_WRITE_SERIALIZER_DATETIME_UTC,
111
146
  CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE,
112
147
  CRUD_LOOKUP_FORM_CONTROL_SELECT,
113
148
  CRUD_RUNTIME_LOOKUPS_FIELD_KEY,
114
149
  checkCrudLookupFormControl,
115
150
  isCrudRuntimeOutputOnlyFieldKey,
116
- normalizeCrudFieldRepositoryConfig
151
+ normalizeCrudFieldRepositoryConfig,
152
+ normalizeCrudFieldRepositoryWriteSerializer
117
153
  };
@@ -132,6 +132,34 @@ test("buildWritePayload respects defined keys", () => {
132
132
  });
133
133
  });
134
134
 
135
+ test("buildWritePayload serializes configured date-time keys to database shape", () => {
136
+ const payload = buildWritePayload(
137
+ {
138
+ scheduledAt: "2026-04-23T10:11:12.000Z",
139
+ archivedAt: null,
140
+ title: "Example"
141
+ },
142
+ ["scheduledAt", "archivedAt", "title"],
143
+ {
144
+ scheduledAt: "scheduled_at",
145
+ archivedAt: "archived_at",
146
+ title: "title"
147
+ },
148
+ {
149
+ serializerByKey: {
150
+ scheduledAt: "datetime-utc",
151
+ archivedAt: "datetime-utc"
152
+ }
153
+ }
154
+ );
155
+
156
+ assert.deepEqual(payload, {
157
+ scheduled_at: "2026-04-23 10:11:12.000",
158
+ archived_at: null,
159
+ title: "Example"
160
+ });
161
+ });
162
+
135
163
  test("applyCrudListQueryFilters applies search and cursor filters", () => {
136
164
  const { query, calls } = createQueryDouble();
137
165
  const result = applyCrudListQueryFilters(query, {
@@ -565,3 +593,103 @@ test("deriveRepositoryMappingFromResource throws when create schema properties a
565
593
  /operations\.create\.bodyValidator\.schema\.properties/
566
594
  );
567
595
  });
596
+
597
+ test("deriveRepositoryMappingFromResource tracks writable column-backed write serializers", () => {
598
+ const resource = {
599
+ operations: {
600
+ view: {
601
+ outputValidator: {
602
+ schema: {
603
+ type: "object",
604
+ properties: {
605
+ id: { type: "integer" },
606
+ scheduledAt: { type: "string", format: "date-time" },
607
+ archivedAt: { type: "string", format: "date-time" },
608
+ remainingBatchWeight: { type: "number" }
609
+ }
610
+ }
611
+ }
612
+ },
613
+ create: {
614
+ bodyValidator: {
615
+ schema: {
616
+ type: "object",
617
+ properties: {
618
+ scheduledAt: { type: "string", format: "date-time" }
619
+ }
620
+ }
621
+ }
622
+ },
623
+ patch: {
624
+ bodyValidator: {
625
+ schema: {
626
+ type: "object",
627
+ properties: {
628
+ archivedAt: {
629
+ anyOf: [
630
+ { type: "string", format: "date-time" },
631
+ { type: "null" }
632
+ ]
633
+ }
634
+ }
635
+ }
636
+ }
637
+ }
638
+ },
639
+ fieldMeta: [
640
+ {
641
+ key: "remainingBatchWeight",
642
+ repository: {
643
+ storage: "virtual"
644
+ }
645
+ }
646
+ ]
647
+ };
648
+
649
+ const mapping = deriveRepositoryMappingFromResource(resource);
650
+ assert.deepEqual(mapping.writeSerializerByKey, {
651
+ scheduledAt: "datetime-utc",
652
+ archivedAt: "datetime-utc"
653
+ });
654
+ });
655
+
656
+ test("deriveRepositoryMappingFromResource keeps explicit repository.writeSerializer metadata", () => {
657
+ const resource = {
658
+ operations: {
659
+ view: {
660
+ outputValidator: {
661
+ schema: {
662
+ type: "object",
663
+ properties: {
664
+ id: { type: "integer" },
665
+ arrivalDatetime: { type: "string", format: "date-time" }
666
+ }
667
+ }
668
+ }
669
+ },
670
+ create: {
671
+ bodyValidator: {
672
+ schema: {
673
+ type: "object",
674
+ properties: {
675
+ arrivalDatetime: { type: "string", format: "date-time" }
676
+ }
677
+ }
678
+ }
679
+ }
680
+ },
681
+ fieldMeta: [
682
+ {
683
+ key: "arrivalDatetime",
684
+ repository: {
685
+ writeSerializer: "datetime-utc"
686
+ }
687
+ }
688
+ ]
689
+ };
690
+
691
+ const mapping = deriveRepositoryMappingFromResource(resource);
692
+ assert.deepEqual(mapping.writeSerializerByKey, {
693
+ arrivalDatetime: "datetime-utc"
694
+ });
695
+ });