@happyvertical/smrt-core 0.42.6 → 0.42.7

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 (48) hide show
  1. package/AGENTS.md +13 -41
  2. package/agents/collection-reads.md +40 -0
  3. package/agents/data-query.md +21 -0
  4. package/agents/latest-related.md +35 -0
  5. package/agents/memory.md +16 -0
  6. package/dist/browser.js +2 -2
  7. package/dist/class.d.ts +7 -0
  8. package/dist/class.d.ts.map +1 -1
  9. package/dist/class.js +9 -0
  10. package/dist/class.js.map +1 -1
  11. package/dist/collection.d.ts +164 -3
  12. package/dist/collection.d.ts.map +1 -1
  13. package/dist/collection.js +345 -10
  14. package/dist/collection.js.map +1 -1
  15. package/dist/generators/rest.d.ts.map +1 -1
  16. package/dist/generators/rest.js +10 -2
  17. package/dist/generators/rest.js.map +1 -1
  18. package/dist/generators/tool-schema.d.ts +14 -0
  19. package/dist/generators/tool-schema.d.ts.map +1 -1
  20. package/dist/generators/tool-schema.js +13 -1
  21. package/dist/generators/tool-schema.js.map +1 -1
  22. package/dist/index.js +2 -2
  23. package/dist/interceptors.d.ts +2 -1
  24. package/dist/interceptors.d.ts.map +1 -1
  25. package/dist/interceptors.js.map +1 -1
  26. package/dist/manifest/static-manifest.d.ts.map +1 -1
  27. package/dist/manifest/static-manifest.js +37 -1
  28. package/dist/manifest/static-manifest.js.map +1 -1
  29. package/dist/manifest/store.js +1 -1
  30. package/dist/manifest/store.js.map +1 -1
  31. package/dist/manifest.json +43 -1
  32. package/dist/prebuild/index.d.ts.map +1 -1
  33. package/dist/prebuild/index.js +9 -0
  34. package/dist/prebuild/index.js.map +1 -1
  35. package/dist/runtime/types.d.ts +2 -2
  36. package/dist/runtime/types.d.ts.map +1 -1
  37. package/dist/smrt-knowledge.json +50 -5
  38. package/dist/vite-plugin/index.d.ts.map +1 -1
  39. package/dist/vite-plugin/index.js +12 -3
  40. package/dist/vite-plugin/index.js.map +1 -1
  41. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  42. package/dist/vite-plugin/sveltekit-generator.js +24 -3
  43. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  44. package/dist/vite-plugin/web-collections.d.ts +3 -1
  45. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  46. package/dist/vite-plugin/web-collections.js +24 -4
  47. package/dist/vite-plugin/web-collections.js.map +1 -1
  48. package/package.json +4 -4
