@happyvertical/smrt-core 0.40.69 → 0.40.70

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 (144) hide show
  1. package/AGENTS.md +29 -4
  2. package/README.md +20 -1
  3. package/agents/query-bounds.md +45 -0
  4. package/agents/schema-paths.md +328 -0
  5. package/dist/browser.d.ts +1 -0
  6. package/dist/browser.d.ts.map +1 -1
  7. package/dist/browser.js +2 -1
  8. package/dist/class.d.ts.map +1 -1
  9. package/dist/class.js +28 -4
  10. package/dist/class.js.map +1 -1
  11. package/dist/collection.d.ts +101 -2
  12. package/dist/collection.d.ts.map +1 -1
  13. package/dist/collection.js +224 -43
  14. package/dist/collection.js.map +1 -1
  15. package/dist/database.d.ts +8 -0
  16. package/dist/database.d.ts.map +1 -1
  17. package/dist/database.js +16 -8
  18. package/dist/database.js.map +1 -1
  19. package/dist/db-errors.d.ts +105 -0
  20. package/dist/db-errors.d.ts.map +1 -0
  21. package/dist/db-errors.js +382 -0
  22. package/dist/db-errors.js.map +1 -0
  23. package/dist/decorators/index.d.ts +17 -1
  24. package/dist/decorators/index.d.ts.map +1 -1
  25. package/dist/decorators/index.js +65 -7
  26. package/dist/decorators/index.js.map +1 -1
  27. package/dist/dispatch/bus.d.ts.map +1 -1
  28. package/dist/dispatch/bus.js +4 -3
  29. package/dist/dispatch/bus.js.map +1 -1
  30. package/dist/errors.d.ts +30 -2
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js +34 -2
  33. package/dist/errors.js.map +1 -1
  34. package/dist/generators/changes-route.d.ts.map +1 -1
  35. package/dist/generators/changes-route.js +6 -3
  36. package/dist/generators/changes-route.js.map +1 -1
  37. package/dist/generators/mcp-runtime-template.d.ts +8 -0
  38. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  39. package/dist/generators/mcp-runtime-template.js +38 -4
  40. package/dist/generators/mcp-runtime-template.js.map +1 -1
  41. package/dist/generators/mcp.d.ts +16 -0
  42. package/dist/generators/mcp.d.ts.map +1 -1
  43. package/dist/generators/mcp.js +41 -3
  44. package/dist/generators/mcp.js.map +1 -1
  45. package/dist/generators/rest.d.ts +22 -0
  46. package/dist/generators/rest.d.ts.map +1 -1
  47. package/dist/generators/rest.js +34 -3
  48. package/dist/generators/rest.js.map +1 -1
  49. package/dist/index.d.ts +6 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +8 -3
  52. package/dist/manifest/static-manifest.js +1 -1
  53. package/dist/manifest/static-manifest.js.map +1 -1
  54. package/dist/manifest/store.js +1 -1
  55. package/dist/manifest.json +1 -1
  56. package/dist/migrations/differ.d.ts +202 -8
  57. package/dist/migrations/differ.d.ts.map +1 -1
  58. package/dist/migrations/differ.js +604 -46
  59. package/dist/migrations/differ.js.map +1 -1
  60. package/dist/migrations/generator.d.ts +31 -4
  61. package/dist/migrations/generator.d.ts.map +1 -1
  62. package/dist/migrations/generator.js +49 -5
  63. package/dist/migrations/generator.js.map +1 -1
  64. package/dist/migrations/index.d.ts +3 -2
  65. package/dist/migrations/index.d.ts.map +1 -1
  66. package/dist/migrations/index.js +5 -3
  67. package/dist/migrations/orchestrate.js +35 -6
  68. package/dist/migrations/orchestrate.js.map +1 -1
  69. package/dist/migrations/sqlite-rebuild.d.ts +142 -0
  70. package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
  71. package/dist/migrations/sqlite-rebuild.js +514 -0
  72. package/dist/migrations/sqlite-rebuild.js.map +1 -0
  73. package/dist/migrations/tracker.d.ts +114 -1
  74. package/dist/migrations/tracker.d.ts.map +1 -1
  75. package/dist/migrations/tracker.js +331 -16
  76. package/dist/migrations/tracker.js.map +1 -1
  77. package/dist/migrations/types.d.ts +19 -4
  78. package/dist/migrations/types.d.ts.map +1 -1
  79. package/dist/migrations.js +5 -3
  80. package/dist/object.d.ts +45 -6
  81. package/dist/object.d.ts.map +1 -1
  82. package/dist/object.js +60 -13
  83. package/dist/object.js.map +1 -1
  84. package/dist/postgres-timeouts.d.ts +240 -0
  85. package/dist/postgres-timeouts.d.ts.map +1 -0
  86. package/dist/postgres-timeouts.js +204 -0
  87. package/dist/postgres-timeouts.js.map +1 -0
  88. package/dist/query-bounds.d.ts +97 -0
  89. package/dist/query-bounds.d.ts.map +1 -0
  90. package/dist/query-bounds.js +173 -0
  91. package/dist/query-bounds.js.map +1 -0
  92. package/dist/registry/types.d.ts +32 -1
  93. package/dist/registry/types.d.ts.map +1 -1
  94. package/dist/registry.js +1 -1
  95. package/dist/scanner/types.d.ts +5 -0
  96. package/dist/scanner/types.d.ts.map +1 -1
  97. package/dist/scanner/types.js.map +1 -1
  98. package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
  99. package/dist/schema/ddl/base-strategy.js +2 -2
  100. package/dist/schema/ddl/base-strategy.js.map +1 -1
  101. package/dist/schema/generator.d.ts +70 -5
  102. package/dist/schema/generator.d.ts.map +1 -1
  103. package/dist/schema/generator.js +124 -6
  104. package/dist/schema/generator.js.map +1 -1
  105. package/dist/schema/index.d.ts +3 -0
  106. package/dist/schema/index.d.ts.map +1 -1
  107. package/dist/schema/index.js +4 -1
  108. package/dist/schema/live-parity.d.ts +90 -0
  109. package/dist/schema/live-parity.d.ts.map +1 -0
  110. package/dist/schema/live-parity.js +600 -0
  111. package/dist/schema/live-parity.js.map +1 -0
  112. package/dist/schema/manifest-schema.d.ts +121 -0
  113. package/dist/schema/manifest-schema.d.ts.map +1 -0
  114. package/dist/schema/manifest-schema.js +267 -0
  115. package/dist/schema/manifest-schema.js.map +1 -0
  116. package/dist/schema/schema-aggregator.d.ts +24 -10
  117. package/dist/schema/schema-aggregator.d.ts.map +1 -1
  118. package/dist/schema/schema-aggregator.js +35 -90
  119. package/dist/schema/schema-aggregator.js.map +1 -1
  120. package/dist/schema/system-table-shapes.d.ts +52 -0
  121. package/dist/schema/system-table-shapes.d.ts.map +1 -0
  122. package/dist/schema/system-table-shapes.js +174 -0
  123. package/dist/schema/system-table-shapes.js.map +1 -0
  124. package/dist/schema/types.d.ts +103 -4
  125. package/dist/schema/types.d.ts.map +1 -1
  126. package/dist/schema/utils.d.ts +1 -0
  127. package/dist/schema/utils.d.ts.map +1 -1
  128. package/dist/schema/utils.js +3 -1
  129. package/dist/schema/utils.js.map +1 -1
  130. package/dist/schema.js +4 -1
  131. package/dist/smrt-knowledge.json +18 -6
  132. package/dist/sync/apply.d.ts.map +1 -1
  133. package/dist/sync/apply.js +9 -16
  134. package/dist/sync/apply.js.map +1 -1
  135. package/dist/testing/database.d.ts.map +1 -1
  136. package/dist/testing/database.js +1 -0
  137. package/dist/testing/database.js.map +1 -1
  138. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  139. package/dist/vite-plugin/sveltekit-generator.js +71 -5
  140. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  141. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  142. package/dist/vite-plugin/web-collections.js +6 -4
  143. package/dist/vite-plugin/web-collections.js.map +1 -1
  144. package/package.json +5 -5
