webspresso 0.0.91 → 0.0.93

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.
Files changed (60) hide show
  1. package/README.md +103 -2864
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +8 -2
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +2 -0
  12. package/core/compileSchema.js +1 -5
  13. package/core/compression/compressible.js +17 -5
  14. package/core/compression/compression-stream.js +1 -1
  15. package/core/compression/middleware.js +9 -0
  16. package/core/compression/negotiate.js +23 -11
  17. package/core/kernel/app.js +2 -1
  18. package/core/kernel/index.js +5 -1
  19. package/core/orm/cache/fingerprint.js +23 -5
  20. package/core/orm/eager-loader.js +54 -9
  21. package/core/orm/index.js +38 -2
  22. package/core/orm/model.js +11 -2
  23. package/core/queue/adapters/database.js +46 -13
  24. package/core/queue/adapters/memory.js +21 -0
  25. package/core/queue/adapters/redis.js +21 -0
  26. package/core/queue/manager.js +46 -20
  27. package/core/validation/index.js +70 -0
  28. package/index.d.ts +99 -5
  29. package/index.js +7 -1
  30. package/package.json +9 -1
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
  32. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
  33. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  34. package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
  35. package/plugins/admin-panel/index.js +65 -6
  36. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  37. package/plugins/admin-panel/modules/dashboard.js +8 -1
  38. package/plugins/data-exchange/export-xlsx.js +28 -6
  39. package/plugins/data-exchange/import.js +45 -20
  40. package/plugins/data-exchange/index.js +5 -0
  41. package/plugins/data-exchange/parse-table.js +14 -5
  42. package/plugins/data-exchange/record-selection.js +2 -0
  43. package/plugins/index.js +4 -0
  44. package/plugins/mcp/auth.js +104 -0
  45. package/plugins/mcp/discovery.js +88 -0
  46. package/plugins/mcp/index.js +208 -0
  47. package/plugins/mcp/prompts.js +149 -0
  48. package/plugins/mcp/providers/orm.js +325 -0
  49. package/plugins/mcp/providers/services.js +190 -0
  50. package/plugins/mcp/providers/system.js +87 -0
  51. package/plugins/mcp/server.js +472 -0
  52. package/plugins/mcp/transports/sse.js +139 -0
  53. package/plugins/mcp/transports/stdio.js +108 -0
  54. package/src/file-router.js +184 -123
  55. package/src/njk-frontmatter.js +24 -14
  56. package/src/server.js +30 -12
  57. package/src/services/discovery.js +2 -1
  58. package/src/services/registry.js +2 -0
  59. package/src/services/validator.js +2 -53
  60. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
@@ -3,8 +3,13 @@
3
3
  * Reset admin user password via CLI
4
4
  */
5
5
 
6
- const inquirer = require('inquirer');
7
- const { hash } = require('../../core/auth/hash');
6
+ let _inquirer;
7
+ const inquirer = {
8
+ prompt: (...args) => {
9
+ if (!_inquirer) _inquirer = require('inquirer');
10
+ return _inquirer.prompt(...args);
11
+ },
12
+ };
8
13
  const { loadDbConfig, createDbInstance } = require('../utils/db');
9
14
 
