@happyvertical/smrt-core 0.40.69 → 0.41.0

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 (237) hide show
  1. package/AGENTS.md +29 -4
  2. package/README.md +20 -1
  3. package/agents/change-feed.md +1 -1
  4. package/agents/query-bounds.md +45 -0
  5. package/agents/schema-paths.md +786 -0
  6. package/dist/browser.d.ts +1 -0
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +5 -3
  9. package/dist/cascade.d.ts +120 -0
  10. package/dist/cascade.d.ts.map +1 -0
  11. package/dist/cascade.js +430 -0
  12. package/dist/cascade.js.map +1 -0
  13. package/dist/change-feed.d.ts +34 -2
  14. package/dist/change-feed.d.ts.map +1 -1
  15. package/dist/change-feed.js +52 -11
  16. package/dist/change-feed.js.map +1 -1
  17. package/dist/class.d.ts +36 -3
  18. package/dist/class.d.ts.map +1 -1
  19. package/dist/class.js +87 -9
  20. package/dist/class.js.map +1 -1
  21. package/dist/collection-cache.js +0 -0
  22. package/dist/collection-cache.js.map +1 -1
  23. package/dist/collection.d.ts +130 -2
  24. package/dist/collection.d.ts.map +1 -1
  25. package/dist/collection.js +290 -57
  26. package/dist/collection.js.map +1 -1
  27. package/dist/config.d.ts +10 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/database.d.ts +8 -0
  31. package/dist/database.d.ts.map +1 -1
  32. package/dist/database.js +16 -8
  33. package/dist/database.js.map +1 -1
  34. package/dist/db-errors.d.ts +105 -0
  35. package/dist/db-errors.d.ts.map +1 -0
  36. package/dist/db-errors.js +382 -0
  37. package/dist/db-errors.js.map +1 -0
  38. package/dist/decorators/index.d.ts +80 -6
  39. package/dist/decorators/index.d.ts.map +1 -1
  40. package/dist/decorators/index.js +102 -12
  41. package/dist/decorators/index.js.map +1 -1
  42. package/dist/dispatch/bus.d.ts.map +1 -1
  43. package/dist/dispatch/bus.js +4 -3
  44. package/dist/dispatch/bus.js.map +1 -1
  45. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  46. package/dist/dispatch/collections/Dispatches.js +19 -4
  47. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  48. package/dist/dispatch/types.d.ts +5 -0
  49. package/dist/dispatch/types.d.ts.map +1 -1
  50. package/dist/embedded-write-queue.d.ts +46 -0
  51. package/dist/embedded-write-queue.d.ts.map +1 -0
  52. package/dist/embedded-write-queue.js +66 -0
  53. package/dist/embedded-write-queue.js.map +1 -0
  54. package/dist/embeddings/storage.d.ts +7 -0
  55. package/dist/embeddings/storage.d.ts.map +1 -1
  56. package/dist/embeddings/storage.js +29 -12
  57. package/dist/embeddings/storage.js.map +1 -1
  58. package/dist/errors.d.ts +31 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js +34 -2
  61. package/dist/errors.js.map +1 -1
  62. package/dist/generators/changes-route.d.ts.map +1 -1
  63. package/dist/generators/changes-route.js +6 -3
  64. package/dist/generators/changes-route.js.map +1 -1
  65. package/dist/generators/mcp-runtime-template.d.ts +8 -0
  66. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  67. package/dist/generators/mcp-runtime-template.js +38 -4
  68. package/dist/generators/mcp-runtime-template.js.map +1 -1
  69. package/dist/generators/mcp.d.ts +16 -0
  70. package/dist/generators/mcp.d.ts.map +1 -1
  71. package/dist/generators/mcp.js +41 -3
  72. package/dist/generators/mcp.js.map +1 -1
  73. package/dist/generators/rest.d.ts +22 -0
  74. package/dist/generators/rest.d.ts.map +1 -1
  75. package/dist/generators/rest.js +34 -3
  76. package/dist/generators/rest.js.map +1 -1
  77. package/dist/hierarchical.js +1 -1
  78. package/dist/index.d.ts +7 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +12 -5
  81. package/dist/interceptors.d.ts +21 -0
  82. package/dist/interceptors.d.ts.map +1 -1
  83. package/dist/interceptors.js +27 -1
  84. package/dist/interceptors.js.map +1 -1
  85. package/dist/manifest/generator.d.ts.map +1 -1
  86. package/dist/manifest/generator.js +4 -7
  87. package/dist/manifest/generator.js.map +1 -1
  88. package/dist/manifest/static-manifest.js +10 -10
  89. package/dist/manifest/static-manifest.js.map +1 -1
  90. package/dist/manifest/store.js +1 -1
  91. package/dist/manifest/store.js.map +1 -1
  92. package/dist/manifest.json +19 -19
  93. package/dist/migrations/differ.d.ts +211 -9
  94. package/dist/migrations/differ.d.ts.map +1 -1
  95. package/dist/migrations/differ.js +613 -50
  96. package/dist/migrations/differ.js.map +1 -1
  97. package/dist/migrations/generator.d.ts +31 -4
  98. package/dist/migrations/generator.d.ts.map +1 -1
  99. package/dist/migrations/generator.js +49 -5
  100. package/dist/migrations/generator.js.map +1 -1
  101. package/dist/migrations/index.d.ts +4 -2
  102. package/dist/migrations/index.d.ts.map +1 -1
  103. package/dist/migrations/index.js +6 -3
  104. package/dist/migrations/minor-units.d.ts +162 -0
  105. package/dist/migrations/minor-units.d.ts.map +1 -0
  106. package/dist/migrations/minor-units.js +381 -0
  107. package/dist/migrations/minor-units.js.map +1 -0
  108. package/dist/migrations/orchestrate.js +35 -6
  109. package/dist/migrations/orchestrate.js.map +1 -1
  110. package/dist/migrations/sqlite-rebuild.d.ts +142 -0
  111. package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
  112. package/dist/migrations/sqlite-rebuild.js +514 -0
  113. package/dist/migrations/sqlite-rebuild.js.map +1 -0
  114. package/dist/migrations/tracker.d.ts +114 -1
  115. package/dist/migrations/tracker.d.ts.map +1 -1
  116. package/dist/migrations/tracker.js +331 -16
  117. package/dist/migrations/tracker.js.map +1 -1
  118. package/dist/migrations/types.d.ts +19 -4
  119. package/dist/migrations/types.d.ts.map +1 -1
  120. package/dist/migrations.js +6 -3
  121. package/dist/object.d.ts +142 -10
  122. package/dist/object.d.ts.map +1 -1
  123. package/dist/object.js +196 -41
  124. package/dist/object.js.map +1 -1
  125. package/dist/postgres-timeouts.d.ts +240 -0
  126. package/dist/postgres-timeouts.d.ts.map +1 -0
  127. package/dist/postgres-timeouts.js +204 -0
  128. package/dist/postgres-timeouts.js.map +1 -0
  129. package/dist/query-bounds.d.ts +101 -0
  130. package/dist/query-bounds.d.ts.map +1 -0
  131. package/dist/query-bounds.js +177 -0
  132. package/dist/query-bounds.js.map +1 -0
  133. package/dist/registry/class-registration.d.ts.map +1 -1
  134. package/dist/registry/class-registration.js +3 -1
  135. package/dist/registry/class-registration.js.map +1 -1
  136. package/dist/registry/manifest-field-merge.d.ts +12 -0
  137. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  138. package/dist/registry/manifest-field-merge.js +14 -2
  139. package/dist/registry/manifest-field-merge.js.map +1 -1
  140. package/dist/registry/schema-builder.d.ts +22 -1
  141. package/dist/registry/schema-builder.d.ts.map +1 -1
  142. package/dist/registry/schema-builder.js +205 -165
  143. package/dist/registry/schema-builder.js.map +1 -1
  144. package/dist/registry/types.d.ts +35 -3
  145. package/dist/registry/types.d.ts.map +1 -1
  146. package/dist/registry.d.ts +41 -46
  147. package/dist/registry.d.ts.map +1 -1
  148. package/dist/registry.js +61 -83
  149. package/dist/registry.js.map +1 -1
  150. package/dist/scanner/manifest-generator.d.ts +45 -0
  151. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  152. package/dist/scanner/manifest-generator.js +92 -28
  153. package/dist/scanner/manifest-generator.js.map +1 -1
  154. package/dist/scanner/types.d.ts +5 -0
  155. package/dist/scanner/types.d.ts.map +1 -1
  156. package/dist/scanner/types.js.map +1 -1
  157. package/dist/schema/conflict-target.d.ts +104 -0
  158. package/dist/schema/conflict-target.d.ts.map +1 -0
  159. package/dist/schema/conflict-target.js +129 -0
  160. package/dist/schema/conflict-target.js.map +1 -0
  161. package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
  162. package/dist/schema/ddl/base-strategy.js +2 -2
  163. package/dist/schema/ddl/base-strategy.js.map +1 -1
  164. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  165. package/dist/schema/ddl/duckdb-strategy.js +2 -1
  166. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  167. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  168. package/dist/schema/ddl/postgres-strategy.js +12 -1
  169. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  170. package/dist/schema/generator.d.ts +307 -6
  171. package/dist/schema/generator.d.ts.map +1 -1
  172. package/dist/schema/generator.js +510 -87
  173. package/dist/schema/generator.js.map +1 -1
  174. package/dist/schema/index-utils.d.ts +120 -0
  175. package/dist/schema/index-utils.d.ts.map +1 -1
  176. package/dist/schema/index-utils.js +242 -1
  177. package/dist/schema/index-utils.js.map +1 -1
  178. package/dist/schema/index.d.ts +3 -0
  179. package/dist/schema/index.d.ts.map +1 -1
  180. package/dist/schema/index.js +4 -1
  181. package/dist/schema/live-parity.d.ts +90 -0
  182. package/dist/schema/live-parity.d.ts.map +1 -0
  183. package/dist/schema/live-parity.js +602 -0
  184. package/dist/schema/live-parity.js.map +1 -0
  185. package/dist/schema/manifest-schema.d.ts +121 -0
  186. package/dist/schema/manifest-schema.d.ts.map +1 -0
  187. package/dist/schema/manifest-schema.js +267 -0
  188. package/dist/schema/manifest-schema.js.map +1 -0
  189. package/dist/schema/schema-aggregator.d.ts +24 -10
  190. package/dist/schema/schema-aggregator.d.ts.map +1 -1
  191. package/dist/schema/schema-aggregator.js +35 -90
  192. package/dist/schema/schema-aggregator.js.map +1 -1
  193. package/dist/schema/system-table-shapes.d.ts +65 -0
  194. package/dist/schema/system-table-shapes.d.ts.map +1 -0
  195. package/dist/schema/system-table-shapes.js +187 -0
  196. package/dist/schema/system-table-shapes.js.map +1 -0
  197. package/dist/schema/types.d.ts +103 -4
  198. package/dist/schema/types.d.ts.map +1 -1
  199. package/dist/schema/utils.d.ts +2 -1
  200. package/dist/schema/utils.d.ts.map +1 -1
  201. package/dist/schema/utils.js +5 -3
  202. package/dist/schema/utils.js.map +1 -1
  203. package/dist/schema.js +4 -1
  204. package/dist/smrt-knowledge.json +20 -8
  205. package/dist/sync/apply.d.ts.map +1 -1
  206. package/dist/sync/apply.js +9 -16
  207. package/dist/sync/apply.js.map +1 -1
  208. package/dist/system/compatibility.d.ts +42 -0
  209. package/dist/system/compatibility.d.ts.map +1 -1
  210. package/dist/system/compatibility.js +182 -9
  211. package/dist/system/compatibility.js.map +1 -1
  212. package/dist/system/index.d.ts +1 -0
  213. package/dist/system/index.d.ts.map +1 -1
  214. package/dist/system/index.js +3 -2
  215. package/dist/system/retention.d.ts +237 -0
  216. package/dist/system/retention.d.ts.map +1 -0
  217. package/dist/system/retention.js +497 -0
  218. package/dist/system/retention.js.map +1 -0
  219. package/dist/system/schema.d.ts +100 -15
  220. package/dist/system/schema.d.ts.map +1 -1
  221. package/dist/system/schema.js +81 -45
  222. package/dist/system/schema.js.map +1 -1
  223. package/dist/system/types.d.ts +0 -2
  224. package/dist/system/types.d.ts.map +1 -1
  225. package/dist/testing/database.d.ts.map +1 -1
  226. package/dist/testing/database.js +1 -0
  227. package/dist/testing/database.js.map +1 -1
  228. package/dist/vite-plugin/index.d.ts.map +1 -1
  229. package/dist/vite-plugin/index.js +4 -9
  230. package/dist/vite-plugin/index.js.map +1 -1
  231. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  232. package/dist/vite-plugin/sveltekit-generator.js +71 -5
  233. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  234. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  235. package/dist/vite-plugin/web-collections.js +6 -4
  236. package/dist/vite-plugin/web-collections.js.map +1 -1
  237. package/package.json +5 -5
