@jskit-ai/crud-core 0.1.26 → 0.1.28

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.
@@ -0,0 +1,381 @@
1
+ import { toInsertDateTime } from "@jskit-ai/database-runtime/shared";
2
+ import { applyVisibility, applyVisibilityOwners } from "@jskit-ai/database-runtime/shared/visibility";
3
+ import { normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { Check, Errors } from "typebox/value";
5
+ import {
6
+ DEFAULT_LIST_LIMIT,
7
+ MAX_LIST_LIMIT,
8
+ buildRepositoryColumnMetadata,
9
+ deriveRepositoryMappingFromResource,
10
+ normalizeCrudListLimit,
11
+ requireCrudTableName,
12
+ applyCrudListQueryFilters,
13
+ buildWritePayload,
14
+ mapRecordRow,
15
+ resolveColumnName,
16
+ resolveCrudIdColumn
17
+ } from "./repositorySupport.js";
18
+ import {
19
+ createCrudLookupRuntime,
20
+ hydrateCrudLookupRecords
21
+ } from "./lookupHydration.js";
22
+
23
+ function resolveRepositoryDefaults(resource = {}, repositoryMapping = {}) {
24
+ const resourceName = normalizeText(resource.resource);
25
+ const tableName = normalizeText(resource.tableName) || resourceName;
26
+ if (!tableName) {
27
+ throw new TypeError("createCrudRepositoryFromResource requires resource.tableName or resource.resource.");
28
+ }
29
+
30
+ const idColumn = normalizeText(resource.idColumn) || resolveColumnName("id", repositoryMapping.columnOverrides) || "id";
31
+ const createdAtColumn = repositoryMapping.outputKeys.includes("createdAt")
32
+ ? resolveColumnName("createdAt", repositoryMapping.columnOverrides)
33
+ : "";
34
+ const updatedAtColumn = repositoryMapping.outputKeys.includes("updatedAt")
35
+ ? resolveColumnName("updatedAt", repositoryMapping.columnOverrides)
36
+ : "";
37
+
38
+ return Object.freeze({
39
+ tableName,
40
+ idColumn,
41
+ createdAtColumn,
42
+ updatedAtColumn
43
+ });
44
+ }
45
+
46
+ function normalizeSearchColumns(searchColumns = [], fallbackColumns = []) {
47
+ const normalizedConfiguredColumns = (Array.isArray(searchColumns) ? searchColumns : [])
48
+ .map((columnName) => String(columnName || "").trim())
49
+ .filter(Boolean);
50
+
51
+ if (normalizedConfiguredColumns.length > 0) {
52
+ return Object.freeze([...new Set(normalizedConfiguredColumns)]);
53
+ }
54
+
55
+ return Object.freeze(
56
+ (Array.isArray(fallbackColumns) ? fallbackColumns : [])
57
+ .map((columnName) => String(columnName || "").trim())
58
+ .filter(Boolean)
59
+ );
60
+ }
61
+
62
+ function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = []) {
63
+ const parsedMaxLimit = Number(list?.maxLimit);
64
+ const normalizedMaxLimit = Number.isInteger(parsedMaxLimit) && parsedMaxLimit > 0
65
+ ? parsedMaxLimit
66
+ : MAX_LIST_LIMIT;
67
+ const normalizedDefaultLimit = normalizeCrudListLimit(list?.defaultLimit, {
68
+ fallback: DEFAULT_LIST_LIMIT,
69
+ max: normalizedMaxLimit
70
+ });
71
+
72
+ return Object.freeze({
73
+ defaultLimit: normalizedDefaultLimit,
74
+ maxLimit: normalizedMaxLimit,
75
+ searchColumns: normalizeSearchColumns(list?.searchColumns, fallbackSearchColumns)
76
+ });
77
+ }
78
+
79
+ function resolveRecordOutputValidator(resource = {}, { context = "crudRepository" } = {}) {
80
+ const outputValidator = resource?.operations?.view?.outputValidator;
81
+ if (!outputValidator || typeof outputValidator !== "object" || Array.isArray(outputValidator)) {
82
+ throw new TypeError(`${context} requires resource.operations.view.outputValidator.`);
83
+ }
84
+
85
+ const schema = outputValidator?.schema;
86
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
87
+ throw new TypeError(`${context} requires resource.operations.view.outputValidator.schema.`);
88
+ }
89
+
90
+ const normalize = typeof outputValidator.normalize === "function"
91
+ ? outputValidator.normalize
92
+ : (payload = {}) => payload;
93
+
94
+ return Object.freeze({
95
+ schema,
96
+ normalize
97
+ });
98
+ }
99
+
100
+ function formatOutputValidationError(error = {}) {
101
+ const path = normalizeText(error?.instancePath || error?.path) || "/";
102
+ const message = normalizeText(error?.message) || "invalid output value";
103
+ return `${path} ${message}`;
104
+ }
105
+
106
+ async function normalizeRepositoryOutputRecord(runtime, record = {}, { operation = "read" } = {}) {
107
+ const outputRuntime = runtime?.output || {};
108
+ const normalizedRecord = await outputRuntime.normalize(record);
109
+ if (Check(outputRuntime.schema, normalizedRecord)) {
110
+ return normalizedRecord;
111
+ }
112
+
113
+ const issues = [...Errors(outputRuntime.schema, normalizedRecord)];
114
+ const formattedIssue = formatOutputValidationError(issues[0]);
115
+ throw new TypeError(
116
+ `${runtime?.context || "crudRepository"} ${operation} output validation failed: ${formattedIssue}.`
117
+ );
118
+ }
119
+
120
+ function createCrudRepositoryRuntime(resource = {}, { context = "crudRepository", list = {} } = {}) {
121
+ const repositoryMapping = deriveRepositoryMappingFromResource(resource, { context });
122
+ const defaults = resolveRepositoryDefaults(resource, repositoryMapping);
123
+ const output = resolveRecordOutputValidator(resource, { context });
124
+ const lookupRuntime = createCrudLookupRuntime(resource, {
125
+ outputKeys: repositoryMapping.outputKeys
126
+ });
127
+ const { selectColumns } = buildRepositoryColumnMetadata({
128
+ outputKeys: repositoryMapping.outputKeys,
129
+ writeKeys: repositoryMapping.writeKeys,
130
+ columnOverrides: repositoryMapping.columnOverrides
131
+ });
132
+
133
+ return Object.freeze({
134
+ context,
135
+ defaults,
136
+ selectColumns,
137
+ output,
138
+ list: resolveListRuntimeConfig(list, repositoryMapping.listSearchColumns),
139
+ lookup: lookupRuntime,
140
+ mapping: repositoryMapping
141
+ });
142
+ }
143
+
144
+ function resolveCrudRepositoryCall(runtime, knex, repositoryOptions = {}, callOptions = {}) {
145
+ if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) {
146
+ throw new TypeError("crudRepository methods require runtime.");
147
+ }
148
+ if (typeof knex !== "function") {
149
+ throw new TypeError("crudRepository requires knex.");
150
+ }
151
+
152
+ const client = callOptions?.trx || knex;
153
+ const tableName = requireCrudTableName(repositoryOptions?.tableName ?? runtime.defaults?.tableName, {
154
+ context: runtime.context || "crudRepository"
155
+ });
156
+ const idColumn = resolveCrudIdColumn(repositoryOptions?.idColumn, {
157
+ fallback: runtime.defaults?.idColumn || "id"
158
+ });
159
+ const visible = (queryBuilder) => applyVisibility(queryBuilder, callOptions.visibilityContext);
160
+
161
+ return Object.freeze({
162
+ client,
163
+ tableName,
164
+ idColumn,
165
+ visible
166
+ });
167
+ }
168
+
169
+ async function crudRepositoryList(runtime, knex, query = {}, repositoryOptions = {}, callOptions = {}) {
170
+ const { client, tableName, idColumn, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
171
+ const normalizedLimit = normalizeCrudListLimit(query?.limit, {
172
+ fallback: runtime.list.defaultLimit,
173
+ max: runtime.list.maxLimit
174
+ });
175
+ let dbQuery = client(tableName)
176
+ .select(...runtime.selectColumns)
177
+ .where(visible)
178
+ .orderBy(idColumn, "asc")
179
+ .limit(normalizedLimit + 1);
180
+
181
+ dbQuery = applyCrudListQueryFilters(dbQuery, {
182
+ idColumn,
183
+ cursor: query?.cursor,
184
+ q: query?.q,
185
+ searchColumns: runtime.list.searchColumns,
186
+ parentFilters: query,
187
+ parentFilterColumns: runtime.mapping.parentFilterColumns
188
+ });
189
+
190
+ const rows = await dbQuery;
191
+ const hasMore = rows.length > normalizedLimit;
192
+ const pageRows = hasMore ? rows.slice(0, normalizedLimit) : rows;
193
+ const items = [];
194
+ for (const row of pageRows) {
195
+ const mappedRecord = mapRecordRow(row, runtime.mapping.outputKeys, runtime.mapping.columnOverrides);
196
+ if (!mappedRecord) {
197
+ continue;
198
+ }
199
+
200
+ items.push(await normalizeRepositoryOutputRecord(runtime, mappedRecord, {
201
+ operation: "list"
202
+ }));
203
+ }
204
+
205
+ const hydratedItems = await hydrateCrudLookupRecords(items, {
206
+ ...runtime.lookup,
207
+ context: runtime.context
208
+ }, {
209
+ include: query?.include,
210
+ mode: "list",
211
+ repositoryOptions,
212
+ callOptions
213
+ });
214
+
215
+ return {
216
+ items: hydratedItems,
217
+ nextCursor: hasMore && items.length > 0 ? String(items[items.length - 1].id) : null
218
+ };
219
+ }
220
+
221
+ async function crudRepositoryFindById(runtime, knex, recordId, repositoryOptions = {}, callOptions = {}) {
222
+ const { client, tableName, idColumn, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
223
+ const row = await client(tableName)
224
+ .select(...runtime.selectColumns)
225
+ .where(visible)
226
+ .where({
227
+ [idColumn]: Number(recordId)
228
+ })
229
+ .first();
230
+
231
+ const mappedRecord = mapRecordRow(row, runtime.mapping.outputKeys, runtime.mapping.columnOverrides);
232
+ if (!mappedRecord) {
233
+ return null;
234
+ }
235
+ const normalizedRecord = await normalizeRepositoryOutputRecord(runtime, mappedRecord, {
236
+ operation: "findById"
237
+ });
238
+
239
+ const hydrated = await hydrateCrudLookupRecords([normalizedRecord], {
240
+ ...runtime.lookup,
241
+ context: runtime.context
242
+ }, {
243
+ include: callOptions?.include,
244
+ mode: "view",
245
+ repositoryOptions,
246
+ callOptions
247
+ });
248
+
249
+ return hydrated[0] || null;
250
+ }
251
+
252
+ async function crudRepositoryListByIds(runtime, knex, ids = [], repositoryOptions = {}, callOptions = {}) {
253
+ const { client, tableName, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
254
+ const lookupValueKey = normalizeText(callOptions?.valueKey) || "id";
255
+ if (!runtime.mapping.outputKeys.includes(lookupValueKey)) {
256
+ throw new TypeError(
257
+ `${runtime.context || "crudRepository"} listByIds requires valueKey "${lookupValueKey}" to exist in output schema.`
258
+ );
259
+ }
260
+ const lookupColumn = resolveColumnName(lookupValueKey, runtime.mapping.columnOverrides);
261
+ if (!lookupColumn) {
262
+ throw new TypeError(`${runtime.context || "crudRepository"} listByIds requires a valid valueKey.`);
263
+ }
264
+
265
+ const normalizedIds = normalizeUniqueTextList(ids);
266
+ if (normalizedIds.length < 1) {
267
+ return [];
268
+ }
269
+
270
+ const rows = await client(tableName)
271
+ .select(...runtime.selectColumns)
272
+ .where(visible)
273
+ .whereIn(lookupColumn, normalizedIds);
274
+
275
+ const records = [];
276
+ for (const row of rows) {
277
+ const mappedRecord = mapRecordRow(row, runtime.mapping.outputKeys, runtime.mapping.columnOverrides);
278
+ if (!mappedRecord) {
279
+ continue;
280
+ }
281
+
282
+ records.push(await normalizeRepositoryOutputRecord(runtime, mappedRecord, {
283
+ operation: "listByIds"
284
+ }));
285
+ }
286
+
287
+ const lookupInclude = callOptions?.include === undefined ? "none" : callOptions.include;
288
+
289
+ return hydrateCrudLookupRecords(records, {
290
+ ...runtime.lookup,
291
+ context: runtime.context
292
+ }, {
293
+ include: lookupInclude,
294
+ mode: "list",
295
+ repositoryOptions,
296
+ callOptions
297
+ });
298
+ }
299
+
300
+ async function crudRepositoryCreate(runtime, knex, payload = {}, repositoryOptions = {}, callOptions = {}) {
301
+ const { client, tableName } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
302
+ const insertPayload = buildWritePayload(payload, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
303
+ const timestamp = toInsertDateTime();
304
+ if (runtime.defaults.createdAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.createdAtColumn)) {
305
+ insertPayload[runtime.defaults.createdAtColumn] = timestamp;
306
+ }
307
+ if (runtime.defaults.updatedAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.updatedAtColumn)) {
308
+ insertPayload[runtime.defaults.updatedAtColumn] = timestamp;
309
+ }
310
+
311
+ const withOwners = applyVisibilityOwners(insertPayload, callOptions.visibilityContext);
312
+ const [recordId] = await client(tableName).insert(withOwners);
313
+
314
+ return crudRepositoryFindById(runtime, knex, recordId, repositoryOptions, {
315
+ ...callOptions,
316
+ trx: client
317
+ });
318
+ }
319
+
320
+ async function crudRepositoryUpdateById(runtime, knex, recordId, patch = {}, repositoryOptions = {}, callOptions = {}) {
321
+ const { client, tableName, idColumn, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
322
+ const dbPatch = buildWritePayload(patch, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
323
+
324
+ if (runtime.defaults.updatedAtColumn) {
325
+ dbPatch[runtime.defaults.updatedAtColumn] = toInsertDateTime();
326
+ }
327
+
328
+ if (Object.keys(dbPatch).length < 1) {
329
+ return crudRepositoryFindById(runtime, knex, recordId, repositoryOptions, {
330
+ ...callOptions,
331
+ trx: client
332
+ });
333
+ }
334
+
335
+ await client(tableName)
336
+ .where(visible)
337
+ .where({
338
+ [idColumn]: Number(recordId)
339
+ })
340
+ .update(dbPatch);
341
+
342
+ return crudRepositoryFindById(runtime, knex, recordId, repositoryOptions, {
343
+ ...callOptions,
344
+ trx: client
345
+ });
346
+ }
347
+
348
+ async function crudRepositoryDeleteById(runtime, knex, recordId, repositoryOptions = {}, callOptions = {}) {
349
+ const { client, tableName, idColumn, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
350
+ const existing = await crudRepositoryFindById(runtime, knex, recordId, repositoryOptions, {
351
+ ...callOptions,
352
+ include: "none",
353
+ trx: client
354
+ });
355
+
356
+ if (!existing) {
357
+ return null;
358
+ }
359
+
360
+ await client(tableName)
361
+ .where(visible)
362
+ .where({
363
+ [idColumn]: Number(recordId)
364
+ })
365
+ .delete();
366
+
367
+ return {
368
+ id: existing.id,
369
+ deleted: true
370
+ };
371
+ }
372
+
373
+ export {
374
+ createCrudRepositoryRuntime,
375
+ crudRepositoryList,
376
+ crudRepositoryFindById,
377
+ crudRepositoryListByIds,
378
+ crudRepositoryCreate,
379
+ crudRepositoryUpdateById,
380
+ crudRepositoryDeleteById
381
+ };
@@ -1,6 +1,11 @@
1
1
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
2
  import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators/inputNormalization";
3
3
  import { toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
4
+ import {
5
+ resolveCrudLookupContainerKey,
6
+ resolveCrudLookupFieldKeys
7
+ } from "@jskit-ai/kernel/shared/support/crudLookup";
8
+ import { isCrudRuntimeOutputOnlyFieldKey } from "../shared/crudFieldMetaSupport.js";
4
9
 
5
10
  const DEFAULT_LIST_LIMIT = 20;
6
11
  const MAX_LIST_LIMIT = 100;
@@ -70,6 +75,131 @@ function buildRepositoryColumnMetadata({
70
75
  });
71
76
  }
72
77
 
78
+ function requireObjectSchemaProperties(schema, { context = "crudRepository", schemaLabel = "schema" } = {}) {
79
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
80
+ throw new TypeError(`${context} requires ${schemaLabel} to be an object schema.`);
81
+ }
82
+
83
+ const properties = schema.properties;
84
+ if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
85
+ throw new TypeError(`${context} requires ${schemaLabel}.properties.`);
86
+ }
87
+
88
+ return properties;
89
+ }
90
+
91
+ function normalizeResourceFieldMetaEntries(fieldMeta = []) {
92
+ if (!Array.isArray(fieldMeta)) {
93
+ return [];
94
+ }
95
+
96
+ const normalized = [];
97
+ const seenKeys = new Set();
98
+ for (const rawEntry of fieldMeta) {
99
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
100
+ continue;
101
+ }
102
+
103
+ const key = normalizeText(rawEntry.key);
104
+ if (!key || seenKeys.has(key)) {
105
+ continue;
106
+ }
107
+ seenKeys.add(key);
108
+ normalized.push(rawEntry);
109
+ }
110
+
111
+ return normalized;
112
+ }
113
+
114
+ function schemaIncludesStringType(schema = {}) {
115
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
116
+ return false;
117
+ }
118
+
119
+ const type = normalizeText(schema.type).toLowerCase();
120
+ if (type === "string") {
121
+ return true;
122
+ }
123
+
124
+ const variants = Array.isArray(schema.anyOf)
125
+ ? schema.anyOf
126
+ : Array.isArray(schema.oneOf)
127
+ ? schema.oneOf
128
+ : [];
129
+ return variants.some((entry) => schemaIncludesStringType(entry));
130
+ }
131
+
132
+ function deriveRepositoryMappingFromResource(resource = {}, { context = "crudRepository" } = {}) {
133
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
134
+ throw new TypeError(`${context} requires resource object.`);
135
+ }
136
+
137
+ const operations = resource.operations;
138
+ if (!operations || typeof operations !== "object" || Array.isArray(operations)) {
139
+ throw new TypeError(`${context} requires resource.operations.`);
140
+ }
141
+
142
+ const outputSchema = operations?.view?.outputValidator?.schema;
143
+ const writeSchema = operations?.create?.bodyValidator?.schema;
144
+ const outputProperties = requireObjectSchemaProperties(outputSchema, {
145
+ context,
146
+ schemaLabel: "operations.view.outputValidator.schema"
147
+ });
148
+ const writeProperties = requireObjectSchemaProperties(writeSchema, {
149
+ context,
150
+ schemaLabel: "operations.create.bodyValidator.schema"
151
+ });
152
+ const lookupContainerKey = resolveCrudLookupContainerKey(resource, {
153
+ context: `${context} resource.contract.lookup.containerKey`
154
+ });
155
+ const outputKeys = Object.freeze(
156
+ Object.keys(outputProperties).filter(
157
+ (key) => !isCrudRuntimeOutputOnlyFieldKey(key, { lookupContainerKey })
158
+ )
159
+ );
160
+ const writeKeys = Object.freeze(Object.keys(writeProperties));
161
+
162
+ const columnOverrides = {};
163
+ for (const entry of normalizeResourceFieldMetaEntries(resource.fieldMeta)) {
164
+ const key = normalizeText(entry.key);
165
+ const dbColumn = normalizeText(entry.dbColumn);
166
+ if (!key || !dbColumn) {
167
+ continue;
168
+ }
169
+ columnOverrides[key] = dbColumn;
170
+ }
171
+
172
+ const listSearchColumns = [];
173
+ for (const [key, schema] of Object.entries(outputProperties)) {
174
+ if (!schemaIncludesStringType(schema)) {
175
+ continue;
176
+ }
177
+
178
+ const columnName = resolveColumnName(key, columnOverrides);
179
+ if (!columnName || listSearchColumns.includes(columnName)) {
180
+ continue;
181
+ }
182
+ listSearchColumns.push(columnName);
183
+ }
184
+
185
+ const parentFilterColumns = {};
186
+ for (const key of resolveCrudLookupFieldKeys(resource, { allowKeys: writeKeys })) {
187
+ const columnName = resolveColumnName(key, columnOverrides);
188
+ if (!columnName) {
189
+ continue;
190
+ }
191
+ parentFilterColumns[key] = columnName;
192
+ }
193
+
194
+ return Object.freeze({
195
+ outputKeys,
196
+ writeKeys,
197
+ columnOverrides: Object.freeze(columnOverrides),
198
+ listSearchColumns: Object.freeze(listSearchColumns),
199
+ parentFilterColumns: Object.freeze(parentFilterColumns)
200
+ });
201
+ }
202
+
73
203
  function mapRecordRow(row, fieldKeys = [], overrides = {}) {
74
204
  if (!row) {
75
205
  return null;
@@ -87,6 +217,79 @@ function mapRecordRow(row, fieldKeys = [], overrides = {}) {
87
217
  return mapped;
88
218
  }
89
219
 
220
+ function applyCrudListQueryFilters(
221
+ query,
222
+ {
223
+ idColumn = "id",
224
+ cursor = 0,
225
+ q = "",
226
+ searchColumns = [],
227
+ parentFilters = {},
228
+ parentFilterColumns = {}
229
+ } = {}
230
+ ) {
231
+ if (!query || typeof query.modify !== "function" || typeof query.where !== "function") {
232
+ throw new TypeError("applyCrudListQueryFilters requires query builder.");
233
+ }
234
+
235
+ const normalizedSearch = String(q || "").trim();
236
+ const normalizedSearchColumns = (Array.isArray(searchColumns) ? searchColumns : [])
237
+ .map((columnName) => String(columnName || "").trim())
238
+ .filter(Boolean);
239
+
240
+ let nextQuery = query;
241
+
242
+ const sourceParentFilters = normalizeObjectInput(parentFilters);
243
+ const normalizedParentFilterColumns = parentFilterColumns && typeof parentFilterColumns === "object" && !Array.isArray(parentFilterColumns)
244
+ ? parentFilterColumns
245
+ : {};
246
+
247
+ for (const [fieldKey, columnNameRaw] of Object.entries(normalizedParentFilterColumns)) {
248
+ if (!Object.hasOwn(sourceParentFilters, fieldKey)) {
249
+ continue;
250
+ }
251
+
252
+ const columnName = String(columnNameRaw || "").trim();
253
+ if (!columnName) {
254
+ continue;
255
+ }
256
+
257
+ const sourceValue = sourceParentFilters[fieldKey];
258
+ const normalizedValue = typeof sourceValue === "string"
259
+ ? sourceValue.trim()
260
+ : sourceValue;
261
+ if (normalizedValue === "" || normalizedValue === undefined || normalizedValue === null) {
262
+ continue;
263
+ }
264
+
265
+ nextQuery = nextQuery.where(columnName, normalizedValue);
266
+ }
267
+
268
+ if (normalizedSearch && normalizedSearchColumns.length > 0) {
269
+ const searchPattern = `%${normalizedSearch}%`;
270
+ nextQuery = nextQuery.modify((searchQuery) => {
271
+ searchQuery.where((whereQuery) => {
272
+ for (const [index, columnName] of normalizedSearchColumns.entries()) {
273
+ if (index === 0) {
274
+ whereQuery.where(columnName, "like", searchPattern);
275
+ continue;
276
+ }
277
+ whereQuery.orWhere(columnName, "like", searchPattern);
278
+ }
279
+ });
280
+ });
281
+ }
282
+
283
+ const numericCursor = Number(cursor);
284
+ const normalizedCursor = Number.isInteger(numericCursor) && numericCursor > 0 ? numericCursor : 0;
285
+ const normalizedIdColumn = String(idColumn || "").trim() || "id";
286
+ if (normalizedCursor > 0) {
287
+ nextQuery = nextQuery.where(normalizedIdColumn, ">", normalizedCursor);
288
+ }
289
+
290
+ return nextQuery;
291
+ }
292
+
90
293
  function buildWritePayload(sourcePayload = {}, fieldKeys = [], overrides = {}) {
91
294
  const source = normalizeObjectInput(sourcePayload);
92
295
  const payload = {};
@@ -117,6 +320,8 @@ export {
117
320
  MAX_LIST_LIMIT,
118
321
  normalizeCrudListLimit,
119
322
  requireCrudTableName,
323
+ deriveRepositoryMappingFromResource,
324
+ applyCrudListQueryFilters,
120
325
  mapRecordRow,
121
326
  buildWritePayload,
122
327
  resolveColumnName,
@@ -0,0 +1,53 @@
1
+ import {
2
+ requireCrudNamespace,
3
+ resolveCrudRecordChangedEvent
4
+ } from "../shared/crudNamespaceSupport.js";
5
+
6
+ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}) {
7
+ const namespace = requireCrudNamespace(resource?.resource, { context: `${context} resource.resource` });
8
+ const recordChangedEventName = resolveCrudRecordChangedEvent(namespace);
9
+
10
+ return Object.freeze({
11
+ createRecord: Object.freeze([
12
+ Object.freeze({
13
+ type: "entity.changed",
14
+ source: "crud",
15
+ entity: "record",
16
+ operation: "created",
17
+ entityId: ({ result }) => result?.id,
18
+ realtime: Object.freeze({
19
+ event: recordChangedEventName,
20
+ audience: "event_scope"
21
+ })
22
+ })
23
+ ]),
24
+ updateRecord: Object.freeze([
25
+ Object.freeze({
26
+ type: "entity.changed",
27
+ source: "crud",
28
+ entity: "record",
29
+ operation: "updated",
30
+ entityId: ({ result }) => result?.id,
31
+ realtime: Object.freeze({
32
+ event: recordChangedEventName,
33
+ audience: "event_scope"
34
+ })
35
+ })
36
+ ]),
37
+ deleteRecord: Object.freeze([
38
+ Object.freeze({
39
+ type: "entity.changed",
40
+ source: "crud",
41
+ entity: "record",
42
+ operation: "deleted",
43
+ entityId: ({ result }) => result?.id,
44
+ realtime: Object.freeze({
45
+ event: recordChangedEventName,
46
+ audience: "event_scope"
47
+ })
48
+ })
49
+ ])
50
+ });
51
+ }
52
+
53
+ export { createCrudServiceEvents };