@@ -3,6 +3,7 @@ import { GlobalInterceptors, createInterceptorContext } from "./interceptors.js"
3
3
  import { SmrtClass } from "./class.js";
4
4
  import { EmbeddingProvider } from "./embeddings/provider.js";
5
5
  import { EmbeddingStorage } from "./embeddings/storage.js";
6
+ import { QueryBoundsError, QueryOrderByError } from "./query-bounds.js";
6
7
  import { classnameToTablename, toSnakeCase } from "./utils/naming.js";
7
8
  import { verifyPersistenceTable } from "./schema/table-verifier.js";
8
9
  import { fieldsFromClass, formatDataJs, toCamelCase } from "./utils.js";
@@ -13,6 +14,40 @@ import { buildWhere } from "@happyvertical/sql";
13
14
  //#region src/collection.ts
14
15
  var logger = createLogger({ level: "info" });
15
16
  /**
17
+ * Validate an optional collection-level list bound (#2367).
18
+ *
19
+ * @returns the bound, or `undefined` when the option was not supplied
20
+ * @throws {QueryBoundsError} when the option is present but not a positive
21
+ * integer — a `maxListLimit` of `0` or `-1` would silently make every read on
22
+ * the collection return nothing
23
+ */
24
+ function assertOptionalListBound(value, optionName) {
25
+ if (value === void 0) return void 0;
26
+ if (!Number.isSafeInteger(value) || value < 1) throw new QueryBoundsError(`Invalid ${optionName}: expected a positive integer, got ${String(value)}.`, "INVALID_QUERY_BOUNDS", {
27
+ optionName,
28
+ value
29
+ });
30
+ return value;
31
+ }
32
+ /**
33
+ * Validate a per-query `limit`/`offset` before it is bound (#2367).
34
+ *
35
+ * `0` is allowed — `LIMIT 0` and `OFFSET 0` are both meaningful — but `NaN`,
36
+ * `Infinity`, negatives and fractions are not: they used to be bound verbatim
37
+ * and surface as a driver-level 500 for what is a caller error.
38
+ *
39
+ * @returns the bound, or `undefined` when the caller supplied none
40
+ * @throws {QueryBoundsError} when the value is not a non-negative integer
41
+ */
42
+ function assertQueryBound(value, parameterName) {
43
+ if (value === void 0 || value === null) return void 0;
44
+ if (!Number.isSafeInteger(value) || value < 0) throw new QueryBoundsError(`Invalid ${parameterName}: expected a non-negative integer, got ${String(value)}.`, "INVALID_QUERY_BOUNDS", {
45
+ parameterName,
46
+ value
47
+ });
48
+ return value;
49
+ }
50
+ /**
16
51
  * Resolve _meta_type in WHERE clause from simple class name to qualified name (Issue #713)
17
52
  *
18
53
  * This helper function allows queries like { _meta_type: 'Image' } to work correctly
@@ -91,6 +126,13 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
91
126
  * @private
92
127
  */