@@ -1,18 +1,61 @@
1
+ import { chunkArray } from "./utils/chunk.js";
2
+ import { classnameToTablename, toSnakeCase } from "./utils/naming.js";
1
3
  import { buildQueryCacheKey, ensureCacheInvalidationListener, getCacheGeneration, getCachedRows, invalidateCollectionCache, registerCrossProcessCacheInterest, resolveDbCacheKey, setCachedRows } from "./collection-cache.js";
2
- import { GlobalInterceptors, createInterceptorContext } from "./interceptors.js";
4
+ import { isTenantScopedClassResolved, resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
5
+ import { GlobalInterceptors, createInterceptorContext, resolveGetStringFilter } from "./interceptors.js";
3
6
  import { SmrtClass } from "./class.js";
4
7
  import { EmbeddingProvider } from "./embeddings/provider.js";
5
8
  import { EmbeddingStorage } from "./embeddings/storage.js";
6
- import { classnameToTablename, toSnakeCase } from "./utils/naming.js";
9
+ import { QueryBoundsError, QueryOrderByError } from "./query-bounds.js";
7
10
  import { verifyPersistenceTable } from "./schema/table-verifier.js";
8
11
  import { fieldsFromClass, formatDataJs, toCamelCase } from "./utils.js";
9
12
  import { ObjectRegistry } from "./registry.js";
10
- import { chunkArray } from "./utils/chunk.js";
11
13
  import { createLogger } from "@happyvertical/logger";
12
14
  import { buildWhere } from "@happyvertical/sql";
13
15
  //#region src/collection.ts
14
16
  var logger = createLogger({ level: "info" });
15
17
  /**
18
+ * `_smrt_contexts.owner_id` value for collection-level (as opposed to
19
+ * per-object) memory. Tenant-scoped collections suffix the active tenant id
20
+ * (`__collection__:<tenantId>`) so learned memory never crosses tenants —
21
+ * see {@link SmrtCollection.resolveCollectionMemoryOwnerId} (#2365).
22
+ */
23
+ var COLLECTION_MEMORY_OWNER_ID = "__collection__";
24
+ /**
25
+ * Validate an optional collection-level list bound (#2367).
26
+ *
27
+ * @returns the bound, or `undefined` when the option was not supplied
28
+ * @throws {QueryBoundsError} when the option is present but not a positive
29
+ * integer — a `maxListLimit` of `0` or `-1` would silently make every read on
30
+ * the collection return nothing
31
+ */
32
+ function assertOptionalListBound(value, optionName) {
33
+ if (value === void 0) return void 0;
34
+ if (!Number.isSafeInteger(value) || value < 1) throw new QueryBoundsError(`Invalid ${optionName}: expected a positive integer, got ${String(value)}.`, "INVALID_QUERY_BOUNDS", {
35
+ optionName,
36
+ value
37
+ });
38
+ return value;
39
+ }
40
+ /**
41
+ * Validate a per-query `limit`/`offset` before it is bound (#2367).
42
+ *
43
+ * `0` is allowed — `LIMIT 0` and `OFFSET 0` are both meaningful — but `NaN`,
44
+ * `Infinity`, negatives and fractions are not: they used to be bound verbatim
45
+ * and surface as a driver-level 500 for what is a caller error.
46
+ *
47
+ * @returns the bound, or `undefined` when the caller supplied none
48
+ * @throws {QueryBoundsError} when the value is not a non-negative integer
49
+ */
50
+ function assertQueryBound(value, parameterName) {
51
+ if (value === void 0 || value === null) return void 0;
52
+ if (!Number.isSafeInteger(value) || value < 0) throw new QueryBoundsError(`Invalid ${parameterName}: expected a non-negative integer, got ${String(value)}.`, "INVALID_QUERY_BOUNDS", {
53
+ parameterName,
54
+ value
55
+ });
56
+ return value;
57
+ }
58
+ /**
16
59
  * Resolve _meta_type in WHERE clause from simple class name to qualified name (Issue #713)
17
60
  *
18
61
  * This helper function allows queries like { _meta_type: 'Image' } to work correctly
@@ -91,6 +134,13 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
91
134
  * @private
92
135
  */
93
136
  _cachedFields = null;
137
+ /**
138
+ * Opt-in list bounds (#2367). See {@link SmrtCollectionOptions.defaultListLimit}
139
+ * and {@link SmrtCollectionOptions.maxListLimit}.
140
+ * @private
141
+ */
142
+ _defaultListLimit;
143
+ _maxListLimit;
94
144
  getRegisteredItemClass() {
95
145
  return ObjectRegistry.getClassByConstructor(this._itemClass) || ObjectRegistry.getClass(this._itemClass.name);
96
146
  }
@@ -102,6 +152,135 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
102
152
  return registered?.qualifiedName || registered?.name || this._itemClass.name;
103
153
  }