@@ -4,10 +4,11 @@ import { buildQueryCacheKey, ensureCacheInvalidationListener, getCacheGeneration
4
4
  import { toSafeInteger } from "./utils/safe-integer.js";
5
5
  import { isTenantScopedClassResolved, resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
6
6
  import { GlobalInterceptors, createInterceptorContext, resolveGetStringFilter } from "./interceptors.js";
7
+ import { detectEngine } from "./schema/ddl/index.js";
7
8
  import { SmrtClass } from "./class.js";
8
9
  import { EmbeddingProvider } from "./embeddings/provider.js";
9
10
  import { EmbeddingStorage } from "./embeddings/storage.js";
10
- import { QueryBoundsError, QueryOrderByError } from "./query-bounds.js";
11
+ import { MAX_LIST_LIMIT, QueryBoundsError, QueryOrderByError } from "./query-bounds.js";
11
12
  import { verifyPersistenceTable } from "./schema/table-verifier.js";
12
13
  import { fieldsFromClass, formatDataJs, toCamelCase } from "./utils.js";
13
14
  import { ObjectRegistry } from "./registry.js";
@@ -22,6 +23,12 @@ var logger = createLogger({ level: "info" });
22
23
  * see {@link SmrtCollection.resolveCollectionMemoryOwnerId} (#2365).
23
24
  */
24
25
  var COLLECTION_MEMORY_OWNER_ID = "__collection__";
26
+ /** Maximum number of independent facet fields accepted by one call. */
27
+ var MAX_FACET_FIELDS = 20;
28
+ /** Default number of distinct values returned for each facet field. */
29
+ var DEFAULT_FACET_LIMIT = 50;
30
+ /** Hard ceiling for distinct values returned for one facet field. */
31
+ var MAX_FACET_LIMIT = MAX_LIST_LIMIT;
25
32
  /**
26
33
  * Validate an optional collection-level list bound (#2367).
27
34
  *
@@ -66,6 +73,7 @@ function assertQueryBound(value, parameterName) {
66
73
  * @returns Modified WHERE clause with qualified _meta_type, or original if no resolution needed
67
74
  */
68
75
  function resolveMetaTypeInWhere(where) {
76
+ if (Array.isArray(where)) return where.map((group) => group.map((condition) => resolveMetaTypeInWhere(condition)));
69
77
  if (!where?._meta_type || typeof where._meta_type !== "string") return where;
70
78
  const metaTypeValue = where._meta_type;
71
79
  if (metaTypeValue.includes(":")) return where;
@@ -76,6 +84,14 @@ function resolveMetaTypeInWhere(where) {
76
84
  };
77
85
  return where;
78
86
  }
87
+ /** Add an AND predicate without collapsing a caller's DNF OR branches. */
88
+ function andWhereCondition(where, condition) {
89
+ if (Array.isArray(where)) return where.map((group) => [condition, ...group]);
90
+ return {
91
+ ...where ?? {},
92
+ ...condition
93
+ };
94
+ }
79
95
  /**
80
96
  * Field names that carry a stored embedding vector for a class (Issue #2281)
81
97
  *
@@ -298,6 +314,13 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
298
314
  * ```
299
315
  */
300
316
  convertWhereKeys(where) {
317
+ if (Array.isArray(where)) {
318
+ if (where.length === 0 || where.some((group) => !Array.isArray(group) || group.length === 0)) throw new Error("Invalid DNF where clause: each OR branch must contain at least one condition.");
319
+ return where.map((group) => group.map((condition) => this.convertWhereKeysRecord(condition)));
320
+ }
321
+ return this.convertWhereKeysRecord(where);
322
+ }
323
+ convertWhereKeysRecord(where) {
301
324
  const VALID_OPERATORS = [
302
325
  "=",
303
326
  ">",
@@ -389,6 +412,20 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
389
412
  hasCustomPrimaryKey(fields) {
390
413
  return Object.values(fields).some((fieldDef) => fieldDef.primaryKey === true || fieldDef._meta?.primaryKey === true);
391
414
  }
415
+ resolvePrimaryKeyField(fields, label) {
416
+ const declared = Object.entries(fields).filter(([, fieldDef]) => fieldDef.primaryKey === true || fieldDef._meta?.primaryKey === true).map(([field]) => field);
417
+ if (declared.length > 1) throw new Error(`${label} declares multiple primary-key fields (${declared.join(", ")}); listWithLatestRelated requires a single primary key.`);
418
+ const field = declared[0] ?? "id";
419
+ return {
420
+ field,
421
+ column: this.toDbColumnName(field)
422
+ };
423
+ }
424
+ getDatabaseEngine() {
425
+ const db = this.db;
426
+ const isDuckDbConnection = (db.client?.constructor?.name?.toLowerCase() ?? "").includes("duckdb") || typeof db.exportTable === "function" && db.client !== void 0 && "connection" in db.client;
427
+ return detectEngine(db.url || db.config?.url || "", this.getDatabaseEngineHint() || db.type || db.config?.type || (isDuckDbConnection ? "duckdb" : void 0));
428
+ }
392
429
  isOmittedCustomPrimaryKeySystemField(fieldName, hasCustomPrimaryKey, explicitFieldNames) {
393
430
  return hasCustomPrimaryKey && !explicitFieldNames.has(fieldName) && (fieldName === "id" || fieldName === "slug" || fieldName === "context");
394
431
  }
@@ -436,6 +473,210 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
436
473
  outputFields
437
474
  };
438
475
  }
476
+ /**
477
+ * Parse and validate order terms while retaining the resolved database
478
+ * column. The normal list order builder remains the source of truth for
479
+ * field, sensitive-field, and permission validation; this companion shape
480
+ * lets the latest-row CTE qualify the same safe identifiers with its alias.
481
+ */
482
+ resolveOrderByTerms(orderBy, fields, label) {
483
+ const validatedSql = this.buildOrderBySql(orderBy, fields);
484
+ const items = Array.isArray(orderBy) ? orderBy : [orderBy];
485
+ if (items.length === 0) throw new Error(`Invalid ${label}: at least one order field is required.`);
486
+ const validatedTerms = validatedSql.replace(/^ ORDER BY /, "").split(", ").map((term) => term.split(/\s+/));
487
+ return items.map((item, index) => {
488
+ const [field, direction = "ASC"] = String(item).trim().split(/\s+/);
489
+ if (!field) throw new Error(`Invalid ${label}: an order field is required.`);
490
+ const normalizedDirection = direction.toUpperCase();
491
+ if (normalizedDirection !== "ASC" && normalizedDirection !== "DESC") throw new Error(`Invalid ${label} direction: ${direction}. Must be ASC or DESC.`);
492
+ return {
493
+ column: validatedTerms[index]?.[0] ?? this.toDbColumnName(field),
494
+ direction: normalizedDirection,
495
+ field
496
+ };
497
+ });
498
+ }
499
+ qualifyLatestOrderTerms(alias, terms) {
500
+ return terms.flatMap((term) => {
501
+ const column = `${alias}.${this.quoteProjectionIdentifier(term.column)}`;
502
+ return [`CASE WHEN ${column} IS NULL THEN 1 ELSE 0 END ASC`, `${column} ${term.direction}`];
503
+ }).join(", ");
504
+ }
505
+ qualifyLatestParentOrderTerms(alias, terms) {
506
+ return terms.flatMap((term) => {
507
+ const column = `${alias}.${this.quoteProjectionIdentifier(term.column)}`;
508
+ return [`CASE WHEN ${column} IS NULL THEN 1 ELSE 0 END ASC`, `${column} ${term.direction}`];
509
+ }).join(", ");
510
+ }
511
+ resolveOneToManyInverseForeignKey(relationship) {
512
+ const inverseCandidates = ObjectRegistry.getInverseRelationshipsForSelf(this._itemClass.name).filter((candidate) => candidate.sourceClass === relationship.targetClass && candidate.type === "foreignKey");
513
+ const explicitForeignKey = relationship.options?.foreignKey;
514
+ const matchedForeignKey = explicitForeignKey ? inverseCandidates.find((candidate) => candidate.fieldName === explicitForeignKey) : void 0;
515
+ if (explicitForeignKey && !matchedForeignKey) throw new Error(`oneToMany ${relationship.fieldName} specifies foreignKey '${explicitForeignKey}', but ${relationship.targetClass} has no matching inverse foreignKey. Candidates: ${inverseCandidates.map((candidate) => candidate.fieldName).join(", ") || "(none)"}`);
516
+ const inverseForeignKey = matchedForeignKey ?? inverseCandidates.find((candidate) => candidate.targetClass === this._itemClass.name) ?? inverseCandidates[0];
517
+ if (!inverseForeignKey) throw new Error(`Could not find inverse foreignKey on ${relationship.targetClass} for oneToMany relationship ${relationship.fieldName}`);
518
+ return inverseForeignKey;
519
+ }
520
+ /**
521
+ * Load a page of hydrated parents with one selected latest row from a
522
+ * declared `@oneToMany` relation. The parent page is sliced only after the
523
+ * latest-row join and optional related sort are applied, so unrelated rows
524
+ * are never hydrated in application code.
525
+ *
526
+ * Missing related rows return `latestRelated: null`. Related order and sort
527
+ * fields place null values after non-null values consistently on all
528
+ * supported adapters. The related row is a plain projection and is never a
529
+ * full `SmrtObject` hydration.
530
+ *
531
+ * @example
532
+ * ```typescript
533
+ * const page = await opportunities.listWithLatestRelated({
534
+ * latestRelated: {
535
+ * relation: 'evaluations',
536
+ * orderBy: 'evaluatedAt DESC',
537
+ * select: ['score', 'evaluatedAt'],
538
+ * sortBy: 'score DESC',
539
+ * },
540
+ * limit: 25,
541
+ * offset: 0,
542
+ * });
543
+ * console.log(page[0].parent, page[0].latestRelated?.score);
544
+ * ```
545
+ */
546
+ async listWithLatestRelated(options) {
547
+ await this.ensureStorageReady();
548
+ const itemClassName = this.getResolvedItemClassName();
549
+ const itemQualifiedName = this.getResolvedItemQualifiedName();
550
+ const interceptorContext = createInterceptorContext(itemClassName, "list", this.constructor.name);
551
+ const interceptedOptions = await GlobalInterceptors.executeBeforeList(itemClassName, options, interceptorContext) ?? options;
552
+ let { where, offset, limit, orderBy } = interceptedOptions;
553
+ const latestOptions = interceptedOptions.latestRelated ?? options.latestRelated;
554
+ const isSTI = ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti";
555
+ if (isSTI) {
556
+ const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
557
+ if (stiBase && stiBase !== itemQualifiedName) where = {
558
+ _meta_type: itemQualifiedName,
559
+ ...where || {}
560
+ };
561
+ }
562
+ where = resolveMetaTypeInWhere(where);
563
+ const relationship = ObjectRegistry.getRelationships(this._itemClass.name).find((candidate) => candidate.fieldName === latestOptions.relation && candidate.type === "oneToMany");
564
+ if (!relationship) throw new Error(`latestRelated.relation '${latestOptions.relation}' is not a declared oneToMany relationship on ${itemClassName}.`);
565
+ const inverseForeignKey = this.resolveOneToManyInverseForeignKey(relationship);
566
+ const relatedCollection = await ObjectRegistry.getCollection(relationship.targetClass, this.options);
567
+ await relatedCollection.ensureStorageReady();
568
+ const relatedFields = relatedCollection.getFieldsSync();
569
+ const relatedItemClassName = relatedCollection.getResolvedItemClassName();
570
+ const relatedQualifiedName = relatedCollection.getResolvedItemQualifiedName();
571
+ const relatedIsSTI = ObjectRegistry.getTableStrategy(relatedQualifiedName) === "sti";
572
+ const relatedInterceptorContext = createInterceptorContext(relatedItemClassName, "list", relatedCollection.constructor.name);
573
+ let relatedWhere = (await GlobalInterceptors.executeBeforeList(relatedItemClassName, { where: {} }, relatedInterceptorContext) ?? { where: {} }).where;
574
+ if (relatedIsSTI) {
575
+ const relatedStiBase = ObjectRegistry.getSTIBase(relatedQualifiedName);
576
+ if (relatedStiBase && relatedStiBase !== relatedQualifiedName) relatedWhere = {
577
+ _meta_type: relatedQualifiedName,
578
+ ...relatedWhere || {}
579
+ };
580
+ }
581
+ relatedWhere = resolveMetaTypeInWhere(relatedWhere);
582
+ const { sql: relatedWhereSql, values: relatedWhereValues } = buildWhere(relatedCollection.convertWhereKeys(relatedWhere || {}));
583
+ const relatedPrimaryKey = relatedCollection.resolvePrimaryKeyField(relatedFields, `Related model ${relatedItemClassName}`);
584
+ const relatedSelect = latestOptions.select ?? [relatedPrimaryKey.field];
585
+ const relatedProjection = relatedCollection.resolveProjectionSelect(relatedSelect, relatedFields, relatedIsSTI);
586
+ const relatedOrderTerms = relatedCollection.resolveOrderByTerms(latestOptions.orderBy, relatedFields, "latestRelated.orderBy");
587
+ const relatedSortTerms = latestOptions.sortBy ? relatedCollection.resolveOrderByTerms(latestOptions.sortBy, relatedFields, "latestRelated.sortBy") : [];
588
+ const parentFields = this.getFieldsSync();
589
+ const parentPrimaryKey = this.resolvePrimaryKeyField(parentFields, `Parent model ${itemClassName}`);
590
+ const relatedOutputFields = new Set(relatedProjection.outputFields);
591
+ relatedOutputFields.add(relatedPrimaryKey.field);
592
+ relatedOutputFields.add(inverseForeignKey.fieldName);
593
+ if (parentFields.tenantId && relatedFields.tenantId) relatedOutputFields.add("tenantId");
594
+ for (const term of [...relatedOrderTerms, ...relatedSortTerms]) relatedOutputFields.add(term.field);
595
+ const relatedFieldColumns = new Map([...relatedOrderTerms, ...relatedSortTerms].map((term) => [term.field, term.column]));
596
+ const parentSchema = ObjectRegistry.getSchema(itemQualifiedName) ?? ObjectRegistry.getSchema(itemClassName);
597
+ const liveParentSchema = typeof this.db.getTableSchema === "function" ? await this.db.getTableSchema(this.tableName) : void 0;
598
+ const parentColumnNames = Object.keys(liveParentSchema?.columns ?? parentSchema?.columns ?? {});
599
+ const occupiedParentIdentifiers = /* @__PURE__ */ new Set([
600
+ ...Object.keys(parentFields),
601
+ ...Object.keys(parentFields).map((fieldName) => this.toDbColumnName(fieldName)),
602
+ ...parentColumnNames,
603
+ ...Object.keys(parentSchema?.columns ?? {})
604
+ ]);
605
+ const relatedFieldAliases = /* @__PURE__ */ new Map();
606
+ let nextAliasIndex = 0;
607
+ for (const fieldName of relatedOutputFields) {
608
+ let alias;
609
+ do
610
+ alias = `__smrt_lr_${nextAliasIndex++}`;
611
+ while (occupiedParentIdentifiers.has(alias) || [...relatedFieldAliases.values()].includes(alias));
612
+ relatedFieldAliases.set(fieldName, alias);
613
+ }
614
+ const parentSelectSql = parentColumnNames.length > 0 ? parentColumnNames.map((columnName) => this.quoteProjectionIdentifier(columnName)).join(", ") : "*";
615
+ const relatedAlias = (fieldName) => {
616
+ const alias = relatedFieldAliases.get(fieldName);
617
+ if (!alias) throw new Error(`Missing latest-related alias for '${fieldName}'.`);
618
+ return alias;
619
+ };
620
+ const relatedSelectExpressions = Array.from(relatedOutputFields).map((fieldName) => {
621
+ const column = relatedFieldColumns.get(fieldName) ?? relatedCollection.toDbColumnName(fieldName);
622
+ const alias = relatedAlias(fieldName);
623
+ return `${relatedCollection.quoteProjectionIdentifier(column)} AS ${relatedCollection.quoteProjectionIdentifier(alias)}`;
624
+ });
625
+ const relatedRankOrder = [relatedCollection.qualifyLatestOrderTerms("r", relatedOrderTerms), `r.${relatedCollection.quoteProjectionIdentifier(relatedPrimaryKey.column)} DESC`].filter(Boolean).join(", ");
626
+ const latestRankAlias = relatedCollection.quoteProjectionIdentifier("__smrt_latest_rank");
627
+ const latestRelatedAlias = (fieldName) => relatedCollection.quoteProjectionIdentifier(relatedAlias(fieldName));
628
+ const relatedCte = `WITH ranked_latest_related AS (SELECT ${relatedSelectExpressions.map((expression) => `r.${expression}`).join(", ")}, ROW_NUMBER() OVER (PARTITION BY r.${relatedCollection.quoteProjectionIdentifier(relatedCollection.toDbColumnName(inverseForeignKey.fieldName))} ORDER BY ${relatedRankOrder}) AS ${latestRankAlias} FROM ${relatedCollection.tableName} r${relatedWhereSql ? ` ${relatedWhereSql}` : ""}) `;
629
+ const { sql: rawWhereSql, values: parentWhereValues } = buildWhere(this.convertWhereKeys(where || {}));
630
+ const parentWhereSql = rawWhereSql.replace(/\$(\d+)/g, (_match, index) => `$${Number(index) + relatedWhereValues.length}`);
631
+ const relatedJoinConditions = [`rr.${latestRankAlias} = 1`, `rr.${latestRelatedAlias(inverseForeignKey.fieldName)} = ${this.quoteProjectionIdentifier(parentPrimaryKey.column)}`];
632
+ if (parentFields.tenantId && relatedFields.tenantId) relatedJoinConditions.push(`(rr.${latestRelatedAlias("tenantId")} = ${this.quoteProjectionIdentifier("tenant_id")} OR (rr.${latestRelatedAlias("tenantId")} IS NULL AND ${this.quoteProjectionIdentifier("tenant_id")} IS NULL))`);
633
+ const orderFragments = [];
634
+ if (relatedSortTerms.length > 0) orderFragments.push(relatedCollection.qualifyLatestParentOrderTerms("rr", relatedSortTerms.map((term) => ({
635
+ ...term,
636
+ column: relatedAlias(term.field)
637
+ }))));
638
+ if (orderBy) {
639
+ const parentOrderTerms = this.resolveOrderByTerms(orderBy, parentFields, "orderBy");
640
+ orderFragments.push(parentOrderTerms.map((term) => `${this.quoteProjectionIdentifier(term.column)} ${term.direction}`).join(", "));
641
+ }
642
+ orderFragments.push(`${this.quoteProjectionIdentifier(parentPrimaryKey.column)} ASC`);
643
+ const boundedLimit = this.applyListBounds(assertQueryBound(limit, "limit"));
644
+ const boundedOffset = assertQueryBound(offset, "offset");
645
+ let limitOffsetSql = "";
646
+ const limitOffsetValues = [];
647
+ let nextParameter = relatedWhereValues.length + parentWhereValues.length + 1;
648
+ if (boundedLimit !== void 0) {
649
+ limitOffsetSql += ` LIMIT $${nextParameter++}`;
650
+ limitOffsetValues.push(boundedLimit);
651
+ }
652
+ if (boundedOffset !== void 0) {
653
+ if (boundedLimit === void 0) limitOffsetSql += this.getDatabaseEngine() === "sqlite" ? " LIMIT -1" : " LIMIT ALL";
654
+ limitOffsetSql += ` OFFSET $${nextParameter++}`;
655
+ limitOffsetValues.push(boundedOffset);
656
+ }
657
+ const sql = `${relatedCte}SELECT ${parentSelectSql}, ${Array.from(relatedOutputFields).map((fieldName) => `rr.${latestRelatedAlias(fieldName)} AS ${this.quoteProjectionIdentifier(relatedAlias(fieldName))}`).join(", ")} FROM ${this.quoteProjectionIdentifier(this.tableName)} LEFT JOIN ranked_latest_related rr ON ${relatedJoinConditions.join(" AND ")} ${parentWhereSql} ${orderFragments.length > 0 ? `ORDER BY ${orderFragments.join(", ")}` : ""}${limitOffsetSql}`;
658
+ const rows = await this.db.query(sql, ...relatedWhereValues, ...parentWhereValues, ...limitOffsetValues);
659
+ const instances = [];
660
+ const relatedAliasNames = new Set(relatedFieldAliases.values());
661
+ for (const row of rows.rows) {
662
+ const parentRow = Object.fromEntries(Object.entries(row).filter(([key]) => !relatedAliasNames.has(key)));
663
+ instances.push(await this.hydrateResultRow(parentRow, parentFields, isSTI));
664
+ }
665
+ const relatedByParentId = /* @__PURE__ */ new Map();
666
+ for (const row of rows.rows) {
667
+ const parentId = row[parentPrimaryKey.column];
668
+ if (parentId === null || parentId === void 0) continue;
669
+ const relatedRow = Object.fromEntries(relatedProjection.outputFields.map((fieldName) => [fieldName, row[relatedAlias(fieldName)]]));
670
+ const relatedId = row[relatedAlias(relatedPrimaryKey.field)];
671
+ relatedByParentId.set(String(parentId), relatedId === null || relatedId === void 0 ? null : relatedCollection.formatProjectionRow(relatedRow, relatedFields, relatedProjection.outputFields));
672
+ }
673
+ return (await GlobalInterceptors.executeAfterList(itemClassName, instances, interceptorContext)).map((parent) => {
674
+ return {
675
+ latestRelated: parent[parentPrimaryKey.field] === null || parent[parentPrimaryKey.field] === void 0 ? null : relatedByParentId.get(String(parent[parentPrimaryKey.field])) ?? null,
676
+ parent
677
+ };
678
+ });
679
+ }
439
680
  formatProjectionRow(row, fields, outputFields) {
440
681
  const formattedData = this.withHydratedCoreFields(formatDataJs(row, fields));
441
682
  const projected = {};
@@ -788,10 +1029,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
788
1029
  const isSTI = ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti";
789
1030
  if (isSTI) {
790
1031
  const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
791
- if (stiBase && stiBase !== itemQualifiedName) where = {
792
- _meta_type: itemQualifiedName,
793
- ...where || {}
794
- };
1032
+ if (stiBase && stiBase !== itemQualifiedName) where = andWhereCondition(where, { _meta_type: itemQualifiedName });
795
1033
  }
796
1034
  where = resolveMetaTypeInWhere(where);
797
1035
  const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where || {}));
@@ -1360,16 +1598,113 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1360
1598
  let { where } = await GlobalInterceptors.executeBeforeList(itemClassName, options, interceptorContext) ?? options ?? {};
1361
1599
  if (ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti") {
1362
1600
  const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
1363
- if (stiBase && stiBase !== itemQualifiedName) where = {
1364
- _meta_type: itemQualifiedName,
1365
- ...where || {}
1366
- };
1601
+ if (stiBase && stiBase !== itemQualifiedName) where = andWhereCondition(where, { _meta_type: itemQualifiedName });
1367
1602
  }
1368
1603
  where = resolveMetaTypeInWhere(where);
1369
1604
  const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where || {}));
