@jskit-ai/kernel 0.1.44 → 0.1.45

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/kernel",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "typebox": "^1.0.81"
@@ -35,6 +35,7 @@
35
35
  "./shared/support/tokens": "./shared/support/tokens.js",
36
36
  "./shared/support/normalize": "./shared/support/normalize.js",
37
37
  "./shared/support/permissions": "./shared/support/permissions.js",
38
+ "./shared/support/crudListFilters": "./shared/support/crudListFilters.js",
38
39
  "./shared/support/crudLookup": "./shared/support/crudLookup.js",
39
40
  "./shared/support/deepFreeze": "./shared/support/deepFreeze.js",
40
41
  "./shared/support/listenerSet": "./shared/support/listenerSet.js",
@@ -0,0 +1,294 @@
1
+ import { deepFreeze } from "./deepFreeze.js";
2
+ import {
3
+ normalizeText,
4
+ normalizeObject
5
+ } from "./normalize.js";
6
+ import {
7
+ normalizeCrudLookupApiPath,
8
+ normalizeCrudLookupNamespace,
9
+ resolveCrudLookupApiPathFromNamespace
10
+ } from "./crudLookup.js";
11
+
12
+ const CRUD_LIST_FILTER_TYPE_FLAG = "flag";
13
+ const CRUD_LIST_FILTER_TYPE_ENUM = "enum";
14
+ const CRUD_LIST_FILTER_TYPE_ENUM_MANY = "enumMany";
15
+ const CRUD_LIST_FILTER_TYPE_RECORD_ID = "recordId";
16
+ const CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY = "recordIdMany";
17
+ const CRUD_LIST_FILTER_TYPE_DATE = "date";
18
+ const CRUD_LIST_FILTER_TYPE_DATE_RANGE = "dateRange";
19
+ const CRUD_LIST_FILTER_TYPE_NUMBER_RANGE = "numberRange";
20
+ const CRUD_LIST_FILTER_TYPE_PRESENCE = "presence";
21
+
22
+ const CRUD_LIST_FILTER_TYPES = Object.freeze([
23
+ CRUD_LIST_FILTER_TYPE_FLAG,
24
+ CRUD_LIST_FILTER_TYPE_ENUM,
25
+ CRUD_LIST_FILTER_TYPE_ENUM_MANY,
26
+ CRUD_LIST_FILTER_TYPE_RECORD_ID,
27
+ CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY,
28
+ CRUD_LIST_FILTER_TYPE_DATE,
29
+ CRUD_LIST_FILTER_TYPE_DATE_RANGE,
30
+ CRUD_LIST_FILTER_TYPE_NUMBER_RANGE,
31
+ CRUD_LIST_FILTER_TYPE_PRESENCE
32
+ ]);
33
+
34
+ const CRUD_LIST_FILTER_PRESENCE_PRESENT = "present";
35
+ const CRUD_LIST_FILTER_PRESENCE_MISSING = "missing";
36
+ const CRUD_LIST_FILTER_PRESENCE_OPTIONS = Object.freeze([
37
+ Object.freeze({
38
+ value: CRUD_LIST_FILTER_PRESENCE_PRESENT,
39
+ label: "Present"
40
+ }),
41
+ Object.freeze({
42
+ value: CRUD_LIST_FILTER_PRESENCE_MISSING,
43
+ label: "Missing"
44
+ })
45
+ ]);
46
+
47
+ function normalizeCrudListFilterType(value = "") {
48
+ const normalized = normalizeText(value);
49
+ if (CRUD_LIST_FILTER_TYPES.includes(normalized)) {
50
+ return normalized;
51
+ }
52
+
53
+ throw new TypeError(`Unsupported CRUD list filter type "${value}".`);
54
+ }
55
+
56
+ function normalizeCrudListFilterOption(rawOption = null, { context = "filter option" } = {}) {
57
+ const source = typeof rawOption === "string"
58
+ ? { value: rawOption }
59
+ : normalizeObject(rawOption);
60
+ const value = normalizeText(source.value);
61
+ if (!value) {
62
+ throw new TypeError(`${context} requires value.`);
63
+ }
64
+
65
+ return Object.freeze({
66
+ value,
67
+ label: normalizeText(source.label) || value
68
+ });
69
+ }
70
+
71
+ function normalizeCrudListFilterOptions(rawOptions = [], { context = "filter options" } = {}) {
72
+ const source = Array.isArray(rawOptions) ? rawOptions : [];
73
+ const options = [];
74
+ const seenValues = new Set();
75
+
76
+ for (const rawOption of source) {
77
+ const option = normalizeCrudListFilterOption(rawOption, { context });
78
+ if (seenValues.has(option.value)) {
79
+ continue;
80
+ }
81
+
82
+ seenValues.add(option.value);
83
+ options.push(option);
84
+ }
85
+
86
+ return Object.freeze(options);
87
+ }
88
+
89
+ function normalizeCrudListFilterPresenceOptions(rawOptions = []) {
90
+ const sourceOptions = normalizeCrudListFilterOptions(rawOptions, {
91
+ context: "presence filter options"
92
+ });
93
+ if (sourceOptions.length < 1) {
94
+ return CRUD_LIST_FILTER_PRESENCE_OPTIONS;
95
+ }
96
+
97
+ const optionMap = new Map(sourceOptions.map((entry) => [entry.value, entry]));
98
+ const presentOption = optionMap.get(CRUD_LIST_FILTER_PRESENCE_PRESENT);
99
+ const missingOption = optionMap.get(CRUD_LIST_FILTER_PRESENCE_MISSING);
100
+ if (!presentOption || !missingOption) {
101
+ throw new TypeError('Presence filter options must contain both "present" and "missing" values.');
102
+ }
103
+
104
+ return Object.freeze([
105
+ presentOption,
106
+ missingOption
107
+ ]);
108
+ }
109
+
110
+ function normalizeCrudListFilterLookup(rawLookup = null) {
111
+ if (rawLookup == null) {
112
+ return null;
113
+ }
114
+ if (!rawLookup || typeof rawLookup !== "object" || Array.isArray(rawLookup)) {
115
+ throw new TypeError("CRUD list filter lookup must be an object.");
116
+ }
117
+ const source = normalizeObject(rawLookup);
118
+
119
+ const namespace = normalizeCrudLookupNamespace(source.namespace);
120
+ const explicitApiPath = normalizeCrudLookupApiPath(source.apiSuffix || source.apiPath);
121
+ const apiSuffix = explicitApiPath || resolveCrudLookupApiPathFromNamespace(namespace);
122
+ const labelKey = normalizeText(source.labelKey);
123
+ const valueKey = normalizeText(source.valueKey) || "id";
124
+
125
+ return Object.freeze({
126
+ ...(namespace ? { namespace } : {}),
127
+ ...(apiSuffix ? { apiSuffix } : {}),
128
+ ...(labelKey ? { labelKey } : {}),
129
+ valueKey
130
+ });
131
+ }
132
+
133
+ function resolveCrudListFilterOptionSet(rawDefinition = {}, type = "") {
134
+ if (type === CRUD_LIST_FILTER_TYPE_ENUM || type === CRUD_LIST_FILTER_TYPE_ENUM_MANY) {
135
+ const options = normalizeCrudListFilterOptions(rawDefinition.options, {
136
+ context: `${type} filter options`
137
+ });
138
+ if (options.length < 1) {
139
+ throw new TypeError(`${type} filters require at least one option.`);
140
+ }
141
+ return options;
142
+ }
143
+
144
+ if (type === CRUD_LIST_FILTER_TYPE_PRESENCE) {
145
+ return normalizeCrudListFilterPresenceOptions(rawDefinition.options);
146
+ }
147
+
148
+ return Object.freeze([]);
149
+ }
150
+
151
+ function resolveCrudListFilterQueryKeys(definition = {}) {
152
+ const type = normalizeCrudListFilterType(definition.type);
153
+ if (type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
154
+ return Object.freeze([
155
+ normalizeText(definition.fromKey),
156
+ normalizeText(definition.toKey)
157
+ ].filter(Boolean));
158
+ }
159
+ if (type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
160
+ return Object.freeze([
161
+ normalizeText(definition.minKey),
162
+ normalizeText(definition.maxKey)
163
+ ].filter(Boolean));
164
+ }
165
+
166
+ return Object.freeze([normalizeText(definition.queryKey)].filter(Boolean));
167
+ }
168
+
169
+ function resolveCrudListFilterOptionLabel(definition = {}, value = "", { fallback = "" } = {}) {
170
+ const normalizedValue = normalizeText(value);
171
+ if (!normalizedValue) {
172
+ return normalizeText(fallback);
173
+ }
174
+
175
+ const options = Array.isArray(definition?.options) ? definition.options : [];
176
+ const optionLabel = options.find((entry) => entry?.value === normalizedValue)?.label;
177
+ return normalizeText(optionLabel) || normalizeText(fallback) || normalizedValue;
178
+ }
179
+
180
+ function normalizeCrudListFilterDefinition(rawKey = "", rawDefinition = null) {
181
+ const key = normalizeText(rawKey);
182
+ if (!key) {
183
+ throw new TypeError("CRUD list filter definitions require non-empty keys.");
184
+ }
185
+
186
+ if (!rawDefinition || typeof rawDefinition !== "object" || Array.isArray(rawDefinition)) {
187
+ throw new TypeError(`CRUD list filter "${key}" must be an object.`);
188
+ }
189
+ const source = normalizeObject(rawDefinition);
190
+
191
+ const type = normalizeCrudListFilterType(source.type);
192
+ const label = normalizeText(source.label) || key;
193
+ const options = resolveCrudListFilterOptionSet(source, type);
194
+ const lookup = normalizeCrudListFilterLookup(source.lookup);
195
+ const chipLabel = typeof source.chipLabel === "function" ? source.chipLabel : null;
196
+ const ui = source.ui && typeof source.ui === "object" && !Array.isArray(source.ui)
197
+ ? deepFreeze({ ...source.ui })
198
+ : null;
199
+ const meta = source.meta && typeof source.meta === "object" && !Array.isArray(source.meta)
200
+ ? deepFreeze({ ...source.meta })
201
+ : null;
202
+
203
+ if (type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
204
+ return Object.freeze({
205
+ key,
206
+ type,
207
+ label,
208
+ fromKey: normalizeText(source.fromKey) || `${key}From`,
209
+ toKey: normalizeText(source.toKey) || `${key}To`,
210
+ options,
211
+ lookup,
212
+ chipLabel,
213
+ ui,
214
+ meta
215
+ });
216
+ }
217
+
218
+ if (type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
219
+ return Object.freeze({
220
+ key,
221
+ type,
222
+ label,
223
+ minKey: normalizeText(source.minKey) || `${key}Min`,
224
+ maxKey: normalizeText(source.maxKey) || `${key}Max`,
225
+ options,
226
+ lookup,
227
+ chipLabel,
228
+ ui,
229
+ meta
230
+ });
231
+ }
232
+
233
+ return Object.freeze({
234
+ key,
235
+ type,
236
+ label,
237
+ queryKey: normalizeText(source.queryKey) || key,
238
+ options,
239
+ lookup,
240
+ chipLabel,
241
+ ui,
242
+ meta
243
+ });
244
+ }
245
+
246
+ function defineCrudListFilters(definitions = {}) {
247
+ if (!definitions || typeof definitions !== "object" || Array.isArray(definitions)) {
248
+ throw new TypeError("defineCrudListFilters requires an object.");
249
+ }
250
+ const source = normalizeObject(definitions);
251
+
252
+ const normalized = {};
253
+ const seenFilterKeys = new Set();
254
+ const seenQueryKeys = new Map();
255
+
256
+ for (const [rawKey, rawDefinition] of Object.entries(source)) {
257
+ const filter = normalizeCrudListFilterDefinition(rawKey, rawDefinition);
258
+ if (seenFilterKeys.has(filter.key)) {
259
+ throw new TypeError(`Duplicate CRUD list filter key "${filter.key}".`);
260
+ }
261
+ seenFilterKeys.add(filter.key);
262
+
263
+ for (const queryKey of resolveCrudListFilterQueryKeys(filter)) {
264
+ const seenBy = seenQueryKeys.get(queryKey);
265
+ if (seenBy) {
266
+ throw new TypeError(`CRUD list filters "${seenBy}" and "${filter.key}" both use query key "${queryKey}".`);
267
+ }
268
+ seenQueryKeys.set(queryKey, filter.key);
269
+ }
270
+
271
+ normalized[filter.key] = filter;
272
+ }
273
+
274
+ return deepFreeze(normalized);
275
+ }
276
+
277
+ export {
278
+ CRUD_LIST_FILTER_TYPE_FLAG,
279
+ CRUD_LIST_FILTER_TYPE_ENUM,
280
+ CRUD_LIST_FILTER_TYPE_ENUM_MANY,
281
+ CRUD_LIST_FILTER_TYPE_RECORD_ID,
282
+ CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY,
283
+ CRUD_LIST_FILTER_TYPE_DATE,
284
+ CRUD_LIST_FILTER_TYPE_DATE_RANGE,
285
+ CRUD_LIST_FILTER_TYPE_NUMBER_RANGE,
286
+ CRUD_LIST_FILTER_TYPE_PRESENCE,
287
+ CRUD_LIST_FILTER_TYPES,
288
+ CRUD_LIST_FILTER_PRESENCE_PRESENT,
289
+ CRUD_LIST_FILTER_PRESENCE_MISSING,
290
+ CRUD_LIST_FILTER_PRESENCE_OPTIONS,
291
+ defineCrudListFilters,
292
+ resolveCrudListFilterQueryKeys,
293
+ resolveCrudListFilterOptionLabel
294
+ };
@@ -0,0 +1,135 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ defineCrudListFilters,
5
+ resolveCrudListFilterQueryKeys,
6
+ resolveCrudListFilterOptionLabel
7
+ } from "./crudListFilters.js";
8
+
9
+ test("defineCrudListFilters normalizes common filter shapes", () => {
10
+ const filters = defineCrudListFilters({
11
+ onlyStaff: {
12
+ type: "flag",
13
+ label: "Staff"
14
+ },
15
+ status: {
16
+ type: "enumMany",
17
+ label: "Status",
18
+ options: [
19
+ { value: "active", label: "Active" },
20
+ { value: "archived", label: "Archived" }
21
+ ]
22
+ },
23
+ arrivalDate: {
24
+ type: "dateRange",
25
+ label: "Arrival Date"
26
+ },
27
+ weight: {
28
+ type: "numberRange",
29
+ label: "Weight"
30
+ },
31
+ supplierContactId: {
32
+ type: "recordIdMany",
33
+ label: "Supplier",
34
+ lookup: {
35
+ namespace: "contacts"
36
+ }
37
+ }
38
+ });
39
+
40
+ assert.deepEqual(filters.onlyStaff, {
41
+ key: "onlyStaff",
42
+ type: "flag",
43
+ label: "Staff",
44
+ queryKey: "onlyStaff",
45
+ options: [],
46
+ lookup: null,
47
+ chipLabel: null,
48
+ ui: null,
49
+ meta: null
50
+ });
51
+ assert.deepEqual(filters.arrivalDate, {
52
+ key: "arrivalDate",
53
+ type: "dateRange",
54
+ label: "Arrival Date",
55
+ fromKey: "arrivalDateFrom",
56
+ toKey: "arrivalDateTo",
57
+ options: [],
58
+ lookup: null,
59
+ chipLabel: null,
60
+ ui: null,
61
+ meta: null
62
+ });
63
+ assert.deepEqual(filters.weight, {
64
+ key: "weight",
65
+ type: "numberRange",
66
+ label: "Weight",
67
+ minKey: "weightMin",
68
+ maxKey: "weightMax",
69
+ options: [],
70
+ lookup: null,
71
+ chipLabel: null,
72
+ ui: null,
73
+ meta: null
74
+ });
75
+ assert.deepEqual(filters.supplierContactId.lookup, {
76
+ namespace: "contacts",
77
+ apiSuffix: "/contacts",
78
+ valueKey: "id"
79
+ });
80
+ });
81
+
82
+ test("defineCrudListFilters uses fixed presence semantics with optional label overrides", () => {
83
+ const filters = defineCrudListFilters({
84
+ locationAssignment: {
85
+ type: "presence",
86
+ label: "Storage",
87
+ options: [
88
+ { value: "present", label: "Assigned" },
89
+ { value: "missing", label: "Unassigned" }
90
+ ]
91
+ }
92
+ });
93
+
94
+ assert.deepEqual(filters.locationAssignment.options, [
95
+ { value: "present", label: "Assigned" },
96
+ { value: "missing", label: "Unassigned" }
97
+ ]);
98
+ });
99
+
100
+ test("defineCrudListFilters rejects duplicate query keys", () => {
101
+ assert.throws(
102
+ () => defineCrudListFilters({
103
+ arrivalDate: {
104
+ type: "dateRange",
105
+ label: "Arrival Date"
106
+ },
107
+ arrivalDateFrom: {
108
+ type: "date",
109
+ label: "Arrival Date From"
110
+ }
111
+ }),
112
+ /both use query key "arrivalDateFrom"/
113
+ );
114
+ });
115
+
116
+ test("resolveCrudListFilter helpers expose query keys and option labels", () => {
117
+ const filters = defineCrudListFilters({
118
+ status: {
119
+ type: "enum",
120
+ label: "Status",
121
+ options: [
122
+ { value: "active", label: "Active" }
123
+ ]
124
+ },
125
+ arrivalDate: {
126
+ type: "dateRange",
127
+ label: "Arrival Date"
128
+ }
129
+ });
130
+
131
+ assert.deepEqual(resolveCrudListFilterQueryKeys(filters.status), ["status"]);
132
+ assert.deepEqual(resolveCrudListFilterQueryKeys(filters.arrivalDate), ["arrivalDateFrom", "arrivalDateTo"]);
133
+ assert.equal(resolveCrudListFilterOptionLabel(filters.status, "active"), "Active");
134
+ assert.equal(resolveCrudListFilterOptionLabel(filters.status, "missing", { fallback: "Unknown" }), "Unknown");
135
+ });