104
154
  /**
155
+ * Resolve the identifier whitelist and the sensitive-column blocklist that
156
+ * every caller-supplied identifier on this collection is checked against.
157
+ *
158
+ * Extracted from `convertWhereKeys()` so `orderBy` validation enforces the
159
+ * *same* sets (#2367). `where` (#1540) and `select` (#1902) both rejected
160
+ * sensitive columns while `orderBy` accepted any identifier, which made
161
+ * `?orderBy=api_secret&limit=1` an ordering oracle over a column the same
162
+ * request is forbidden to filter on or project.
163
+ *
164
+ * @returns the snake_case identifiers this collection accepts, the sensitive
165
+ * names it must refuse, and whether whitelist enforcement applies at all —
166
+ * classes with no registered fields (manifest-less inline test classes,
167
+ * #869) have nothing to validate against and fall through to SQL.
168
+ * @private
169
+ */
170
+ collectQueryableFieldNames(fields) {
171
+ const validFieldNames = new Set(Object.keys(fields).map((f) => toSnakeCase(f)));
172
+ const sensitiveFieldNames = this.collectSensitiveFieldNames(fields);
173
+ const readPermissionFieldNames = this.collectReadPermissionFieldNames(fields);
174
+ validFieldNames.add("id");
175
+ validFieldNames.add("slug");
176
+ validFieldNames.add("context");
177
+ validFieldNames.add("created_at");
178
+ validFieldNames.add("updated_at");
179
+ const itemClassName = this.getResolvedItemClassName();
180
+ const itemQualifiedName = this.getResolvedItemQualifiedName();
181
+ if (ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti") {
182
+ validFieldNames.add("_meta_type");
183
+ validFieldNames.add("meta_type");
184
+ validFieldNames.add("_meta_data");
185
+ validFieldNames.add("meta_data");
186
+ const inheritanceChain = ObjectRegistry.getInheritanceChain(itemQualifiedName);
187
+ for (const ancestorName of inheritanceChain) {
188
+ if (ancestorName === "SmrtObject" || ancestorName === "SmrtClass" || ancestorName === itemQualifiedName || ancestorName === itemClassName) continue;
189
+ const ancestorFields = ObjectRegistry.getFields(ancestorName);
190
+ for (const fieldName of ancestorFields.keys()) validFieldNames.add(toSnakeCase(fieldName));
191
+ this.collectSensitiveFieldNames(ancestorFields, sensitiveFieldNames);
192
+ this.collectReadPermissionFieldNames(ancestorFields, readPermissionFieldNames);
193
+ }
194
+ const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
195
+ if (stiBase) for (const descendant of ObjectRegistry.getDescendants(stiBase)) {
196
+ const descendantFields = ObjectRegistry.getFields(descendant);
197
+ this.collectSensitiveFieldNames(descendantFields, sensitiveFieldNames);
198
+ this.collectReadPermissionFieldNames(descendantFields, readPermissionFieldNames);
199
+ }
200
+ }
201
+ return {
202
+ readPermissionFieldNames,
203
+ skipFieldValidation: Object.keys(fields).length === 0,
204
+ sensitiveFieldNames,
205
+ validFieldNames
206
+ };
207
+ }
208
+ /**
209
+ * Build the `ORDER BY` clause for `list()`, validating every term against the
210
+ * same whitelist and sensitive-column blocklist as `where` and `select`
211
+ * (#2367).
212
+ *
213
+ * `orderBy` is interpolated UNPARAMETERIZED into the identifier position, and
214
+ * it arrives from the generated REST/MCP/SvelteKit surfaces as caller input.
215
+ * The identifier regex here has always blocked injection; what it did not
216
+ * block was ordering by a column the caller may not read. Sorting is a
217
+ * comparison, and a comparison against a secret is an oracle: repeatedly
218
+ * requesting `?orderBy=api_secret&limit=1` (with a shrinking `where`) walks
219
+ * the secret's value ordering without ever serializing the column.
220
+ *
221
+ * @param orderBy - one `'<field> [ASC|DESC]'` term or an array of them
222
+ * @param fields - the collection's cached field definitions
223
+ * @returns the `' ORDER BY ...'` fragment, or `''` when no ordering was asked
224
+ * for
225
+ * @throws {QueryOrderByError} on a malformed term, an unknown column, or a
226
+ * sensitive column — all rendered as a 400 by the generated surfaces
227
+ * @private
228
+ */
229
+ buildOrderBySql(orderBy, fields) {
230
+ if (!orderBy) return "";
231
+ const orderByItems = Array.isArray(orderBy) ? orderBy : [orderBy];
232
+ if (orderByItems.length === 0) return "";
233
+ const itemClassName = this.getResolvedItemClassName();
234
+ const { readPermissionFieldNames, sensitiveFieldNames, skipFieldValidation, validFieldNames } = this.collectQueryableFieldNames(fields);
235
+ const definitionByColumn = /* @__PURE__ */ new Map();
236
+ for (const [fieldName, fieldDef] of Object.entries(fields)) definitionByColumn.set(toSnakeCase(fieldName), fieldDef);
237
+ const customPrimaryKey = this.hasCustomPrimaryKey(fields);
238
+ const registeredFields = ObjectRegistry.getFields(this.getResolvedItemQualifiedName());
239
+ const explicitFieldNames = new Set((registeredFields.size > 0 ? registeredFields : ObjectRegistry.getFields(itemClassName)).keys());
240
+ return ` ORDER BY ${orderByItems.map((item) => {
241
+ const [field, direction = "ASC"] = String(item).trim().split(/\s+/);
242
+ 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 });
243
+ const normalizedDirection = direction.toUpperCase();
244
+ 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 });
245
+ const strippedName = toSnakeCase(field);
246
+ const columnName = definitionByColumn.has(strippedName) ? strippedName : this.toDbColumnName(field);
247
+ 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 });
248
+ 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 });
249
+ 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", {
250
+ field,
251
+ itemClassName
252
+ });
253
+ const fieldDef = definitionByColumn.get(columnName);
254
+ const fieldType = fieldDef?.type;
255
+ const isTransient = fieldDef?.transient === true || fieldDef?._meta?.transient === true;
256
+ 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", {
257
+ field,
258
+ itemClassName
259
+ });
260
+ return `${columnName} ${normalizedDirection}`;
261
+ }).join(", ")}`;
262
+ }
263
+ /**
264
+ * Apply this collection's configured list bounds (#2367).
265
+ *
266
+ * Both bounds are opt-in (`defaultListLimit` / `maxListLimit` collection
267
+ * options) and default to unset. `list()` is the framework's bulk-read
268
+ * primitive — relationship loaders, junction hydration and `listByIds()` all
269
+ * go through it expecting every matching row — so a framework-wide implicit
270
+ * default would silently truncate correct queries instead of bounding an
271
+ * attack surface. The generated surfaces, where untrusted input actually
272
+ * arrives, apply {@link DEFAULT_LIST_LIMIT}/{@link MAX_LIST_LIMIT}
273
+ * unconditionally; an application that wants the same ceiling on its own
274
+ * programmatic reads sets `maxListLimit` when constructing the collection.
275
+ *
276
+ * @private
277
+ */
278
+ applyListBounds(limit) {
279
+ const effective = limit ?? this._defaultListLimit;
280
+ if (effective === void 0) return void 0;
281
+ return this._maxListLimit === void 0 ? effective : Math.min(effective, this._maxListLimit);
282
+ }
283
+ /**
105
284
  * Convert WHERE clause field names from camelCase to snake_case while preserving operators.
106
285
  * Validates operators and field names to prevent SQL injection and invalid queries.
107
286
  *
@@ -131,31 +310,8 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
131
310
  ];
132
311
  const UNSUPPORTED_OPERATOR_HINTS = /* @__PURE__ */ new Map([["contains", "Use 'like' with explicit wildcards instead, e.g. { 'name like': '%term%' }."]]);