93
128
  _cachedFields = null;
129
+ /**
130
+ * Opt-in list bounds (#2367). See {@link SmrtCollectionOptions.defaultListLimit}
131
+ * and {@link SmrtCollectionOptions.maxListLimit}.
132
+ * @private
133
+ */
134
+ _defaultListLimit;
135
+ _maxListLimit;
94
136
  getRegisteredItemClass() {
95
137
  return ObjectRegistry.getClassByConstructor(this._itemClass) || ObjectRegistry.getClass(this._itemClass.name);
96
138
  }
@@ -102,6 +144,135 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
102
144
  return registered?.qualifiedName || registered?.name || this._itemClass.name;
103
145
  }
104
146
  /**
147
+ * Resolve the identifier whitelist and the sensitive-column blocklist that
148
+ * every caller-supplied identifier on this collection is checked against.
149
+ *
150
+ * Extracted from `convertWhereKeys()` so `orderBy` validation enforces the
151
+ * *same* sets (#2367). `where` (#1540) and `select` (#1902) both rejected
152
+ * sensitive columns while `orderBy` accepted any identifier, which made
153
+ * `?orderBy=api_secret&limit=1` an ordering oracle over a column the same
154
+ * request is forbidden to filter on or project.
155
+ *
156
+ * @returns the snake_case identifiers this collection accepts, the sensitive
157
+ * names it must refuse, and whether whitelist enforcement applies at all —
158
+ * classes with no registered fields (manifest-less inline test classes,
159
+ * #869) have nothing to validate against and fall through to SQL.
160
+ * @private
161
+ */
162
+ collectQueryableFieldNames(fields) {
163
+ const validFieldNames = new Set(Object.keys(fields).map((f) => toSnakeCase(f)));
164
+ const sensitiveFieldNames = this.collectSensitiveFieldNames(fields);
165
+ const readPermissionFieldNames = this.collectReadPermissionFieldNames(fields);
166
+ validFieldNames.add("id");
167
+ validFieldNames.add("slug");
168
+ validFieldNames.add("context");
169
+ validFieldNames.add("created_at");
170
+ validFieldNames.add("updated_at");
171
+ const itemClassName = this.getResolvedItemClassName();
172
+ const itemQualifiedName = this.getResolvedItemQualifiedName();
173
+ if (ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti") {
174
+ validFieldNames.add("_meta_type");
175
+ validFieldNames.add("meta_type");
176
+ validFieldNames.add("_meta_data");
177
+ validFieldNames.add("meta_data");
178
+ const inheritanceChain = ObjectRegistry.getInheritanceChain(itemQualifiedName);
179
+ for (const ancestorName of inheritanceChain) {
180
+ if (ancestorName === "SmrtObject" || ancestorName === "SmrtClass" || ancestorName === itemQualifiedName || ancestorName === itemClassName) continue;
181
+ const ancestorFields = ObjectRegistry.getFields(ancestorName);
182
+ for (const fieldName of ancestorFields.keys()) validFieldNames.add(toSnakeCase(fieldName));
183
+ this.collectSensitiveFieldNames(ancestorFields, sensitiveFieldNames);
184
+ this.collectReadPermissionFieldNames(ancestorFields, readPermissionFieldNames);
185
+ }
186
+ const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
187
+ if (stiBase) for (const descendant of ObjectRegistry.getDescendants(stiBase)) {
188
+ const descendantFields = ObjectRegistry.getFields(descendant);
189
+ this.collectSensitiveFieldNames(descendantFields, sensitiveFieldNames);
190
+ this.collectReadPermissionFieldNames(descendantFields, readPermissionFieldNames);
191
+ }
192
+ }
193
+ return {
194
+ readPermissionFieldNames,
195
+ skipFieldValidation: Object.keys(fields).length === 0,
196
+ sensitiveFieldNames,
197
+ validFieldNames
198
+ };
199
+ }
200
+ /**
201
+ * Build the `ORDER BY` clause for `list()`, validating every term against the
202
+ * same whitelist and sensitive-column blocklist as `where` and `select`
203
+ * (#2367).
204
+ *
205
+ * `orderBy` is interpolated UNPARAMETERIZED into the identifier position, and
206
+ * it arrives from the generated REST/MCP/SvelteKit surfaces as caller input.
207
+ * The identifier regex here has always blocked injection; what it did not
208
+ * block was ordering by a column the caller may not read. Sorting is a
209
+ * comparison, and a comparison against a secret is an oracle: repeatedly
210
+ * requesting `?orderBy=api_secret&limit=1` (with a shrinking `where`) walks
211
+ * the secret's value ordering without ever serializing the column.
212
+ *
213
+ * @param orderBy - one `'<field> [ASC|DESC]'` term or an array of them
214
+ * @param fields - the collection's cached field definitions
215
+ * @returns the `' ORDER BY ...'` fragment, or `''` when no ordering was asked
216
+ * for
217
+ * @throws {QueryOrderByError} on a malformed term, an unknown column, or a
218
+ * sensitive column — all rendered as a 400 by the generated surfaces
219
+ * @private
220
+ */
221
+ buildOrderBySql(orderBy, fields) {
222
+ if (!orderBy) return "";
223
+ const orderByItems = Array.isArray(orderBy) ? orderBy : [orderBy];
224
+ if (orderByItems.length === 0) return "";
225
+ const itemClassName = this.getResolvedItemClassName();
226
+ const { readPermissionFieldNames, sensitiveFieldNames, skipFieldValidation, validFieldNames } = this.collectQueryableFieldNames(fields);
227
+ const definitionByColumn = /* @__PURE__ */ new Map();
228
+ for (const [fieldName, fieldDef] of Object.entries(fields)) definitionByColumn.set(toSnakeCase(fieldName), fieldDef);
229
+ const customPrimaryKey = this.hasCustomPrimaryKey(fields);
230
+ const registeredFields = ObjectRegistry.getFields(this.getResolvedItemQualifiedName());
231
+ const explicitFieldNames = new Set((registeredFields.size > 0 ? registeredFields : ObjectRegistry.getFields(itemClassName)).keys());
232
+ return ` ORDER BY ${orderByItems.map((item) => {
233
+ const [field, direction = "ASC"] = String(item).trim().split(/\s+/);
234
+ if (!/^[a-zA-Z0-9_]+$/.test(field)) throw new QueryOrderByError(`Invalid field name for ordering: ${field}`, `Invalid field name for ordering: ${field}`, "INVALID_ORDER_BY", { field });
235
+ const normalizedDirection = direction.toUpperCase();
236
+ if (normalizedDirection !== "ASC" && normalizedDirection !== "DESC") throw new QueryOrderByError(`Invalid sort direction: ${direction}. Must be ASC or DESC.`, `Invalid sort direction: ${direction}. Must be ASC or DESC.`, "INVALID_ORDER_BY", { direction });
237
+ const strippedName = toSnakeCase(field);
238
+ const columnName = definitionByColumn.has(strippedName) ? strippedName : this.toDbColumnName(field);
239
+ if (sensitiveFieldNames.has(columnName) || sensitiveFieldNames.has(field)) throw new QueryOrderByError(`Invalid orderBy field: '${field}'. Ordering by sensitive fields is not allowed.`, `Invalid orderBy field: '${field}'. Ordering by sensitive fields is not allowed.`, "INVALID_ORDER_BY_SENSITIVE", { field });
240
+ if (readPermissionFieldNames.has(columnName) || readPermissionFieldNames.has(field)) throw new QueryOrderByError(`Invalid orderBy field: '${field}'. Ordering by permission-gated fields is not allowed.`, `Invalid orderBy field: '${field}'. Ordering by permission-gated fields is not allowed.`, "INVALID_ORDER_BY_RESTRICTED", { field });
241
+ if (!skipFieldValidation && !validFieldNames.has(columnName)) throw new QueryOrderByError(`Invalid orderBy field: '${field}'. Field does not exist on ${itemClassName}. Valid fields: ${Array.from(validFieldNames).sort().join(", ")}`, `Invalid orderBy field: '${field}'.`, "INVALID_ORDER_BY_UNKNOWN_FIELD", {
242
+ field,
243
+ itemClassName
244
+ });
245
+ const fieldDef = definitionByColumn.get(columnName);
246
+ const fieldType = fieldDef?.type;
247
+ const isTransient = fieldDef?.transient === true || fieldDef?._meta?.transient === true;
248
+ if (fieldType === "meta" || fieldType === "oneToMany" || fieldType === "manyToMany" || isTransient || this.isOmittedCustomPrimaryKeySystemField(columnName, customPrimaryKey, explicitFieldNames)) throw new QueryOrderByError(`Invalid orderBy field: '${field}'. Field is not column-backed on ${itemClassName}.`, `Invalid orderBy field: '${field}'. Field is not column-backed.`, "INVALID_ORDER_BY_NOT_COLUMN_BACKED", {
249
+ field,
250
+ itemClassName
251
+ });
252
+ return `${columnName} ${normalizedDirection}`;
253
+ }).join(", ")}`;
254
+ }
255
+ /**
256
+ * Apply this collection's configured list bounds (#2367).
257
+ *
258
+ * Both bounds are opt-in (`defaultListLimit` / `maxListLimit` collection
259
+ * options) and default to unset. `list()` is the framework's bulk-read
260
+ * primitive — relationship loaders, junction hydration and `listByIds()` all
261
+ * go through it expecting every matching row — so a framework-wide implicit
262
+ * default would silently truncate correct queries instead of bounding an
263
+ * attack surface. The generated surfaces, where untrusted input actually
264
+ * arrives, apply {@link DEFAULT_LIST_LIMIT}/{@link MAX_LIST_LIMIT}
265
+ * unconditionally; an application that wants the same ceiling on its own
266
+ * programmatic reads sets `maxListLimit` when constructing the collection.
267
+ *
268
+ * @private
269
+ */
270
+ applyListBounds(limit) {
271
+ const effective = limit ?? this._defaultListLimit;
272
+ if (effective === void 0) return void 0;
273
+ return this._maxListLimit === void 0 ? effective : Math.min(effective, this._maxListLimit);
274
+ }
275
+ /**
105
276
  * Convert WHERE clause field names from camelCase to snake_case while preserving operators.
106
277
  * Validates operators and field names to prevent SQL injection and invalid queries.
107
278
  *
@@ -131,31 +302,8 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
131
302
  ];
132
303
  const UNSUPPORTED_OPERATOR_HINTS = /* @__PURE__ */ new Map([["contains", "Use 'like' with explicit wildcards instead, e.g. { 'name like': '%term%' }."]]);