10
15
  function registerCommand(program) {
@@ -86,6 +91,7 @@ function registerCommand(program) {
86
91
  }
87
92
 
88
93
  // Hash the password (same rounds as admin panel setup)
94
+ const { hash } = require('../../core/auth/hash');
89
95
  const hashedPassword = await hash(password, 10);
90
96
 
91
97
  // Update the password
@@ -3,7 +3,13 @@
3
3
  * Add a new API endpoint to the current project
4
4
  */
5
5
 
6
- const inquirer = require('inquirer');
6
+ let _inquirer;
7
+ const inquirer = {
8
+ prompt: (...args) => {
9
+ if (!_inquirer) _inquirer = require('inquirer');
10
+ return _inquirer.prompt(...args);
11
+ },
12
+ };
7
13
  const fs = require('fs');
8
14
  const path = require('path');
9
15
 
@@ -5,9 +5,11 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
- const sharp = process.env.NODE_ENV === 'test'
9
- ? require('../../tests/mocks/sharp')
10
- : require('sharp');
8
+ function getSharp() {
9
+ return process.env.NODE_ENV === 'test'
10
+ ? require('../../tests/mocks/sharp')
11
+ : require('sharp');
12
+ }
11
13
 
12
14
  const APPLE_SIZES = [57, 60, 72, 76, 114, 120, 144, 152, 180];
13
15
  const ANDROID_SIZE = 192;
@@ -54,7 +56,7 @@ function registerCommand(program) {
54
56
  const allSizes = [...new Set([...APPLE_SIZES, ANDROID_SIZE, ...FAVICON_SIZES, MS_TILE_SIZE])].sort((a, b) => a - b);
55
57
 
56
58
  for (const size of allSizes) {
57
- const buffer = await sharp(sourcePath).resize(size, size).png().toBuffer();
59
+ const buffer = await getSharp()(sourcePath).resize(size, size).png().toBuffer();
58
60
 
59
61
  if (APPLE_SIZES.includes(size)) {
60
62
  const filename = `apple-icon-${size}x${size}.png`;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Webspresso CLI: MCP Command
3
+ * Runs the Webspresso Model Context Protocol (MCP) server over standard I/O for IDEs (Antigravity, Claude, Cursor)
4
+ * @module bin/commands/mcp
5
+ */
6
+
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+
10
+
11
+ /**
12
+ * Register MCP command in Commander CLI
13
+ * @param {import('commander').Command} program
14
+ */
15
+ function registerCommand(program) {
16
+ program
17
+ .command('mcp')
18
+ .description('Start Model Context Protocol (MCP) server over Stdio for AI coding assistants')
19
+ .option('-d, --dir <path>', 'Project root directory', process.cwd())
20
+ .option('-c, --config <path>', 'Database config path (knexfile.js or webspresso.db.js)')
21
+ .option('-E, --env <environment>', 'Environment (development, production)', 'development')
22
+ .option('--read-only', 'Restrict tools to read-only queries (blocks mutations/inserts)', false)
23
+ .option('--no-services', 'Disable auto-bridging of services/ directory')
24
+ .option('--no-orm', 'Disable auto-bridging of ORM models and schema')
25
+ .option('--print-config', 'Print sample Claude Desktop / Cursor MCP configuration JSON snippet')
26
+ .action(async (options) => {
27
+ // 1. If print-config flag is passed, print snippet and exit cleanly
28
+ if (options.printConfig) {
29
+ const configSnippet = {
30
+ mcpServers: {
31
+ webspresso: {
32
+ command: 'npx',
33
+ args: ['webspresso', 'mcp'],
34
+ },
35
+ },
36
+ };
37
+ console.log(JSON.stringify(configSnippet, null, 2));
38
+ return;
39
+ }
40
+
41
+ const rootDir = path.resolve(process.cwd(), options.dir || '.');
42
+ const isReadOnly = Boolean(options.readOnly);
43
+
44
+ const {
45
+ McpServer,
46
+ createServiceTools,
47
+ createOrmTools,
48
+ createOrmResources,
49
+ createSystemResources,
50
+ createBuiltinPrompts,
51
+ startStdioTransport,
52
+ discoverMcpDirectory,
53
+ } = require('../../plugins/mcp');
54
+ const { createServiceRegistry } = require('../../src/services');
55
+ const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
56
+ const { getAllModels } = require('../../core/orm/model');
57
+
58
+ // Create Server instance
59
+ const server = new McpServer({
60
+ name: 'webspresso-mcp',
61
+ version: require('../../package.json').version,
62
+ instructions: `Webspresso MCP server running in ${rootDir} (readOnly: ${isReadOnly}). Exposes business logic services and ORM models.`,
63
+ });
64
+
65
+ // Register built-in prompts
66
+ const prompts = createBuiltinPrompts();
67
+ for (const p of prompts) {
68
+ server.registerPrompt(p);
69
+ }
70
+
71
+ let db = null;
72
+ let registry = null;
73
+
74
+ try {
75
+ // 2. Resolve database if not disabled
76
+ if (options.orm !== false) {
77
+ const dbConfigResult = resolveDbConfigIfExists(options.config);
78
+ if (dbConfigResult) {
79
+ try {
80
+ db = await createDbInstance(dbConfigResult.config, options.env);
81
+ server.context.db = db;
82
+
83
+ const ormTools = createOrmTools({
84
+ db,
85
+ readOnly: isReadOnly,
86
+ });
87
+ server.registerTools(ormTools);
88
+
89
+ const { resources, templates } = createOrmResources({ db });
90
+ for (const r of resources) {
91
+ server.registerResource(r);
92
+ }
93
+ for (const t of templates) {
94
+ server.registerResourceTemplate(t);
95
+ }
96
+ } catch (dbErr) {
97
+ process.stderr.write(`[webspresso-mcp] DB initialization skipped: ${dbErr.message}\n`);
98
+ }
99
+ }
100
+ }
101
+
102
+ // 3. Resolve services if not disabled
103
+ if (options.services !== false) {
104
+ const servicesDir = path.join(rootDir, 'services');
105
+ if (fs.existsSync(servicesDir)) {
106
+ registry = createServiceRegistry({
107
+ servicesDir,
108
+ isDev: options.env !== 'production',
109
+ });
110
+ server.context.serviceRegistry = registry;
111
+
112
+ const serviceTools = createServiceTools({
113
+ registry,
114
+ readOnly: isReadOnly,
115
+ role: 'admin',
116
+ });
117
+ server.registerTools(serviceTools);
118
+ }
119
+ }
120
+
121
+ // 4. Register system resources
122
+ const sysResources = createSystemResources({
123
+ serviceRegistry: registry,
124
+ db,
125
+ });
126
+ for (const r of sysResources) {
127
+ server.registerResource(r);
128
+ }
129
+
130
+ // 5. Discover custom mcp/ components
131
+ discoverMcpDirectory(server, rootDir);
132
+
133
+ // 6. Start Stdio transport
134
+ const transport = startStdioTransport(server, {
135
+ redirectStderr: true,
136
+ });
137
+
138
+ process.stderr.write(
139
+ `[webspresso-mcp] Server started (Stdio transport). Registered ${server.tools.size} tools, ${server.resources.size} resources, ${server.prompts.size} prompts.\n`
140
+ );
141
+
142
+ // Handle process termination
143
+ const cleanup = async () => {
144
+ transport.close();
145
+ if (db && typeof db.destroy === 'function') {
146
+ await db.destroy();
147
+ }
148
+ process.exit(0);
149
+ };
150
+
151
+ process.on('SIGINT', cleanup);
152
+ process.on('SIGTERM', cleanup);
153
+ } catch (err) {
154
+ process.stderr.write(`[webspresso-mcp] Fatal error: ${err.message}\n`);
155
+ process.exit(1);
156
+ }
157
+ });
158
+ }
159
+
160
+ module.exports = {
161
+ registerCommand,
162
+ };
@@ -3,7 +3,13 @@
3
3
  * Creates a new Webspresso project
4
4
  */
5
5
 
6
- const inquirer = require('inquirer');
6
+ let _inquirer;
7
+ const inquirer = {
8
+ prompt: (...args) => {
9
+ if (!_inquirer) _inquirer = require('inquirer');
10
+ return _inquirer.prompt(...args);
11
+ },
12
+ };
7
13
  const fs = require('fs');
8
14
  const path = require('path');
9
15
  const { runInstallation, startDevServer } = require('../utils/project');
@@ -3,7 +3,13 @@
3
3
  * Add a new page to the current project
4
4
  */
5
5
 
6
- const inquirer = require('inquirer');
6
+ let _inquirer;
7
+ const inquirer = {
8
+ prompt: (...args) => {
9
+ if (!_inquirer) _inquirer = require('inquirer');
10
+ return _inquirer.prompt(...args);
11
+ },
12
+ };
7
13
  const fs = require('fs');
8
14
  const path = require('path');
9
15
 
@@ -3,7 +3,13 @@
3
3
  * Run database seeders to populate database with fake data
4
4
  */
5
5
 
6
- const inquirer = require('inquirer');
6
+ let _inquirer;
7
+ const inquirer = {
8
+ prompt: (...args) => {
9
+ if (!_inquirer) _inquirer = require('inquirer');
10
+ return _inquirer.prompt(...args);
11
+ },
12
+ };
7
13
  const fs = require('fs');
8
14
  const path = require('path');
9
15
  const { execSync } = require('child_process');
@@ -5,8 +5,13 @@
5
5
  */
6
6
 
7
7
  const path = require('path');
8
- const inquirer = require('inquirer');
9
- const { createServiceRegistry } = require('../../src/services');
8
+ let _inquirer;
9
+ const inquirer = {
10
+ prompt: (...args) => {
11
+ if (!_inquirer) _inquirer = require('inquirer');
12
+ return _inquirer.prompt(...args);
13
+ },
14
+ };
10
15
  const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
11
16
 
12
17
  /**
@@ -69,6 +74,7 @@ function registerCommand(program) {
69
74
  ? path.resolve(process.cwd(), options.dir)
70
75
  : path.resolve(process.cwd(), 'services');
71
76
 
77
+ const { createServiceRegistry } = require('../../src/services');
72
78
  const registry = createServiceRegistry({
73
79
  servicesDir,
74
80
  isDev: true,
@@ -5,7 +5,13 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
- const inquirer = require('inquirer');
8
+ let _inquirer;
9
+ const inquirer = {
10
+ prompt: (...args) => {
11
+ if (!_inquirer) _inquirer = require('inquirer');
12
+ return _inquirer.prompt(...args);
13
+ },
14
+ };
9
15
 
10
16
  const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
11
17
 
package/bin/webspresso.js CHANGED
@@ -34,6 +34,7 @@ const { registerCommand: registerSkill } = require('./commands/skill');
34
34
  const { registerCommand: registerUpgrade } = require('./commands/upgrade');
35
35
  const { registerCommand: registerOrmMap } = require('./commands/orm-map');
36
36
  const { registerCommand: registerService } = require('./commands/service');
37
+ const { registerCommand: registerMcp } = require('./commands/mcp');
37
38
 
38
39
  registerNew(program);
39
40
  registerPage(program);
@@ -56,6 +57,7 @@ registerSkill(program);
56
57
  registerUpgrade(program);
57
58
  registerOrmMap(program);
58
59
  registerService(program);
60
+ registerMcp(program);
59
61
 
60
62
  // Parse arguments
61
63
  program.parse();
@@ -3,13 +3,9 @@
3
3
  * Compiles schema definitions from API files
4
4
  */
5
5
 
6
- const z = require('zod');
7
- const { extendZ } = require('./orm/utils/nanoid');
6
+ const { z: zForApi } = require('./validation');
8
7
  const schemaCache = require('../utils/schemaCache');
9
8
 
10
- /** Zod plus `z.nanoid()` for API schemas (does not mutate the global `z`). */
11
- const zForApi = extendZ(z);
12
-
13
9
  /**
14
10
  * Compile schema from an API module
15
11
  * @param {string} filePath - Absolute file path to API module
@@ -10,6 +10,9 @@ const COMPRESSIBLE_TYPE_REGEX = /^(?:text\/[a-z0-9.+_-]+|application\/(?:javascr
10
10
  // Regex patterns for already-compressed or non-compressible types
11
11
  const INCOMPRESSIBLE_TYPE_REGEX = /^(?:image\/(?:jpeg|png|gif|webp|avif|heic|heif)|video\/[a-z0-9.+_-]+|audio\/[a-z0-9.+_-]+|application\/(?:zip|gzip|x-gzip|x-bzip2|x-compress|x-7z-compressed|x-rar-compressed|pdf|wasm|octet-stream))$/i;
12
12
 
13
+ const compressibleCache = new Map();
14
+ const MAX_MIME_CACHE_SIZE = 256;
15
+
13
16
  /**
14
17
  * Determine if a Content-Type is compressible
15
18
  * @param {string|undefined|null} contentType - Raw Content-Type header (e.g. 'text/html; charset=utf-8')
@@ -20,30 +23,39 @@ function isCompressible(contentType) {
20
23
  return false;
21
24
  }
22
25
 
26
+ const cached = compressibleCache.get(contentType);
27
+ if (cached !== undefined) {
28
+ return cached;
29
+ }
30
+
23
31
  // Extract base MIME type (strip parameters like ; charset=utf-8)
24
32
  const semiIndex = contentType.indexOf(';');
25
33
  const mime = (semiIndex !== -1 ? contentType.slice(0, semiIndex) : contentType).trim().toLowerCase();
26
34
 
27
35
  if (!mime) {
36
+ if (compressibleCache.size < MAX_MIME_CACHE_SIZE) compressibleCache.set(contentType, false);
28
37
  return false;
29
38
  }
30
39
 
31
40
  // Fast negative check
32
41
  if (INCOMPRESSIBLE_TYPE_REGEX.test(mime)) {
42
+ if (compressibleCache.size < MAX_MIME_CACHE_SIZE) compressibleCache.set(contentType, false);
33
43
  return false;
34
44
  }
35
45
 
36
46
  // Check compressible match
47
+ let result = false;
37
48
  if (COMPRESSIBLE_TYPE_REGEX.test(mime)) {
38
- return true;
49
+ result = true;
50
+ } else if (mime.startsWith('text/') || mime.endsWith('+json') || mime.endsWith('+xml') || mime.endsWith('+yaml')) {
51
+ result = true;
39
52
  }
40
53
 
41
- // General text or suffix match (+json, +xml, +yaml)
42
- if (mime.startsWith('text/') || mime.endsWith('+json') || mime.endsWith('+xml') || mime.endsWith('+yaml')) {
43
- return true;
54
+ if (compressibleCache.size < MAX_MIME_CACHE_SIZE) {
55
+ compressibleCache.set(contentType, result);
44
56
  }
45
57
 
46
- return false;
58
+ return result;
47
59
  }
48
60
 
49
61
  module.exports = {
@@ -34,7 +34,7 @@ function createCompressionStream(encoding, options = {}) {
34
34
  // Brotli quality 4 is standard for dynamic HTTP compression (fast & high ratio)
35
35
  brotliOpts.params[zlib.constants.BROTLI_PARAM_QUALITY] = typeof options.brotli?.quality === 'number'
36
36
  ? options.brotli.quality
37
- : (options.level !== undefined ? options.level : 4);
37
+ : (typeof options.brotliQuality === 'number' ? options.brotliQuality : 4);
38
38
  }
39
39
  return zlib.createBrotliCompress(brotliOpts);
40
40
  }
@@ -262,6 +262,15 @@ function createCompressionMiddleware(options = {}) {
262
262
  // Check if total length satisfies threshold and compressibility
263
263
  if (shouldCompress(bufferedLength)) {
264
264
  startCompression();
265
+ if (bufferedChunks.length === 1) {
266
+ if (typeof callback === 'function') {
267
+ compressStream.once('end', callback);
268
+ }
269
+ const singleBuf = bufferedChunks[0];
270
+ bufferedChunks = [];
271
+ compressStream.end(singleBuf);
272
+ return res;
273
+ }
265
274
  for (const b of bufferedChunks) {
266
275
  compressStream.write(b);
267
276
  }
@@ -69,6 +69,9 @@ function parseAcceptEncoding(header) {
69
69
  return results;
70
70
  }
71
71
 
72
+ const selectEncodingCache = new Map();
73
+ const MAX_ENCODING_CACHE_SIZE = 128;
74
+
72
75
  /**
73
76
  * Select the best compression encoding given client header and server supported list
74
77
  * @param {string|undefined|null} acceptEncoding - The Accept-Encoding header value
@@ -76,16 +79,25 @@ function parseAcceptEncoding(header) {
76
79
  * @returns {string|null} The selected encoding ('br', 'gzip', 'deflate', 'identity', or null)
77
80
  */
78
81
  function selectEncoding(acceptEncoding, supportedEncodings) {
82
+ if (!acceptEncoding) {
83
+ return 'identity';
84
+ }
85
+
79
86
  const supported = Array.isArray(supportedEncodings) && supportedEncodings.length > 0
80
87
  ? supportedEncodings.map(e => e.toLowerCase()).filter(e => e !== 'br' || supportsBrotli())
81
88
  : getDefaultSupportedEncodings();
82
89
 
83
- if (!acceptEncoding) {
84
- return 'identity';
90
+ const cacheKey = `${acceptEncoding}|${supported.join(',')}`;
91
+ const cached = selectEncodingCache.get(cacheKey);
92
+ if (cached !== undefined) {
93
+ return cached;
85
94
  }
86
95
 
87
96
  const clientPreferences = parseAcceptEncoding(acceptEncoding);
88
97
  if (clientPreferences.length === 0) {
98
+ if (selectEncodingCache.size < MAX_ENCODING_CACHE_SIZE) {
99
+ selectEncodingCache.set(cacheKey, 'identity');
100
+ }
89
101
  return 'identity';
90
102
  }
91
103
 
@@ -129,20 +141,20 @@ function selectEncoding(acceptEncoding, supportedEncodings) {
129
141
  }
130
142
  }
131
143
 
144
+ let result = null;
132
145
  if (bestEncoding && bestQ > 0) {
133
- return bestEncoding;
146
+ result = bestEncoding;
147
+ } else if (prefMap.has('identity')) {
148
+ result = prefMap.get('identity') > 0 ? 'identity' : null;
149
+ } else if (wildcardQ !== null) {
150
+ result = wildcardQ > 0 ? 'identity' : null;
134
151
  }
135
152
 
136
- // If no compressed encoding was chosen:
137
- // If identity or wildcard was explicitly specified, check their q values
138
- if (prefMap.has('identity')) {
139
- return prefMap.get('identity') > 0 ? 'identity' : null;
140
- }
141
- if (wildcardQ !== null) {
142
- return wildcardQ > 0 ? 'identity' : null;
153
+ if (selectEncodingCache.size < MAX_ENCODING_CACHE_SIZE) {
154
+ selectEncodingCache.set(cacheKey, result);
143
155
  }
144
156
 
145
- return null;
157
+ return result;
146
158
  }
147
159
 
148
160
  module.exports = {
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Minimal extensible application kernel (event bus, views, flows, plugins).
2
+ * Minimal extensible standalone application kernel (event bus, views, flows, plugins).
3
+ * Isolated experimental subsystem — not the primary Express SSR createApp.
3
4
  * @module core/kernel/app
4
5
  */
5
6
 
@@ -1,5 +1,9 @@
1
1
  /**
2
- * Application kernel: event bus, simulated repository hooks, plugins, views, flows.
2
+ * Standalone Experimental Application Kernel
3
+ * In-process event bus, simulated repository hooks, plugin shell, and minimal view resolver.
4
+ *
5
+ * NOTE: This is an isolated, experimental standalone subsystem. It does NOT integrate with
6
+ * or replace Webspresso's Express-based SSR createApp(), Knex ORM, or PluginManager.
3
7
  * @module core/kernel
4
8
  */
5
9
 
@@ -12,10 +12,28 @@ const crypto = require('crypto');
12
12
  function stableValue(v) {
13
13
  if (v === null || v === undefined) return v;
14
14
  if (typeof v !== 'object') return v;
15
- if (Array.isArray(v)) return v.map(stableValue);
16
- const keys = Object.keys(v).sort();
15
+ if (Array.isArray(v)) {
16
+ const len = v.length;
17
+ if (len === 0) return v;
18
+ const out = new Array(len);
19
+ for (let i = 0; i < len; i++) {
20
+ out[i] = stableValue(v[i]);
21
+ }
22
+ return out;
23
+ }
24
+ const keys = Object.keys(v);
25
+ const keyLen = keys.length;
26
+ if (keyLen === 0) return v;
27
+ if (keyLen === 1) {
28
+ const k = keys[0];
29
+ return { [k]: stableValue(v[k]) };
30
+ }
31
+ keys.sort();
17
32
  const out = {};
18
- for (const k of keys) out[k] = stableValue(v[k]);
33
+ for (let i = 0; i < keyLen; i++) {
34
+ const k = keys[i];
35
+ out[k] = stableValue(v[k]);
36
+ }
19
37
  return out;
20
38
  }
21
39
 
@@ -24,9 +42,9 @@ function stableValue(v) {
24
42
  */
25
43
  function scopeFingerprint(scopeContext) {
26
44
  return {
27
- tenantId: scopeContext.tenantId,
28
- withTrashed: !!scopeContext.withTrashed,
29
45
  onlyTrashed: !!scopeContext.onlyTrashed,
46
+ tenantId: scopeContext.tenantId ?? null,
47
+ withTrashed: !!scopeContext.withTrashed,
30
48
  };
31
49
  }
32
50
 
@@ -49,6 +49,35 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
49
49
  return records;
50
50
  }
51
51
 
52
+ /**
53
+ * Execute whereIn query in chunks to prevent parameter limits and query planner degradation
54
+ * @param {import('knex').Knex} knex
55
+ * @param {string} table
56
+ * @param {string} column
57
+ * @param {Array<string|number>} values
58
+ * @param {import('./types').ScopeContext} scopeContext
59
+ * @param {import('./types').ModelDefinition} model
60
+ * @param {number} [chunkSize=500]
61
+ * @returns {Promise<Object[]>}
62
+ */
63
+ async function queryInChunks(knex, table, column, values, scopeContext, model, chunkSize = 500) {
64
+ if (values.length <= chunkSize) {
65
+ let qb = knex(table).whereIn(column, values);
66
+ qb = applyScopes(qb, scopeContext, model);
67
+ return await qb;
68
+ }
69
+
70
+ const results = [];
71
+ for (let i = 0; i < values.length; i += chunkSize) {
72
+ const slice = values.slice(i, i + chunkSize);
73
+ let qb = knex(table).whereIn(column, slice);
74
+ qb = applyScopes(qb, scopeContext, model);
75
+ const chunkResults = await qb;
76
+ results.push(...chunkResults);
77
+ }
78
+ return results;
79
+ }
80
+
52
81
  /**
53
82
  * Load belongsTo relation
54
83
  * Parent record has foreign key pointing to related record's primary key
@@ -78,9 +107,14 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
78
107
  }
79
108
 
80
109
  const foreignKeyValues = Array.from(fkSet);
81
- let qb = knex(relatedModel.table).whereIn(relatedModel.primaryKey, foreignKeyValues);
82
- qb = applyScopes(qb, scopeContext, relatedModel);
83
- const relatedRecords = await qb;
110
+ const relatedRecords = await queryInChunks(
111
+ knex,
112
+ relatedModel.table,
113
+ relatedModel.primaryKey,
114
+ foreignKeyValues,
115
+ scopeContext,
116
+ relatedModel
117
+ );
84
118
 
85
119
  const relatedMap = new Map();
86
120
  const rLen = relatedRecords.length;
@@ -128,9 +162,14 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
128
162
  }
129
163
 
130
164
  const primaryKeyValues = Array.from(pkSet);
131
- let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
132
- qb = applyScopes(qb, scopeContext, relatedModel);
133
- const relatedRecords = await qb;
165
+ const relatedRecords = await queryInChunks(
166
+ knex,
167
+ relatedModel.table,
168
+ foreignKey,
169
+ primaryKeyValues,
170
+ scopeContext,
171
+ relatedModel
172
+ );
134
173
 
135
174
  const relatedGroups = new Map();
136
175
  const rLen = relatedRecords.length;
@@ -181,9 +220,14 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
181
220
  }
182
221
 
183
222
  const primaryKeyValues = Array.from(pkSet);
184
- let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
185
- qb = applyScopes(qb, scopeContext, relatedModel);
186
- const relatedRecords = await qb;
223
+ const relatedRecords = await queryInChunks(
224
+ knex,
225
+ relatedModel.table,
226
+ foreignKey,
227
+ primaryKeyValues,
228
+ scopeContext,
229
+ relatedModel
230
+ );
187
231
 
188
232
  const relatedMap = new Map();
189
233
  const rLen = relatedRecords.length;
@@ -222,5 +266,6 @@ module.exports = {
222
266
  loadBelongsTo,
223
267
  loadHasMany,
224
268
  loadHasOne,
269
+ queryInChunks,
225
270
  };
226
271