133
312
  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
313
  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);
314
+ const { sensitiveFieldNames, skipFieldValidation, validFieldNames } = this.collectQueryableFieldNames(fields);
159
315
  const converted = {};
160
316
  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
317
  for (const [key, value] of Object.entries(where)) {
@@ -212,6 +368,23 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
212
368
  }
213
369
  return target;
214
370
  }
371
+ /**
372
+ * The `collectSensitiveFieldNames` sibling for `@field({ readPermission })`
373
+ * columns (#2367).
374
+ *
375
+ * These are per-caller redacted by `toPublicJSON()` and refused outright by
376
+ * `select`, so an ordering over one leaks comparisons about a value the
377
+ * caller may not read. `list()` has no permission context, so — exactly as
378
+ * `select` does — they are refused unconditionally rather than conditionally.
379
+ */
380
+ collectReadPermissionFieldNames(fieldMap, target = /* @__PURE__ */ new Set()) {
381
+ const entries = fieldMap instanceof Map ? fieldMap.entries() : Object.entries(fieldMap);
382
+ for (const [fieldName, fieldDef] of entries) if (this.getReadPermissionFieldDefinition(fieldDef) !== void 0) {
383
+ target.add(toSnakeCase(fieldName));
384
+ target.add(fieldName);
385
+ }
386
+ return target;
387
+ }
215
388
  hasCustomPrimaryKey(fields) {
216
389
  return Object.values(fields).some((fieldDef) => fieldDef.primaryKey === true || fieldDef._meta?.primaryKey === true);
217
390
  }