133
304
  const fields = this.getFieldsSync();
134
- const validFieldNames = new Set(Object.keys(fields).map((f) => toSnakeCase(f)));
135
- const sensitiveFieldNames = this.collectSensitiveFieldNames(fields);
136
- validFieldNames.add("id");
137
- validFieldNames.add("slug");
138
- validFieldNames.add("context");
139
- validFieldNames.add("created_at");
140
- validFieldNames.add("updated_at");
141
305
  const itemClassName = this.getResolvedItemClassName();
142
- const itemQualifiedName = this.getResolvedItemQualifiedName();
143
- if (ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti") {
144
- validFieldNames.add("_meta_type");
145
- validFieldNames.add("meta_type");
146
- validFieldNames.add("_meta_data");
147
- validFieldNames.add("meta_data");
148
- const inheritanceChain = ObjectRegistry.getInheritanceChain(itemQualifiedName);
149
- for (const ancestorName of inheritanceChain) {
150
- if (ancestorName === "SmrtObject" || ancestorName === "SmrtClass" || ancestorName === itemQualifiedName || ancestorName === itemClassName) continue;
151
- const ancestorFields = ObjectRegistry.getFields(ancestorName);
152
- for (const fieldName of ancestorFields.keys()) validFieldNames.add(toSnakeCase(fieldName));
153
- this.collectSensitiveFieldNames(ancestorFields, sensitiveFieldNames);
154
- }
155
- const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
156
- if (stiBase) for (const descendant of ObjectRegistry.getDescendants(stiBase)) this.collectSensitiveFieldNames(ObjectRegistry.getFields(descendant), sensitiveFieldNames);
157
- }
158
- const skipFieldValidation = !(Object.keys(fields).length > 0);
306
+ const { sensitiveFieldNames, skipFieldValidation, validFieldNames } = this.collectQueryableFieldNames(fields);
159
307
  const converted = {};
