@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.
- package/AGENTS.md +29 -4
- package/README.md +20 -1
- package/agents/query-bounds.md +45 -0
- package/agents/schema-paths.md +328 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -1
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +28 -4
- package/dist/class.js.map +1 -1
- package/dist/collection.d.ts +101 -2
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +224 -43
- package/dist/collection.js.map +1 -1
- package/dist/database.d.ts +8 -0
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +16 -8
- package/dist/database.js.map +1 -1
- package/dist/db-errors.d.ts +105 -0
- package/dist/db-errors.d.ts.map +1 -0
- package/dist/db-errors.js +382 -0
- package/dist/db-errors.js.map +1 -0
- package/dist/decorators/index.d.ts +17 -1
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +65 -7
- package/dist/decorators/index.js.map +1 -1
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +4 -3
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/errors.d.ts +30 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +34 -2
- package/dist/errors.js.map +1 -1
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +6 -3
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/mcp-runtime-template.d.ts +8 -0
- package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
- package/dist/generators/mcp-runtime-template.js +38 -4
- package/dist/generators/mcp-runtime-template.js.map +1 -1
- package/dist/generators/mcp.d.ts +16 -0
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +41 -3
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/rest.d.ts +22 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +34 -3
- package/dist/generators/rest.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrations/differ.d.ts +202 -8
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +604 -46
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/generator.d.ts +31 -4
- package/dist/migrations/generator.d.ts.map +1 -1
- package/dist/migrations/generator.js +49 -5
- package/dist/migrations/generator.js.map +1 -1
- package/dist/migrations/index.d.ts +3 -2
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +5 -3
- package/dist/migrations/orchestrate.js +35 -6
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/sqlite-rebuild.d.ts +142 -0
- package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
- package/dist/migrations/sqlite-rebuild.js +514 -0
- package/dist/migrations/sqlite-rebuild.js.map +1 -0
- package/dist/migrations/tracker.d.ts +114 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +331 -16
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations/types.d.ts +19 -4
- package/dist/migrations/types.d.ts.map +1 -1
- package/dist/migrations.js +5 -3
- package/dist/object.d.ts +45 -6
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +60 -13
- package/dist/object.js.map +1 -1
- package/dist/postgres-timeouts.d.ts +240 -0
- package/dist/postgres-timeouts.d.ts.map +1 -0
- package/dist/postgres-timeouts.js +204 -0
- package/dist/postgres-timeouts.js.map +1 -0
- package/dist/query-bounds.d.ts +97 -0
- package/dist/query-bounds.d.ts.map +1 -0
- package/dist/query-bounds.js +173 -0
- package/dist/query-bounds.js.map +1 -0
- package/dist/registry/types.d.ts +32 -1
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/scanner/types.d.ts +5 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/base-strategy.js +2 -2
- package/dist/schema/ddl/base-strategy.js.map +1 -1
- package/dist/schema/generator.d.ts +70 -5
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +124 -6
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/live-parity.d.ts +90 -0
- package/dist/schema/live-parity.d.ts.map +1 -0
- package/dist/schema/live-parity.js +600 -0
- package/dist/schema/live-parity.js.map +1 -0
- package/dist/schema/manifest-schema.d.ts +121 -0
- package/dist/schema/manifest-schema.d.ts.map +1 -0
- package/dist/schema/manifest-schema.js +267 -0
- package/dist/schema/manifest-schema.js.map +1 -0
- package/dist/schema/schema-aggregator.d.ts +24 -10
- package/dist/schema/schema-aggregator.d.ts.map +1 -1
- package/dist/schema/schema-aggregator.js +35 -90
- package/dist/schema/schema-aggregator.js.map +1 -1
- package/dist/schema/system-table-shapes.d.ts +52 -0
- package/dist/schema/system-table-shapes.d.ts.map +1 -0
- package/dist/schema/system-table-shapes.js +174 -0
- package/dist/schema/system-table-shapes.js.map +1 -0
- package/dist/schema/types.d.ts +103 -4
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/utils.d.ts +1 -0
- package/dist/schema/utils.d.ts.map +1 -1
- package/dist/schema/utils.js +3 -1
- package/dist/schema/utils.js.map +1 -1
- package/dist/schema.js +4 -1
- package/dist/smrt-knowledge.json +18 -6
- package/dist/sync/apply.d.ts.map +1 -1
- package/dist/sync/apply.js +9 -16
- package/dist/sync/apply.js.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +1 -0
- package/dist/testing/database.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +71 -5
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +6 -4
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +5 -5
package/dist/collection.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
803
|
+
limitOffsetValues.push(boundedLimit);
|
|
623
804
|
}
|
|
624
|
-
if (
|
|
805
|
+
if (boundedOffset !== void 0) {
|
|
625
806
|
limitOffsetSql += ` OFFSET $${paramIndex++}`;
|
|
626
|
-
limitOffsetValues.push(
|
|
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];
|