@@ -337,6 +510,8 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
337
510
  */
338
511
  constructor(options = {}) {
339
512
  super(options);
513
+ this._defaultListLimit = assertOptionalListBound(options.defaultListLimit, "defaultListLimit");
514
+ this._maxListLimit = assertOptionalListBound(options.maxListLimit, "maxListLimit");
340
515
  const collectionCtor = this.constructor;
341
516
  if (this.constructor !== SmrtCollection && collectionCtor._itemClass) {
342
517
  const itemClass = collectionCtor._itemClass;
@@ -370,14 +545,16 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
370
545
  * ```
371
546
  */
372
547
  static async create(options = {}) {
373
- const { _className, db, persistence, ai, fs, logging, metrics, pubsub, sanitization, signals } = options;
548
+ const { _className, db, defaultListLimit, persistence, ai, fs, logging, maxListLimit, metrics, pubsub, sanitization, signals } = options;
374
549
  const collectionOptions = {
375
550
  _className,
376
551
  db,
377
552
  persistence,
378
553
  ai,
554
+ defaultListLimit,
379
555
  fs,
380
556
  logging,
557
+ maxListLimit,
381
558
  metrics,
382
559
  pubsub,
383
560
  sanitization,
@@ -472,8 +649,18 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
472
649
  * This is a convenience method that avoids N+1 queries when you have
473
650
  * a list of IDs and need to fetch the corresponding records.
474
651
  *
652
+ * The id list is chunked at `IN_LIST_CHUNK_SIZE` (#2367). `collection.list()`
653
+ * expands an array-valued WHERE into one `id IN (?, ?, ...)` clause and does
654
+ * not chunk it, so an unbounded caller-sized array hits the backend's
655
+ * bind-variable ceiling — `SQLITE_MAX_VARIABLE_NUMBER` (999 pre-3.32, 32766
656
+ * after) or PostgreSQL's 65535 — and the whole query fails before it runs.
657
+ * The relationship, junction and hierarchy loaders have chunked at this value
658
+ * for exactly this reason; `listByIds()` is the remaining path that took ids
659
+ * straight from the caller and did not.
660
+ *
475
661
  * @param ids - Array of UUIDs to fetch
476
- * @returns Promise resolving to array of objects (order not guaranteed)
662
+ * @returns Promise resolving to array of objects (order not guaranteed;
663
+ * results are concatenated in chunk order)
477
664
  *
478
665
  * @example
479
666
  * ```typescript
@@ -482,7 +669,17 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
482
669
  */
483
670
  async listByIds(ids) {
484
671
  if (ids.length === 0) return [];
485
- return this.list({ where: { id: ids } });
672
+ const chunks = chunkArray(ids, this._maxListLimit === void 0 ? 900 : Math.min(900, this._maxListLimit));
673
+ if (chunks.length === 1) return this.list({
674
+ limit: chunks[0].length,
675
+ where: { id: chunks[0] }
676
+ });
677
+ const results = [];
678
+ for (const idChunk of chunks) results.push(...await this.list({
679
+ limit: idChunk.length,
680
+ where: { id: idChunk }
681
+ }));
682
+ return results;
486
683
  }
487
684
  /**
488
685
  * Resolve the effective cache config for a read (issue #1498).
@@ -563,10 +760,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
563
760
  const itemQualifiedName = this.getResolvedItemQualifiedName();
564
761
  const interceptorContext = createInterceptorContext(itemClassName, "get", this.constructor.name);
565
762
  const interceptedFilter = await GlobalInterceptors.executeBeforeGet(itemClassName, filter, interceptorContext);
566
- let where = typeof interceptedFilter === "string" ? /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(interceptedFilter) ? { id: interceptedFilter } : {
567
- slug: interceptedFilter,
568
- context: ""
569
- } : interceptedFilter;
763
+ let where = typeof interceptedFilter === "string" ? resolveGetStringFilter(interceptedFilter) : interceptedFilter;
570
764
  const isSTI = ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti";
571
765
  if (isSTI) {
572
766
  const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
@@ -576,7 +770,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
576
770
  };
577
771
  }
578
772
  const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where));
579
- const fullSQL = `SELECT * FROM ${this.tableName} ${whereSql}`;
773
+ const fullSQL = `SELECT * FROM ${this.tableName} ${whereSql} LIMIT 1`;
580
774
  const rows = await this.queryRowsWithCache(fullSQL, whereValues, this.resolveReadCacheConfig(options.cache));
581
775
  if (!rows?.[0]) return await GlobalInterceptors.executeAfterGet(itemClassName, null, interceptorContext);
582
776
  const fields = this.getFieldsSync();
@@ -603,27 +797,19 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
603
797
  const fields = this.getFieldsSync();
604
798
  const projection = select !== void 0 ? this.resolveProjectionSelect(select, fields, isSTI) : void 0;
605
799
  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
- }
800
+ const orderBySql = this.buildOrderBySql(orderBy, fields);
617
801
  let limitOffsetSql = "";
618
802
  const limitOffsetValues = [];
619
803
  let paramIndex = whereValues.length + 1;
620
- if (limit !== void 0) {
804
+ const boundedLimit = this.applyListBounds(assertQueryBound(limit, "limit"));
805
+ const boundedOffset = assertQueryBound(offset, "offset");
806
+ if (boundedLimit !== void 0) {
621
807
  limitOffsetSql += ` LIMIT $${paramIndex++}`;
622
- limitOffsetValues.push(limit);
808
+ limitOffsetValues.push(boundedLimit);
623
809
  }
624
- if (offset !== void 0) {
810
+ if (boundedOffset !== void 0) {
625
811
  limitOffsetSql += ` OFFSET $${paramIndex++}`;
626
- limitOffsetValues.push(offset);
812
+ limitOffsetValues.push(boundedOffset);
627
813
  }
628
814
  const sql = `SELECT ${projection ? projection.sql : "*"} FROM ${this.tableName} ${whereSql} ${orderBySql} ${limitOffsetSql}`;
629
815
  const params = [...whereValues, ...limitOffsetValues];
@@ -1289,11 +1475,41 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1289
1475
  return instances;
1290
1476
  }
1291
1477
  /**
1478
+ * Resolve the `_smrt_contexts.owner_id` key for collection-level memory
1479
+ * (#2365).
1480
+ *
1481
+ * `_smrt_contexts` has no tenant column, so before this hook a tenant-scoped
1482
+ * collection's learned memory (`remember()`/`recall()`) was filed under one
1483
+ * shared `__collection__` key and leaked across tenants. For a tenant-scoped
1484
+ * item class under an active tenant context the key becomes
1485
+ * `__collection__:<tenantId>`; everything else (tenancy disabled, no active
1486
+ * tenant, system context, non-tenant-scoped class) keeps the shared
1487
+ * `__collection__` key. Tenant-keyed memory is strictly isolated — a tenant
1488
+ * recall does NOT fall back to the shared key, so memory learned outside a
1489
+ * tenant context is invisible inside one (and vice versa).
1490
+ *
1491
+ * Tenant resolution uses the same core-side trust anchors as the generated
1492
+ * REST read scope (#1782): `resolveDispatchTenantScope()` for the active
1493
+ * tenant and the triple tenant-scoped-class check covering `@smrt()` config,
1494
+ * manifest config, and the tenancy-filled `@TenantScoped()` resolver.
1495
+ */
1496
+ resolveCollectionMemoryOwnerId() {
1497
+ const itemClassName = this.getResolvedItemClassName();
1498
+ if (!(ObjectRegistry.isTenantScoped(itemClassName) || !!ObjectRegistry.getConfig(itemClassName)?.tenantScoped || isTenantScopedClassResolved(itemClassName))) return COLLECTION_MEMORY_OWNER_ID;
1499
+ const scope = resolveDispatchTenantScope();
1500
+ if (!scope.enforced || !scope.tenantId) return COLLECTION_MEMORY_OWNER_ID;
1501
+ return `${COLLECTION_MEMORY_OWNER_ID}:${scope.tenantId}`;
1502
+ }
1503
+ /**
1292
1504
  * Remember collection-level context
1293
1505
  *
1294
1506
  * Stores context applicable to all instances of this collection type.
1295
1507
  * Use for patterns that apply to the entire collection (e.g., default parsing strategies).
1296
1508
  *
1509
+ * Under an active tenant context on a tenant-scoped class, memory is keyed
1510
+ * per tenant and never shared across tenants (#2365) — see
1511
+ * {@link resolveCollectionMemoryOwnerId}.
1512
+ *
1297
1513
  * `expiresAt` is stored on the `_smrt_contexts` row as caller-managed metadata.
1298
1514
  * {@link recall} and {@link recallAll} do **not** filter on it, so an expired
1299
1515
  * entry is still returned; filter or delete expired entries yourself.
@@ -1333,7 +1549,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1333
1549
  ], {
1334
1550
  id,
1335
1551
  owner_class: this._itemClass.name,
1336
- owner_id: "__collection__",
1552
+ owner_id: this.resolveCollectionMemoryOwnerId(),
1337
1553
  scope: options.scope,
1338
1554
  key: options.key,
1339
1555
  value: JSON.stringify(options.value),
@@ -1367,12 +1583,13 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1367
1583
  */
1368
1584
  async recall(options) {
1369
1585
  if (!this.systemDb) throw new Error("Database not initialized. Call initialize() first.");
1586
+ const ownerId = this.resolveCollectionMemoryOwnerId();
1370
1587
  let result;
1371
1588
  if (options.minConfidence !== void 0) result = await this.systemDb.single`
1372
1589
  SELECT value, confidence
1373
1590
  FROM _smrt_contexts
1374
1591
  WHERE owner_class = ${this._itemClass.name}
1375
- AND owner_id = ${"__collection__"}
1592
+ AND owner_id = ${ownerId}
1376
1593
  AND scope = ${options.scope}
1377
1594
  AND key = ${options.key}
1378
1595
  AND confidence >= ${options.minConfidence}
@@ -1383,7 +1600,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1383
1600
  SELECT value, confidence
1384
1601
  FROM _smrt_contexts
1385
1602
  WHERE owner_class = ${this._itemClass.name}
1386
- AND owner_id = ${"__collection__"}
1603
+ AND owner_id = ${ownerId}
1387
1604
  AND scope = ${options.scope}
1388
1605
  AND key = ${options.key}
1389
1606
  ORDER BY confidence DESC, version DESC
@@ -1438,7 +1655,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1438
1655
  FROM _smrt_contexts
1439
1656
  WHERE owner_class = ? AND owner_id = ?
1440
1657
  `;
1441
- const params = [this._itemClass.name, "__collection__"];
1658
+ const params = [this._itemClass.name, this.resolveCollectionMemoryOwnerId()];
1442
1659
  if (options.scope) if (options.includeDescendants) {
1443
1660
  query += ` AND (scope = ? OR scope LIKE ?)`;
1444
1661
  params.push(options.scope, `${options.scope}/%`);
@@ -1483,7 +1700,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1483
1700
  async forget(options) {
1484
1701
  if (!this.systemDb) throw new Error("Database not initialized. Call initialize() first.");
1485
1702
  await this.systemDb.query(`DELETE FROM _smrt_contexts
1486
- WHERE owner_class = ? AND owner_id = ? AND scope = ? AND key = ?`, this._itemClass.name, "__collection__", options.scope, options.key);
1703
+ WHERE owner_class = ? AND owner_id = ? AND scope = ? AND key = ?`, this._itemClass.name, this.resolveCollectionMemoryOwnerId(), options.scope, options.key);
1487
1704
  }
1488
1705
  /**
1489
1706
  * Forget all collection-level context in a scope
@@ -1507,7 +1724,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1507
1724
  DELETE FROM _smrt_contexts
1508
1725
  WHERE owner_class = ? AND owner_id = ?
1509
1726
  `;
1510
- const params = [this._itemClass.name, "__collection__"];
1727
+ const params = [this._itemClass.name, this.resolveCollectionMemoryOwnerId()];
1511
1728
  if (options.includeDescendants) {
1512
1729
  query += ` AND (scope = ? OR scope LIKE ?)`;
1513
1730
  params.push(options.scope, `${options.scope}/%`);
@@ -1524,6 +1741,11 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1524
1741
  * Generates an embedding for the query text and finds similar objects
1525
1742
  * based on cosine similarity of stored embeddings.
1526
1743
  *
1744
+ * Under an active tenant context on a tenant-scoped class, candidates are
1745
+ * restricted to the tenant's rows BEFORE top-K ranking (#2365), so results
1746
+ * are never starved by — and similarity ranks never leak — other tenants'
1747
+ * content.
1748
+ *
1527
1749
  * @param query - Text to search for
1528
1750
  * @param options - Search options
1529
1751
  * @param options.field - Specific field to search (defaults to first embedding
@@ -1656,11 +1878,22 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1656
1878
  fallbackToAI: embeddingConfig.fallbackToAI
1657
1879
  }, this.ai).getModelName();
1658
1880
  const vector = (ObjectRegistry.getProjectEmbeddingConfig()?.storage || "json") === "native" ? this.systemDb.vector : void 0;
1881
+ const itemClassName = this.getResolvedItemClassName();
1882
+ const tenantPrefilterContext = createInterceptorContext(itemClassName, "list", this.constructor.name);
1883
+ const tenantPrefilter = await GlobalInterceptors.executeBeforeList(itemClassName, { where: {} }, tenantPrefilterContext);
1884
+ let candidateObjectIds;
1885
+ if (tenantPrefilter.where && Object.keys(tenantPrefilter.where).length > 0) {
1886
+ const { sql: candidateWhereSql, values: candidateValues } = buildWhere(this.convertWhereKeys(tenantPrefilter.where));
1887
+ const { rows: candidateRows } = await this.db.query(`SELECT id FROM ${this.tableName} ${candidateWhereSql}`, ...candidateValues);
1888
+ candidateObjectIds = candidateRows.map((row) => String(row.id));
1889
+ if (candidateObjectIds.length === 0) return [];
1890
+ }
1659
1891
  const scored = await EmbeddingStorage.searchSimilar(this.systemDb, this._itemClass.name, embedding, {
1660
1892
  field: searchField,
1661
1893
  model,
1662
1894
  limit,
1663
- minSimilarity
1895
+ minSimilarity,
1896
+ objectIds: candidateObjectIds
1664
1897
  }, vector);
1665
1898
  if (scored.length === 0) return [];
1666
1899
  const objectIds = scored.map((s) => s.objectId);