160
308
  if (Object.hasOwn(where, "__proto__") || Object.hasOwn(where, "constructor") || Object.hasOwn(where, "prototype")) throw new Error("Invalid WHERE clause: Prototype pollution attempts are not allowed. Detected dangerous properties in WHERE clause.");
161
309
  for (const [key, value] of Object.entries(where)) {
@@ -212,6 +360,23 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
212
360
  }
213
361
  return target;
214
362
  }
363
+ /**
364
+ * The `collectSensitiveFieldNames` sibling for `@field({ readPermission })`
365
+ * columns (#2367).
366
+ *
367
+ * These are per-caller redacted by `toPublicJSON()` and refused outright by
368
+ * `select`, so an ordering over one leaks comparisons about a value the
369
+ * caller may not read. `list()` has no permission context, so — exactly as
370
+ * `select` does — they are refused unconditionally rather than conditionally.
371
+ */
372
+ collectReadPermissionFieldNames(fieldMap, target = /* @__PURE__ */ new Set()) {
373
+ const entries = fieldMap instanceof Map ? fieldMap.entries() : Object.entries(fieldMap);
374
+ for (const [fieldName, fieldDef] of entries) if (this.getReadPermissionFieldDefinition(fieldDef) !== void 0) {
375
+ target.add(toSnakeCase(fieldName));
376
+ target.add(fieldName);
377
+ }
378
+ return target;
379
+ }
215
380
  hasCustomPrimaryKey(fields) {
216
381
  return Object.values(fields).some((fieldDef) => fieldDef.primaryKey === true || fieldDef._meta?.primaryKey === true);
217
382
  }
