spice-js 2.7.29 → 2.7.31

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": "spice-js",
3
- "version": "2.7.29",
3
+ "version": "2.7.31",
4
4
  "description": "spice",
5
5
  "main": "build/index.js",
6
6
  "repository": {
package/src/index.js CHANGED
@@ -2,7 +2,9 @@
2
2
  var path = require("path");
3
3
  //import IO from "koa-socket-2";
4
4
  const cors = require("kcors");
5
- global.spice = {};
5
+ // Preserve an existing host/test configuration when the package is imported.
6
+ // Production boot still starts from an empty object because spice is undefined.
7
+ global.spice = global.spice || {};
6
8
 
7
9
  require("dotenv").config({ path: path.resolve("./") });
8
10
 
@@ -22,9 +22,66 @@ var SDate = require("sonover-date"),
22
22
  _serializers = Symbol(),
23
23
  _level = Symbol(),
24
24
  _mapping_dept = Symbol(),
25
+ _mapping_dept_scalar = Symbol(),
26
+ _mapping_dept_array = Symbol(),
27
+ _mapping_concurrency = Symbol(),
28
+ _skip_mapped_serialization = Symbol(),
25
29
  _mapping_dept_exempt = Symbol(),
26
30
  _current_path = Symbol();
27
31
 
32
+ const GLOBAL_MAPPING_CONCURRENCY = Math.max(
33
+ 1,
34
+ Number(process.env.SPICE_MAPPING_GLOBAL_CONCURRENCY) || 32,
35
+ );
36
+ let activeGlobalMappings = 0;
37
+ const globalMappingWaiters = [];
38
+
39
+ function normalizeConcurrency(value, fallback = 1) {
40
+ const parsed = Number(value);
41
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
42
+ }
43
+
44
+ async function acquireGlobalMappingSlot() {
45
+ const queuedAt = process.hrtime.bigint();
46
+ if (activeGlobalMappings < GLOBAL_MAPPING_CONCURRENCY) {
47
+ activeGlobalMappings += 1;
48
+ } else {
49
+ await new Promise((resolve) => globalMappingWaiters.push(resolve));
50
+ }
51
+
52
+ return {
53
+ active: activeGlobalMappings,
54
+ waitMs: Number(process.hrtime.bigint() - queuedAt) / 1e6,
55
+ release() {
56
+ const next = globalMappingWaiters.shift();
57
+ if (next) {
58
+ next();
59
+ } else {
60
+ activeGlobalMappings -= 1;
61
+ }
62
+ },
63
+ };
64
+ }
65
+
66
+ async function runWithConcurrency(items, concurrency, worker) {
67
+ const results = new Array(items.length);
68
+ let nextIndex = 0;
69
+
70
+ async function runWorker() {
71
+ while (nextIndex < items.length) {
72
+ const index = nextIndex;
73
+ nextIndex += 1;
74
+ results[index] = await worker(items[index], index);
75
+ }
76
+ }
77
+
78
+ const workerCount = Math.min(concurrency, items.length);
79
+ await Promise.all(
80
+ Array.from({ length: workerCount }, () => runWorker()),
81
+ );
82
+ return results;
83
+ }
84
+
28
85
  //const _type = Symbol("type");
29
86
 
30
87
  let that;
@@ -47,12 +104,36 @@ if (!Promise.allSettled) {
47
104
  export default class SpiceModel {
48
105
  constructor(args = {}) {
49
106
  try {
107
+ const modelArgs = args?.args || {};
108
+ const requestProfile = modelArgs?.ctx?.state?.read_profile || {};
109
+ const defaultMappingDept = process.env.MAPPING_DEPT || 3;
110
+ const legacyMappingDept =
111
+ modelArgs.mapping_dept !== undefined && modelArgs.mapping_dept !== null ?
112
+ modelArgs.mapping_dept
113
+ : undefined;
114
+
50
115
  var dbtype =
51
116
  spice.config.database.connections[args.connection].type || "couchbase";
52
117
  let Database = require(`spice-${dbtype}`);
53
- this[_mapping_dept] =
54
- args?.args?.mapping_dept || process.env.MAPPING_DEPT || 3;
55
- this[_mapping_dept_exempt] = args?.args?.mapping_dept_exempt || [];
118
+ this[_mapping_dept] = legacyMappingDept ?? defaultMappingDept;
119
+ this[_mapping_dept_scalar] =
120
+ modelArgs.mapping_dept_scalar ??
121
+ legacyMappingDept ??
122
+ requestProfile.mapping_dept_scalar ??
123
+ defaultMappingDept;
124
+ this[_mapping_dept_array] =
125
+ modelArgs.mapping_dept_array ??
126
+ legacyMappingDept ??
127
+ requestProfile.mapping_dept_array ??
128
+ defaultMappingDept;
129
+ this[_mapping_concurrency] = normalizeConcurrency(
130
+ modelArgs.mapping_concurrency ?? requestProfile.mapping_concurrency,
131
+ );
132
+ this[_skip_mapped_serialization] =
133
+ modelArgs.skip_mapped_serialization ??
134
+ requestProfile.skip_mapped_serialization ??
135
+ false;
136
+ this[_mapping_dept_exempt] = modelArgs.mapping_dept_exempt || [];
56
137
  this.type = "";
57
138
  this.collection = args.connection;
58
139
  this[_args] = args.args;
@@ -237,6 +318,41 @@ export default class SpiceModel {
237
318
  // }
238
319
  }
239
320
 
321
+ applyMappingDepthArgs(args = {}) {
322
+ const hasLegacyDepth =
323
+ args.mapping_dept !== undefined && args.mapping_dept !== null;
324
+
325
+ if (hasLegacyDepth) {
326
+ this[_mapping_dept] = args.mapping_dept;
327
+ this[_mapping_dept_scalar] = args.mapping_dept;
328
+ this[_mapping_dept_array] = args.mapping_dept;
329
+ }
330
+ if (
331
+ args.mapping_dept_scalar !== undefined &&
332
+ args.mapping_dept_scalar !== null
333
+ ) {
334
+ this[_mapping_dept_scalar] = args.mapping_dept_scalar;
335
+ }
336
+ if (
337
+ args.mapping_dept_array !== undefined &&
338
+ args.mapping_dept_array !== null
339
+ ) {
340
+ this[_mapping_dept_array] = args.mapping_dept_array;
341
+ }
342
+ if (args.skip_mapped_serialization !== undefined) {
343
+ this[_skip_mapped_serialization] = args.skip_mapped_serialization;
344
+ }
345
+ if (args.mapping_concurrency !== undefined) {
346
+ this[_mapping_concurrency] = normalizeConcurrency(
347
+ args.mapping_concurrency,
348
+ this[_mapping_concurrency],
349
+ );
350
+ }
351
+ if (args.mapping_dept_exempt) {
352
+ this[_mapping_dept_exempt] = args.mapping_dept_exempt;
353
+ }
354
+ }
355
+
240
356
  get database() {
241
357
  return this[_database];
242
358
  }
@@ -515,13 +631,10 @@ export default class SpiceModel {
515
631
  const p = this[_ctx]?.profiler;
516
632
 
517
633
  const doGet = async () => {
518
- if (args.mapping_dept) this[_mapping_dept] = args.mapping_dept;
519
- if (args.mapping_dept_exempt)
520
- this[_mapping_dept_exempt] = args.mapping_dept_exempt;
521
-
522
634
  if (!args) {
523
635
  args = {};
524
636
  }
637
+ this.applyMappingDepthArgs(args);
525
638
  if (_.isString(args.id)) {
526
639
  if (args.skip_hooks !== true) {
527
640
  await this.run_hook(args, "get", "before");
@@ -687,10 +800,13 @@ export default class SpiceModel {
687
800
  if (!args) {
688
801
  args = {};
689
802
  }
803
+ this.applyMappingDepthArgs(args);
690
804
  if (args.skip_hooks != true) {
691
805
  await this.run_hook(this, "list", "before");
692
806
  }
693
- _.remove(args.ids, (o) => o == undefined);
807
+ args.ids = _.uniq(
808
+ _.filter(args.ids || [], (id) => _.isString(id) && id.length > 0),
809
+ );
694
810
  let key = `multi-get::${this.type}::${_.join(args.ids, "|")}:${args.columns}`;
695
811
  let results = [];
696
812
 
@@ -1384,9 +1500,7 @@ export default class SpiceModel {
1384
1500
  const p = this[_ctx]?.profiler;
1385
1501
 
1386
1502
  const doList = async () => {
1387
- if (args.mapping_dept) this[_mapping_dept] = args.mapping_dept;
1388
- if (args.mapping_dept_exempt)
1389
- this[_mapping_dept_exempt] = args.mapping_dept_exempt;
1503
+ this.applyMappingDepthArgs(args);
1390
1504
 
1391
1505
  // Filter out id, meta().id, and any column ending with .id (e.g., dependents.id)
1392
1506
  if (args.columns) {
@@ -1558,6 +1672,13 @@ export default class SpiceModel {
1558
1672
 
1559
1673
  const doFetch = async () => {
1560
1674
  if (args.is_custom_query === "true" && args.ids.length > 0) {
1675
+ console.log(
1676
+ "Fetching results from database",
1677
+ this.type,
1678
+ args,
1679
+ query,
1680
+ mappedNestings,
1681
+ );
1561
1682
  return await this.database.query(query);
1562
1683
  } else if (args.is_full_text === "true") {
1563
1684
  return await this.database.full_text_search(
@@ -1717,7 +1838,9 @@ export default class SpiceModel {
1717
1838
  data = Array.of(data);
1718
1839
  }
1719
1840
  const isExempt = this.isFieldExempt(source_property);
1720
- if (isExempt || this[_level] + 1 < this[_mapping_dept]) {
1841
+ const activeMappingDept =
1842
+ type === "read" ? this[_mapping_dept_scalar] : this[_mapping_dept];
1843
+ if (isExempt || this[_level] + 1 < activeMappingDept) {
1721
1844
  let classes = _.compact(_.isArray(Class) ? Class : [Class]);
1722
1845
 
1723
1846
  let ids = [];
@@ -1761,6 +1884,10 @@ export default class SpiceModel {
1761
1884
  skip_cache: this[_skip_cache],
1762
1885
  _level: this[_level] + 1,
1763
1886
  mapping_dept: this[_mapping_dept],
1887
+ mapping_dept_scalar: this[_mapping_dept_scalar],
1888
+ mapping_dept_array: this[_mapping_dept_array],
1889
+ mapping_concurrency: this[_mapping_concurrency],
1890
+ skip_mapped_serialization: this[_skip_mapped_serialization],
1764
1891
  mapping_dept_exempt: this[_mapping_dept_exempt],
1765
1892
  _columns: columnArray.join(","),
1766
1893
  _current_path: childPath,
@@ -1770,6 +1897,7 @@ export default class SpiceModel {
1770
1897
  skip_hooks: true,
1771
1898
  ids: ids,
1772
1899
  columns: columnArray,
1900
+ skip_read_serialize: this[_skip_mapped_serialization],
1773
1901
  });
1774
1902
  }),
1775
1903
  );
@@ -1881,7 +2009,9 @@ export default class SpiceModel {
1881
2009
  data = Array.of(data);
1882
2010
  }
1883
2011
  const isExempt = this.isFieldExempt(source_property);
1884
- if (isExempt || this[_level] + 1 < this[_mapping_dept]) {
2012
+ const activeMappingDept =
2013
+ type === "read" ? this[_mapping_dept_array] : this[_mapping_dept];
2014
+ if (isExempt || this[_level] + 1 < activeMappingDept) {
1885
2015
  let ids = [];
1886
2016
  _.each(data, (result) => {
1887
2017
  let value = result[source_property];
@@ -1932,6 +2062,10 @@ export default class SpiceModel {
1932
2062
  skip_cache: this[_skip_cache],
1933
2063
  _level: this[_level] + 1,
1934
2064
  mapping_dept: this[_mapping_dept],
2065
+ mapping_dept_scalar: this[_mapping_dept_scalar],
2066
+ mapping_dept_array: this[_mapping_dept_array],
2067
+ mapping_concurrency: this[_mapping_concurrency],
2068
+ skip_mapped_serialization: this[_skip_mapped_serialization],
1935
2069
  mapping_dept_exempt: this[_mapping_dept_exempt],
1936
2070
  _columns: columnArray.join(","),
1937
2071
  _current_path: childPath,
@@ -1939,6 +2073,7 @@ export default class SpiceModel {
1939
2073
  skip_hooks: true,
1940
2074
  ids: ids,
1941
2075
  columns: columnArray,
2076
+ skip_read_serialize: this[_skip_mapped_serialization],
1942
2077
  });
1943
2078
  }),
1944
2079
  );
@@ -2012,8 +2147,21 @@ export default class SpiceModel {
2012
2147
  return original_is_array ? data : data[0];
2013
2148
  }
2014
2149
 
2015
- addModifier({ when, execute }) {
2150
+ addModifier({
2151
+ when,
2152
+ execute,
2153
+ kind,
2154
+ source_property,
2155
+ store_property,
2156
+ }) {
2016
2157
  if (this[_serializers][when]) {
2158
+ if (kind) {
2159
+ execute.spiceModifier = {
2160
+ kind,
2161
+ source_property,
2162
+ store_property,
2163
+ };
2164
+ }
2017
2165
  this[_serializers][when]["modifiers"].push(execute);
2018
2166
  }
2019
2167
  }
@@ -2087,6 +2235,9 @@ export default class SpiceModel {
2087
2235
  case "string": {
2088
2236
  this.addModifier({
2089
2237
  when: properties[i].map.when || "read",
2238
+ kind: "relationship_map",
2239
+ source_property: i,
2240
+ store_property: properties[i].map.destination || i,
2090
2241
  execute: async (
2091
2242
  data,
2092
2243
  old_data,
@@ -2096,6 +2247,7 @@ export default class SpiceModel {
2096
2247
  mapping_dept,
2097
2248
  level,
2098
2249
  columns,
2250
+ serializationType,
2099
2251
  ) => {
2100
2252
  // Skip if columns are specified but this field isn't included
2101
2253
  if (!this.isFieldInColumns(i, columns)) {
@@ -2110,7 +2262,7 @@ export default class SpiceModel {
2110
2262
  properties[i].map.destination || i,
2111
2263
  properties[i],
2112
2264
  args,
2113
- type,
2265
+ serializationType,
2114
2266
  mapping_dept,
2115
2267
  level,
2116
2268
  columns,
@@ -2123,6 +2275,9 @@ export default class SpiceModel {
2123
2275
  case "array": {
2124
2276
  this.addModifier({
2125
2277
  when: properties[i].map.when || "read",
2278
+ kind: "relationship_map",
2279
+ source_property: i,
2280
+ store_property: properties[i].map.destination || i,
2126
2281
  execute: async (
2127
2282
  data,
2128
2283
  old_data,
@@ -2132,6 +2287,7 @@ export default class SpiceModel {
2132
2287
  mapping_dept,
2133
2288
  level,
2134
2289
  columns,
2290
+ serializationType,
2135
2291
  ) => {
2136
2292
  // Skip if columns are specified but this field isn't included
2137
2293
  if (!this.isFieldInColumns(i, columns)) {
@@ -2147,7 +2303,7 @@ export default class SpiceModel {
2147
2303
  properties[i].map.destination || i,
2148
2304
  properties[i],
2149
2305
  args,
2150
- type,
2306
+ serializationType,
2151
2307
  mapping_dept,
2152
2308
  level,
2153
2309
  columns,
@@ -2185,11 +2341,10 @@ export default class SpiceModel {
2185
2341
 
2186
2342
  // Cache the modifiers lookup for the specified type.
2187
2343
  const modifiers = this[_serializers]?.[type]?.modifiers || [];
2188
- for (let i = 0; i < modifiers.length; i++) {
2189
- const modifier = modifiers[i];
2344
+ const invokeModifier = async (modifier, index, inputData) => {
2190
2345
  try {
2191
2346
  const result = await modifier(
2192
- data,
2347
+ inputData,
2193
2348
  old_data,
2194
2349
  this[_ctx],
2195
2350
  this.type,
@@ -2197,23 +2352,119 @@ export default class SpiceModel {
2197
2352
  this[_mapping_dept],
2198
2353
  this[_level],
2199
2354
  this[_columns],
2355
+ type,
2200
2356
  );
2201
- // Guard against modifiers that return undefined
2202
2357
  if (result !== undefined) {
2203
- data = result;
2204
- } else {
2205
- console.warn(
2206
- `Modifier #${i} for type=${this.type} returned undefined, keeping previous data`,
2207
- );
2358
+ return { hasValue: true, value: result };
2208
2359
  }
2360
+ console.warn(
2361
+ `Modifier #${index} for type=${this.type} returned undefined, keeping previous data`,
2362
+ );
2209
2363
  } catch (error) {
2210
2364
  console.error(
2211
- `Modifier error in do_serialize (type=${this.type}, modifier #${i}):`,
2365
+ `Modifier error in do_serialize (type=${this.type}, modifier #${index}):`,
2212
2366
  error instanceof Error ?
2213
2367
  error.stack
2214
2368
  : `Non-Error thrown: ${JSON.stringify(error)}`,
2215
2369
  );
2216
2370
  }
2371
+ return { hasValue: false };
2372
+ };
2373
+
2374
+ let modifierIndex = 0;
2375
+ while (modifierIndex < modifiers.length) {
2376
+ const modifier = modifiers[modifierIndex];
2377
+ const canRunRelationshipMapsConcurrently =
2378
+ type === "read" &&
2379
+ this[_level] === 0 &&
2380
+ this[_mapping_concurrency] > 1 &&
2381
+ modifier?.spiceModifier?.kind === "relationship_map";
2382
+
2383
+ if (!canRunRelationshipMapsConcurrently) {
2384
+ const result = await invokeModifier(modifier, modifierIndex, data);
2385
+ if (result.hasValue) data = result.value;
2386
+ modifierIndex += 1;
2387
+ continue;
2388
+ }
2389
+
2390
+ const group = [];
2391
+ const destinations = new Set();
2392
+ while (modifierIndex < modifiers.length) {
2393
+ const candidate = modifiers[modifierIndex];
2394
+ const metadata = candidate?.spiceModifier;
2395
+ if (metadata?.kind !== "relationship_map") break;
2396
+ if (
2397
+ metadata.store_property &&
2398
+ destinations.has(metadata.store_property)
2399
+ ) {
2400
+ break;
2401
+ }
2402
+ if (metadata.store_property) {
2403
+ destinations.add(metadata.store_property);
2404
+ }
2405
+ group.push({ modifier: candidate, index: modifierIndex, metadata });
2406
+ modifierIndex += 1;
2407
+ }
2408
+
2409
+ const groupQueuedAt = process.hrtime.bigint();
2410
+ let activeRequestMappings = 0;
2411
+ const executeGroup = async () =>
2412
+ await runWithConcurrency(
2413
+ group,
2414
+ this[_mapping_concurrency],
2415
+ async (entry) => {
2416
+ const requestQueueWaitMs =
2417
+ Number(process.hrtime.bigint() - groupQueuedAt) / 1e6;
2418
+ const globalSlot = await acquireGlobalMappingSlot();
2419
+ activeRequestMappings += 1;
2420
+ const profileMetadata = {
2421
+ source_property: entry.metadata.source_property,
2422
+ store_property: entry.metadata.store_property,
2423
+ request_queue_wait_ms:
2424
+ Math.round(requestQueueWaitMs * 100) / 100,
2425
+ global_queue_wait_ms:
2426
+ Math.round(globalSlot.waitMs * 100) / 100,
2427
+ active_request_mappings: activeRequestMappings,
2428
+ request_limit: this[_mapping_concurrency],
2429
+ active_global_mappings: globalSlot.active,
2430
+ global_limit: GLOBAL_MAPPING_CONCURRENCY,
2431
+ };
2432
+
2433
+ try {
2434
+ const execute = async () =>
2435
+ await invokeModifier(entry.modifier, entry.index, data);
2436
+ if (p) {
2437
+ return await p.track(
2438
+ `${this.type}.map.concurrent.${entry.metadata.source_property}`,
2439
+ execute,
2440
+ profileMetadata,
2441
+ );
2442
+ }
2443
+ return await execute();
2444
+ } finally {
2445
+ activeRequestMappings -= 1;
2446
+ globalSlot.release();
2447
+ }
2448
+ },
2449
+ );
2450
+
2451
+ const groupMetadata = {
2452
+ map_count: group.length,
2453
+ request_limit: this[_mapping_concurrency],
2454
+ global_limit: GLOBAL_MAPPING_CONCURRENCY,
2455
+ };
2456
+ const groupResults =
2457
+ p ?
2458
+ await p.track(
2459
+ `${this.type}.map.concurrent`,
2460
+ executeGroup,
2461
+ groupMetadata,
2462
+ )
2463
+ : await executeGroup();
2464
+
2465
+ for (const result of groupResults) {
2466
+ if (result?.hasValue) data = result.value;
2467
+ }
2217
2468
  }
2218
2469
 
2219
2470
  // Ensure data is always an array for consistent processing.
@@ -22,8 +22,10 @@ const spiceConfig = {
22
22
  cache: {},
23
23
  cache_providers: {},
24
24
  schemas: {},
25
+ models: {},
25
26
  modifiers: {},
26
- schema_extenders: []
27
+ schema_extenders: [],
28
+ getModifiers: jest.fn(() => [])
27
29
  };
28
30
 
29
31
  // Set on multiple global scopes to ensure it's available
@@ -29,6 +29,8 @@ const columnTestCases = {
29
29
  */
30
30
  const sampleDbResults = {
31
31
  users: [
32
+ { id: 'valid-1', type: 'user', name: 'Valid One', email: 'valid1@example.com' },
33
+ { id: 'valid-2', type: 'user', name: 'Valid Two', email: 'valid2@example.com' },
32
34
  { id: 'user-1', type: 'user', name: 'Alice', email: 'alice@example.com' },
33
35
  { id: 'user-2', type: 'user', name: 'Bob', email: 'bob@example.com' },
34
36
  { id: 'user-3', type: 'user', name: 'Charlie', email: 'charlie@example.com' }
@@ -71,7 +73,7 @@ function generateLargeDataset(size = 100) {
71
73
  for (let i = 0; i < size; i++) {
72
74
  data.push({
73
75
  id: `item-${i}`,
74
- type: 'item',
76
+ type: 'user',
75
77
  name: `Item ${i}`,
76
78
  value: Math.random()
77
79
  });
@@ -4,6 +4,12 @@
4
4
 
5
5
  const SpiceModel = require('../../src/models/SpiceModel').default;
6
6
  const MockDatabase = require('../__mocks__/spice-couchbase');
7
+ const spiceConfig = require('../__mocks__/globalMocks');
8
+
9
+ // Importing SpiceModel loads the package index, which initializes global.spice.
10
+ // Restore the complete test configuration after that package initialization.
11
+ global.spice = spiceConfig;
12
+ globalThis.spice = spiceConfig;
7
13
 
8
14
  /**
9
15
  * Create a test model instance with mocked database
@@ -32,7 +38,7 @@ function createTestModel(config = {}) {
32
38
  connection,
33
39
  collection: type,
34
40
  props,
35
- args: { ...args, skip_cache: true }
41
+ args: { ...args, skip_cache: true, disable_lifecycle_events: true }
36
42
  });
37
43
 
38
44
  // Inject mock database
package/tests/jest-env.js CHANGED
@@ -21,8 +21,10 @@ class CustomEnvironment extends NodeEnvironment {
21
21
  cache: {},
22
22
  cache_providers: {},
23
23
  schemas: {},
24
+ models: {},
24
25
  modifiers: {},
25
- schema_extenders: []
26
+ schema_extenders: [],
27
+ getModifiers: () => []
26
28
  };
27
29
 
28
30
  // Set on the global object for the test environment