1370
1605
  return toSafeInteger((await this.db.query(`SELECT COUNT(*) as count FROM ${this.tableName} ${whereSql}`, ...whereValues)).rows[0].count, "Collection count");
1371
1606
  }
1372
1607
  /**
1608
+ * Return database-backed distinct values and row counts for one or more
1609
+ * column-backed fields.
1610
+ *
1611
+ * Each requested field is executed as a bounded `GROUP BY` query. Keeping
1612
+ * one query per field uses standard SQL while avoiding a full collection
1613
+ * read or model hydration. Scalar facet behavior is covered on SQLite and
1614
+ * DuckDB; optional PostgreSQL scalar coverage runs in the `test:postgres`
1615
+ * lane when `SMRT_TEST_POSTGRES_URL` is configured.
1616
+ * The same `beforeList` interceptors as `list()` and `count()` run first,
1617
+ * so tenancy and other read scopes are applied to every facet query.
1618
+ *
1619
+ * Facets group by the value stored in the column. SMRT does not split,
1620
+ * unnest, or otherwise interpret array/string-list fields; consumers that
1621
+ * need a facet per array member should maintain a scalar join table or use
1622
+ * a consumer-specific query. JSON fields are grouped by their stored JSON
1623
+ * value and decoded in the returned `value` when the field metadata allows
1624
+ * it. Array/JSON encoding behavior is adapter-specific and is covered by
1625
+ * the SQLite/DuckDB tests only; the optional PostgreSQL test covers scalar
1626
+ * grouping.
1627
+ *
1628
+ * @param options.fields One or more fields, optionally with per-field limits
1629
+ * @param options.where Filter conditions using the same syntax as `list()`
1630
+ * @returns One value/count list per requested field, in request order
1631
+ *
1632
+ * Limits are always bounded: an explicit per-field limit is clamped to
1633
+ * {@link MAX_FACET_LIMIT}, the collection's `maxListLimit` when configured,
1634
+ * and the lower of those ceilings. When omitted, the effective limit is the
1635
+ * collection's `defaultListLimit` or {@link DEFAULT_FACET_LIMIT}, subject to
1636
+ * the same ceilings. This keeps facet queries bounded even when collection
1637
+ * list bounds are unset.
1638
+ */
1639
+ async facets(options) {
1640
+ await this.ensureStorageReady();
1641
+ if (!options || !Array.isArray(options.fields)) throw new Error("Invalid facets option: fields must be an array.");
1642
+ if (options.fields.length === 0) throw new Error("Invalid facets option: at least one field is required.");
1643
+ if (options.fields.length > 20) throw new Error(`Invalid facets option: at most 20 fields are allowed.`);
1644
+ const itemClassName = this.getResolvedItemClassName();
1645
+ const itemQualifiedName = this.getResolvedItemQualifiedName();
1646
+ const interceptorContext = createInterceptorContext(itemClassName, "list", this.constructor.name);
1647
+ const interceptedOptions = await GlobalInterceptors.executeBeforeList(itemClassName, options, interceptorContext) ?? options ?? {};
1648
+ let { where } = interceptedOptions;
1649
+ const isSTI = ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti";
1650
+ if (isSTI) {
1651
+ const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
1652
+ if (stiBase && stiBase !== itemQualifiedName) where = andWhereCondition(where, { _meta_type: itemQualifiedName });
1653
+ }
1654
+ where = resolveMetaTypeInWhere(where);
1655
+ const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where || {}));
1656
+ const fields = this.getFieldsSync();
1657
+ const effectiveFields = interceptedOptions.fields ?? options.fields;
1658
+ if (!Array.isArray(effectiveFields) || effectiveFields.length === 0) throw new Error("Invalid facets option: at least one field is required after interception.");
1659
+ if (effectiveFields.length > 20) throw new Error(`Invalid facets option: at most 20 fields are allowed after interception.`);
1660
+ const requested = effectiveFields.map((entry) => typeof entry === "string" ? { field: entry } : entry);
1661
+ const seen = /* @__PURE__ */ new Set();
1662
+ const results = [];
1663
+ for (const request of requested) {
1664
+ if (!request || typeof request.field !== "string") throw new Error("Invalid facet field: expected a string or { field, limit }.");
1665
+ const field = request.field;
1666
+ if (seen.has(field)) throw new Error(`Invalid facet field: '${field}' was requested more than once.`);
1667
+ seen.add(field);
1668
+ this.resolveProjectionSelect([field], fields, isSTI);
1669
+ const columnName = this.toDbColumnName(field);
1670
+ const quotedColumn = this.quoteProjectionIdentifier(columnName);
1671
+ const quotedField = this.quoteProjectionIdentifier(field);
1672
+ const requestedLimit = request.limit === void 0 ? this._defaultListLimit ?? DEFAULT_FACET_LIMIT : assertQueryBound(request.limit, `facet limit for '${field}'`);
1673
+ const collectionLimit = this._maxListLimit ?? MAX_FACET_LIMIT;
1674
+ const limit = Math.min(requestedLimit ?? DEFAULT_FACET_LIMIT, collectionLimit, MAX_FACET_LIMIT);
1675
+ const sql = `SELECT ${quotedColumn} AS ${quotedField}, COUNT(*) AS "__smrt_facet_count" FROM ${this.tableName} ${whereSql} GROUP BY ${quotedColumn} ORDER BY "__smrt_facet_count" DESC, CASE WHEN ${quotedColumn} IS NULL THEN 1 ELSE 0 END ASC, ${quotedColumn} ASC LIMIT $${whereValues.length + 1}`;
1676
+ const params = [...whereValues, limit];
1677
+ const queryResult = await this.db.query(sql, ...params);
1678
+ results.push({
1679
+ field,
1680
+ values: queryResult.rows.map((row) => {
1681
+ const rawValue = row[field];
1682
+ const formatted = formatDataJs({ [columnName]: rawValue }, fields);
1683
+ const formattedKey = Object.hasOwn(formatted, field) ? field : toCamelCase(field);
1684
+ return {
1685
+ value: Object.hasOwn(formatted, formattedKey) ? formatted[formattedKey] : rawValue,
1686
+ count: toSafeInteger(row.__smrt_facet_count, `Facet count for '${field}'`)
1687
+ };
1688
+ })
1689
+ });
1690
+ }
1691
+ return results;
1692
+ }
1693
+ /**
1694
+ * Return both the tenant/read-scoped total and a filtered count.
1695
+ *
1696
+ * This intentionally issues two `COUNT(*)` queries rather than reading
1697
+ * rows into application memory. `count()` applies `beforeList` for each
1698
+ * query, so the unfiltered total remains tenant-scoped while the filtered
1699
+ * count adds the caller's `where` conditions.
1700
+ */
1701
+ async counts(options = {}) {
1702
+ return {
1703
+ total: await this.count(),
1704
+ filtered: await this.count(options)
1705
+ };
1706
+ }
1707
+ /**
1373
1708
  * Resolve this collection's STI child discriminator — the qualified item
1374
1709
  * class name to use as a `_meta_type` scope — when the collection's item is
1375
1710
  * an STI **child** (shares a table with sibling subtypes), or `null` for STI
@@ -1971,6 +2306,6 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1971
2306
  }
1972
2307
  };
1973
2308
  //#endregion
1974
- export { SmrtCollection };
2309
+ export { DEFAULT_FACET_LIMIT, MAX_FACET_FIELDS, MAX_FACET_LIMIT, SmrtCollection };
1975
2310
 
1976
2311
  //# sourceMappingURL=collection.js.map