@@ -337,6 +502,8 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
337
502
  */
338
503
  constructor(options = {}) {
339
504
  super(options);
505
+ this._defaultListLimit = assertOptionalListBound(options.defaultListLimit, "defaultListLimit");
506
+ this._maxListLimit = assertOptionalListBound(options.maxListLimit, "maxListLimit");
340
507
  const collectionCtor = this.constructor;
341
508
  if (this.constructor !== SmrtCollection && collectionCtor._itemClass) {
342
509
  const itemClass = collectionCtor._itemClass;
@@ -370,14 +537,16 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
370
537
  * ```
371
538
  */
372
539
  static async create(options = {}) {
373
- const { _className, db, persistence, ai, fs, logging, metrics, pubsub, sanitization, signals } = options;
540
+ const { _className, db, defaultListLimit, persistence, ai, fs, logging, maxListLimit, metrics, pubsub, sanitization, signals } = options;
374
541
  const collectionOptions = {
375
542
  _className,
376
543
  db,
377
544
  persistence,
378
545
  ai,
546
+ defaultListLimit,
379
547
  fs,
380
548
  logging,
549
+ maxListLimit,
381
550
  metrics,
382
551
  pubsub,
383
552
  sanitization,
@@ -472,8 +641,18 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
472
641
  * This is a convenience method that avoids N+1 queries when you have
473
642
  * a list of IDs and need to fetch the corresponding records.
474
643
  *
644
+ * The id list is chunked at `IN_LIST_CHUNK_SIZE` (#2367). `collection.list()`
645
+ * expands an array-valued WHERE into one `id IN (?, ?, ...)` clause and does
646
+ * not chunk it, so an unbounded caller-sized array hits the backend's
647
+ * bind-variable ceiling — `SQLITE_MAX_VARIABLE_NUMBER` (999 pre-3.32, 32766
648
+ * after) or PostgreSQL's 65535 — and the whole query fails before it runs.
649
+ * The relationship, junction and hierarchy loaders have chunked at this value
650
+ * for exactly this reason; `listByIds()` is the remaining path that took ids
651
+ * straight from the caller and did not.
652
+ *
475
653
  * @param ids - Array of UUIDs to fetch
476
- * @returns Promise resolving to array of objects (order not guaranteed)
654
+ * @returns Promise resolving to array of objects (order not guaranteed;
655
+ * results are concatenated in chunk order)
477
656
  *
478
657
  * @example
479
658
  * ```typescript
@@ -482,7 +661,17 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
482
661
  */
483
662
  async listByIds(ids) {
484
663
  if (ids.length === 0) return [];
485
- return this.list({ where: { id: ids } });
664
+ const chunks = chunkArray(ids, this._maxListLimit === void 0 ? 900 : Math.min(900, this._maxListLimit));
665
+ if (chunks.length === 1) return this.list({
666
+ limit: chunks[0].length,
667
+ where: { id: chunks[0] }
668
+ });
669
+ const results = [];
670
+ for (const idChunk of chunks) results.push(...await this.list({
671
+ limit: idChunk.length,
672
+ where: { id: idChunk }
673
+ }));
674
+ return results;
486
675
  }
487
676
  /**
488
677
  * Resolve the effective cache config for a read (issue #1498).
@@ -576,7 +765,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
576
765
  };
577
766
  }
578
767
  const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where));
579
- const fullSQL = `SELECT * FROM ${this.tableName} ${whereSql}`;
768
+ const fullSQL = `SELECT * FROM ${this.tableName} ${whereSql} LIMIT 1`;
580
769
  const rows = await this.queryRowsWithCache(fullSQL, whereValues, this.resolveReadCacheConfig(options.cache));
581
770
  if (!rows?.[0]) return await GlobalInterceptors.executeAfterGet(itemClassName, null, interceptorContext);
582
771
  const fields = this.getFieldsSync();
@@ -603,27 +792,19 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
603
792
  const fields = this.getFieldsSync();
604
793
  const projection = select !== void 0 ? this.resolveProjectionSelect(select, fields, isSTI) : void 0;
605
794
  if (projection && interceptedOptions.include && interceptedOptions.include.length > 0) throw new Error("collection.list({ select }) returns plain projection rows and cannot eager-load relationships. Remove include or omit select.");
606
- let orderBySql = "";
607
- if (orderBy) {
608
- orderBySql = " ORDER BY ";
609
- orderBySql += (Array.isArray(orderBy) ? orderBy : [orderBy]).map((item) => {
610
- const [field, direction = "ASC"] = item.split(" ");
611
- if (!/^[a-zA-Z0-9_]+$/.test(field)) throw new Error(`Invalid field name for ordering: ${field}`);
612
- const normalizedDirection = direction.toUpperCase();
613
- if (normalizedDirection !== "ASC" && normalizedDirection !== "DESC") throw new Error(`Invalid sort direction: ${direction}. Must be ASC or DESC.`);
614
- return `${toSnakeCase(field)} ${normalizedDirection}`;
615
- }).join(", ");
616
- }
795
+ const orderBySql = this.buildOrderBySql(orderBy, fields);
617
796
  let limitOffsetSql = "";
618
797
  const limitOffsetValues = [];
619
798
  let paramIndex = whereValues.length + 1;
620
- if (limit !== void 0) {
799
+ const boundedLimit = this.applyListBounds(assertQueryBound(limit, "limit"));
800
+ const boundedOffset = assertQueryBound(offset, "offset");
801
+ if (boundedLimit !== void 0) {
621
802
  limitOffsetSql += ` LIMIT $${paramIndex++}`;
622
- limitOffsetValues.push(limit);
803
+ limitOffsetValues.push(boundedLimit);
623
804
  }
624
- if (offset !== void 0) {
805
+ if (boundedOffset !== void 0) {
625
806
  limitOffsetSql += ` OFFSET $${paramIndex++}`;
626
- limitOffsetValues.push(offset);
807
+ limitOffsetValues.push(boundedOffset);
627
808
  }
628
809
  const sql = `SELECT ${projection ? projection.sql : "*"} FROM ${this.tableName} ${whereSql} ${orderBySql} ${limitOffsetSql}`;
629
810
  const params = [...whereValues, ...limitOffsetValues];