@open-mercato/shared 0.6.8-develop.7030.1.b84302d973 → 0.6.8-develop.7032.1.70d74d4bfe

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.
@@ -8,7 +8,10 @@ function createPagedListResponseSchema(itemSchema, options = {}) {
8
8
  total: z.number(),
9
9
  page: paginationMetaOptional ? z.number().optional() : z.number(),
10
10
  pageSize: paginationMetaOptional ? z.number().optional() : z.number(),
11
- totalPages: z.number()
11
+ totalPages: z.number(),
12
+ // Present (true) only when the list count was bounded at OM_LIST_COUNT_CAP:
13
+ // `total` is then a floor, not an exact value.
14
+ totalIsCapped: z.boolean().optional()
12
15
  });
13
16
  }
14
17
  function withIdsQueryParam(schema) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/openapi/crud.ts"],
4
- "sourcesContent": ["import { z, type ZodTypeAny } from 'zod'\nimport type { OpenApiResponseDoc, OpenApiRouteDoc } from './types'\n\nexport const defaultCreateResponseSchema = z.object({ id: z.string().uuid().nullable() })\nexport const defaultOkResponseSchema = z.object({ ok: z.literal(true) })\n\nexport type PagedListResponseOptions = {\n paginationMetaOptional?: boolean\n}\n\nexport function createPagedListResponseSchema(itemSchema: ZodTypeAny, options: PagedListResponseOptions = {}) {\n const paginationMetaOptional = options.paginationMetaOptional ?? false\n\n return z.object({\n items: z.array(itemSchema),\n total: z.number(),\n page: paginationMetaOptional ? z.number().optional() : z.number(),\n pageSize: paginationMetaOptional ? z.number().optional() : z.number(),\n totalPages: z.number(),\n })\n}\n\ntype CrudMethodConfig = {\n schema: ZodTypeAny\n description?: string\n responseSchema?: ZodTypeAny\n}\n\ntype CrudCreateConfig = CrudMethodConfig & {\n status?: number\n}\n\ntype CrudDeleteConfig = {\n schema?: ZodTypeAny\n description?: string\n responseSchema?: ZodTypeAny\n errors?: OpenApiResponseDoc[]\n}\n\nexport type CrudOpenApiOptions = {\n tag?: string\n resourceName: string\n pluralName?: string\n description?: string\n querySchema?: ZodTypeAny\n listResponseSchema: ZodTypeAny\n create?: CrudCreateConfig\n update?: CrudMethodConfig\n del?: CrudDeleteConfig\n}\n\nexport type CrudTextContext = {\n resourceName: string\n resourceLower: string\n pluralName: string\n pluralLower: string\n}\n\nexport type CrudOpenApiFactoryConfig = {\n defaultTag: string\n defaultCreateResponseSchema?: ZodTypeAny\n defaultOkResponseSchema?: ZodTypeAny\n makeListDescription?: (ctx: CrudTextContext) => string\n makeCreateDescription?: (ctx: CrudTextContext) => string\n makeCreateRequestBodyDescription?: (ctx: CrudTextContext) => string\n makeUpdateDescription?: (ctx: CrudTextContext) => string\n makeUpdateRequestBodyDescription?: (ctx: CrudTextContext) => string\n makeDeleteDescription?: (ctx: CrudTextContext) => string\n makeDeleteRequestBodyDescription?: (ctx: CrudTextContext) => string\n}\n\nfunction withIdsQueryParam(schema: ZodTypeAny | undefined): ZodTypeAny | undefined {\n if (!schema) return schema\n if (!(schema instanceof z.ZodObject)) return schema\n return schema.extend({\n ids: z\n .string()\n .optional()\n .describe('Comma-separated list of record UUIDs to filter by (max 200).'),\n })\n}\n\nfunction resolveDefault(\n factory: ((ctx: CrudTextContext) => string) | undefined,\n ctx: CrudTextContext,\n fallback: string,\n) {\n if (typeof factory === 'function') return factory(ctx)\n return fallback\n}\n\nexport function createCrudOpenApiFactory(config: CrudOpenApiFactoryConfig) {\n return function createCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {\n const {\n resourceName,\n pluralName,\n tag,\n description,\n querySchema,\n listResponseSchema,\n create,\n update,\n del,\n } = options\n\n const plural = pluralName ?? `${resourceName}s`\n const resourceLower = resourceName.toLowerCase()\n const pluralLower = plural.toLowerCase()\n const context: CrudTextContext = {\n resourceName,\n resourceLower,\n pluralName: plural,\n pluralLower,\n }\n\n const fallbackCreateResponseSchema = config.defaultCreateResponseSchema ?? defaultCreateResponseSchema\n const fallbackOkResponseSchema = config.defaultOkResponseSchema ?? defaultOkResponseSchema\n\n const methods: NonNullable<OpenApiRouteDoc['methods']> = {}\n\n methods.GET = {\n summary: `List ${pluralLower}`,\n description:\n description ?? resolveDefault(config.makeListDescription, context, `Returns a paginated collection of ${pluralLower}.`),\n query: withIdsQueryParam(querySchema),\n responses: [\n {\n status: 200,\n description: `Paginated ${pluralLower}`,\n schema: listResponseSchema,\n },\n ],\n }\n\n if (create) {\n const createDescription =\n create.description ??\n resolveDefault(config.makeCreateDescription, context, `Creates a new ${resourceLower}.`)\n\n const createBodyDescription =\n resolveDefault(\n config.makeCreateRequestBodyDescription,\n context,\n create.description ?? `Payload describing the ${resourceLower} to create.`,\n )\n\n methods.POST = {\n summary: `Create ${resourceLower}`,\n description: createDescription,\n requestBody: {\n schema: create.schema,\n description: createBodyDescription,\n },\n responses: [\n {\n status: create.status ?? 201,\n description: `${resourceName} created`,\n schema: create.responseSchema ?? fallbackCreateResponseSchema,\n },\n ],\n }\n }\n\n if (update) {\n const updateDescription =\n update.description ??\n resolveDefault(config.makeUpdateDescription, context, `Updates an existing ${resourceLower} by id.`)\n\n const updateBodyDescription =\n resolveDefault(\n config.makeUpdateRequestBodyDescription,\n context,\n update.description ?? `Fields to update on the ${resourceLower}.`,\n )\n\n methods.PUT = {\n summary: `Update ${resourceLower}`,\n description: updateDescription,\n requestBody: {\n schema: update.schema,\n description: updateBodyDescription,\n },\n responses: [\n {\n status: 200,\n description: `${resourceName} updated`,\n schema: update.responseSchema ?? fallbackOkResponseSchema,\n },\n ],\n }\n }\n\n if (del) {\n const deleteDescription =\n del.description ??\n resolveDefault(config.makeDeleteDescription, context, `Deletes a ${resourceLower} identified by id.`)\n\n const deleteBodyDescription =\n resolveDefault(\n config.makeDeleteRequestBodyDescription,\n context,\n del.description ?? 'Identifier payload.',\n )\n\n methods.DELETE = {\n summary: `Delete ${resourceLower}`,\n description: deleteDescription,\n requestBody: del.schema\n ? {\n schema: del.schema,\n description: deleteBodyDescription,\n }\n : undefined,\n responses: [\n {\n status: 200,\n description: `${resourceName} deleted`,\n schema: del.responseSchema ?? fallbackOkResponseSchema,\n },\n ],\n ...(del.errors && del.errors.length > 0 ? { errors: del.errors } : {}),\n }\n }\n\n return {\n tag: tag ?? config.defaultTag,\n summary: `${resourceName} management`,\n methods,\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { z, type ZodTypeAny } from 'zod'\nimport type { OpenApiResponseDoc, OpenApiRouteDoc } from './types'\n\nexport const defaultCreateResponseSchema = z.object({ id: z.string().uuid().nullable() })\nexport const defaultOkResponseSchema = z.object({ ok: z.literal(true) })\n\nexport type PagedListResponseOptions = {\n paginationMetaOptional?: boolean\n}\n\nexport function createPagedListResponseSchema(itemSchema: ZodTypeAny, options: PagedListResponseOptions = {}) {\n const paginationMetaOptional = options.paginationMetaOptional ?? false\n\n return z.object({\n items: z.array(itemSchema),\n total: z.number(),\n page: paginationMetaOptional ? z.number().optional() : z.number(),\n pageSize: paginationMetaOptional ? z.number().optional() : z.number(),\n totalPages: z.number(),\n // Present (true) only when the list count was bounded at OM_LIST_COUNT_CAP:\n // `total` is then a floor, not an exact value.\n totalIsCapped: z.boolean().optional(),\n })\n}\n\ntype CrudMethodConfig = {\n schema: ZodTypeAny\n description?: string\n responseSchema?: ZodTypeAny\n}\n\ntype CrudCreateConfig = CrudMethodConfig & {\n status?: number\n}\n\ntype CrudDeleteConfig = {\n schema?: ZodTypeAny\n description?: string\n responseSchema?: ZodTypeAny\n errors?: OpenApiResponseDoc[]\n}\n\nexport type CrudOpenApiOptions = {\n tag?: string\n resourceName: string\n pluralName?: string\n description?: string\n querySchema?: ZodTypeAny\n listResponseSchema: ZodTypeAny\n create?: CrudCreateConfig\n update?: CrudMethodConfig\n del?: CrudDeleteConfig\n}\n\nexport type CrudTextContext = {\n resourceName: string\n resourceLower: string\n pluralName: string\n pluralLower: string\n}\n\nexport type CrudOpenApiFactoryConfig = {\n defaultTag: string\n defaultCreateResponseSchema?: ZodTypeAny\n defaultOkResponseSchema?: ZodTypeAny\n makeListDescription?: (ctx: CrudTextContext) => string\n makeCreateDescription?: (ctx: CrudTextContext) => string\n makeCreateRequestBodyDescription?: (ctx: CrudTextContext) => string\n makeUpdateDescription?: (ctx: CrudTextContext) => string\n makeUpdateRequestBodyDescription?: (ctx: CrudTextContext) => string\n makeDeleteDescription?: (ctx: CrudTextContext) => string\n makeDeleteRequestBodyDescription?: (ctx: CrudTextContext) => string\n}\n\nfunction withIdsQueryParam(schema: ZodTypeAny | undefined): ZodTypeAny | undefined {\n if (!schema) return schema\n if (!(schema instanceof z.ZodObject)) return schema\n return schema.extend({\n ids: z\n .string()\n .optional()\n .describe('Comma-separated list of record UUIDs to filter by (max 200).'),\n })\n}\n\nfunction resolveDefault(\n factory: ((ctx: CrudTextContext) => string) | undefined,\n ctx: CrudTextContext,\n fallback: string,\n) {\n if (typeof factory === 'function') return factory(ctx)\n return fallback\n}\n\nexport function createCrudOpenApiFactory(config: CrudOpenApiFactoryConfig) {\n return function createCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {\n const {\n resourceName,\n pluralName,\n tag,\n description,\n querySchema,\n listResponseSchema,\n create,\n update,\n del,\n } = options\n\n const plural = pluralName ?? `${resourceName}s`\n const resourceLower = resourceName.toLowerCase()\n const pluralLower = plural.toLowerCase()\n const context: CrudTextContext = {\n resourceName,\n resourceLower,\n pluralName: plural,\n pluralLower,\n }\n\n const fallbackCreateResponseSchema = config.defaultCreateResponseSchema ?? defaultCreateResponseSchema\n const fallbackOkResponseSchema = config.defaultOkResponseSchema ?? defaultOkResponseSchema\n\n const methods: NonNullable<OpenApiRouteDoc['methods']> = {}\n\n methods.GET = {\n summary: `List ${pluralLower}`,\n description:\n description ?? resolveDefault(config.makeListDescription, context, `Returns a paginated collection of ${pluralLower}.`),\n query: withIdsQueryParam(querySchema),\n responses: [\n {\n status: 200,\n description: `Paginated ${pluralLower}`,\n schema: listResponseSchema,\n },\n ],\n }\n\n if (create) {\n const createDescription =\n create.description ??\n resolveDefault(config.makeCreateDescription, context, `Creates a new ${resourceLower}.`)\n\n const createBodyDescription =\n resolveDefault(\n config.makeCreateRequestBodyDescription,\n context,\n create.description ?? `Payload describing the ${resourceLower} to create.`,\n )\n\n methods.POST = {\n summary: `Create ${resourceLower}`,\n description: createDescription,\n requestBody: {\n schema: create.schema,\n description: createBodyDescription,\n },\n responses: [\n {\n status: create.status ?? 201,\n description: `${resourceName} created`,\n schema: create.responseSchema ?? fallbackCreateResponseSchema,\n },\n ],\n }\n }\n\n if (update) {\n const updateDescription =\n update.description ??\n resolveDefault(config.makeUpdateDescription, context, `Updates an existing ${resourceLower} by id.`)\n\n const updateBodyDescription =\n resolveDefault(\n config.makeUpdateRequestBodyDescription,\n context,\n update.description ?? `Fields to update on the ${resourceLower}.`,\n )\n\n methods.PUT = {\n summary: `Update ${resourceLower}`,\n description: updateDescription,\n requestBody: {\n schema: update.schema,\n description: updateBodyDescription,\n },\n responses: [\n {\n status: 200,\n description: `${resourceName} updated`,\n schema: update.responseSchema ?? fallbackOkResponseSchema,\n },\n ],\n }\n }\n\n if (del) {\n const deleteDescription =\n del.description ??\n resolveDefault(config.makeDeleteDescription, context, `Deletes a ${resourceLower} identified by id.`)\n\n const deleteBodyDescription =\n resolveDefault(\n config.makeDeleteRequestBodyDescription,\n context,\n del.description ?? 'Identifier payload.',\n )\n\n methods.DELETE = {\n summary: `Delete ${resourceLower}`,\n description: deleteDescription,\n requestBody: del.schema\n ? {\n schema: del.schema,\n description: deleteBodyDescription,\n }\n : undefined,\n responses: [\n {\n status: 200,\n description: `${resourceName} deleted`,\n schema: del.responseSchema ?? fallbackOkResponseSchema,\n },\n ],\n ...(del.errors && del.errors.length > 0 ? { errors: del.errors } : {}),\n }\n }\n\n return {\n tag: tag ?? config.defaultTag,\n summary: `${resourceName} management`,\n methods,\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,11 @@
1
+ import { parseNumberWithDefault } from "../number.js";
2
+ const DEFAULT_LIST_COUNT_CAP = 1e4;
3
+ function resolveListCountCap() {
4
+ const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true });
5
+ return parsed <= 0 ? null : parsed;
6
+ }
7
+ export {
8
+ DEFAULT_LIST_COUNT_CAP,
9
+ resolveListCountCap
10
+ };
11
+ //# sourceMappingURL=count-cap.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/query/count-cap.ts"],
4
+ "sourcesContent": ["import { parseNumberWithDefault } from '../number'\n\nexport const DEFAULT_LIST_COUNT_CAP = 10_000\n\n/**\n * Cap on how many matching rows a list COUNT may visit before reporting\n * `total: cap` with `meta.listCountCapWarning` (surfaced to clients as\n * `totalIsCapped`). Returns the cap as a number, or `null` when capping is\n * disabled.\n *\n * Resolution of `OM_LIST_COUNT_CAP`:\n * - unset / blank / unparseable \u2192 `DEFAULT_LIST_COUNT_CAP` (the cap is on by\n * default, and bad input must not silently disable it)\n * - `0` (or negative) \u2192 `null` \u2014 capping disabled, exact counts everywhere\n */\nexport function resolveListCountCap(): number | null {\n const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true })\n return parsed <= 0 ? null : parsed\n}\n"],
5
+ "mappings": "AAAA,SAAS,8BAA8B;AAEhC,MAAM,yBAAyB;AAa/B,SAAS,sBAAqC;AACnD,QAAM,SAAS,uBAAuB,QAAQ,IAAI,mBAAmB,wBAAwB,EAAE,SAAS,KAAK,CAAC;AAC9G,SAAO,UAAU,IAAI,OAAO;AAC9B;",
6
+ "names": []
7
+ }
@@ -20,6 +20,7 @@ import {
20
20
  } from "../crud/custom-field-definition-index.js";
21
21
  import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
22
22
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
23
+ import { resolveListCountCap } from "./count-cap.js";
23
24
  import { mapWithConcurrency } from "./bounded-decrypt.js";
24
25
  import { createLogger } from "../logger/index.js";
25
26
  const logger = createLogger("shared").child({ component: "query" });
@@ -440,6 +441,7 @@ class BasicQueryEngine {
440
441
  const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
441
442
  const buildQuery = async (projection) => {
442
443
  const isSortKeysProjection = projection === "sortKeys";
444
+ const isCountProjection = projection === "count";
443
445
  let q = db.selectFrom(table);
444
446
  if (!skipAutoScope && orgScope && await this.columnExists(table, "organization_id")) {
445
447
  q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
@@ -513,7 +515,8 @@ class BasicQueryEngine {
513
515
  applyJoinFilterOp,
514
516
  columnExists: (tbl, column) => this.columnExists(tbl, column)
515
517
  });
516
- if (isSortKeysProjection) {
518
+ if (isCountProjection) {
519
+ } else if (isSortKeysProjection) {
517
520
  q = q.select(sql.ref(qualify("id")).as("id"));
518
521
  if (await this.columnExists(table, "tenant_id")) {
519
522
  q = q.select(sql.ref(qualify("tenant_id")).as("tenant_id"));
@@ -535,18 +538,18 @@ class BasicQueryEngine {
535
538
  } else {
536
539
  q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
537
540
  }
538
- const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify);
541
+ const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection);
539
542
  q = cfSourcesResult.builder;
540
543
  const cfSources = cfSourcesResult.sources;
541
544
  const entityIdToSource = /* @__PURE__ */ new Map();
542
545
  for (const source of cfSources) {
543
546
  entityIdToSource.set(String(source.entityId), source);
544
547
  }
545
- const requestedCustomFieldKeys = !isSortKeysProjection && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
548
+ const requestedCustomFieldKeys = projection === "full" && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
546
549
  const cfKeys = /* @__PURE__ */ new Set();
547
550
  const keySource = /* @__PURE__ */ new Map();
548
551
  let resolvedCustomFieldDefinitions2;
549
- if (!isSortKeysProjection) {
552
+ if (projection === "full") {
550
553
  for (const f of opts.fields || []) {
551
554
  if (typeof f === "string" && f.startsWith("cf:")) cfKeys.add(f.slice(3));
552
555
  }
@@ -554,7 +557,7 @@ class BasicQueryEngine {
554
557
  for (const f of cfFilters) {
555
558
  if (typeof f.field === "string" && f.field.startsWith("cf:")) cfKeys.add(f.field.slice(3));
556
559
  }
557
- if (!isSortKeysProjection && opts.includeCustomFields === true) {
560
+ if (projection === "full" && opts.includeCustomFields === true) {
558
561
  if (entityIdToSource.size > 0) {
559
562
  const entityIdList = Array.from(entityIdToSource.keys());
560
563
  const entityOrder = /* @__PURE__ */ new Map();
@@ -653,6 +656,7 @@ class BasicQueryEngine {
653
656
  for (const key of cfKeys) {
654
657
  const source = keySource.get(key);
655
658
  if (!source) continue;
659
+ if (isCountProjection) continue;
656
660
  const entityIdForKey = source.entityId;
657
661
  const recordIdExpr = source.recordIdExpr;
658
662
  const sourceAliasSafe = sanitize(source.alias || "src");
@@ -700,8 +704,10 @@ class BasicQueryEngine {
700
704
  for (const f of regularCfFilters) {
701
705
  if (!f.field.startsWith("cf:")) continue;
702
706
  const key = f.field.slice(3);
707
+ const filterSource = keySource.get(key);
708
+ if (!filterSource) continue;
703
709
  const expr = cfValueExprByKey[key];
704
- if (!expr) continue;
710
+ if (!isCountProjection && !expr) continue;
705
711
  if ((f.op === "like" || f.op === "ilike") && searchActive && typeof f.value === "string") {
706
712
  const tokens = tokenizeText(String(f.value), searchConfig);
707
713
  const hashes = tokens.hashes;
@@ -736,15 +742,39 @@ class BasicQueryEngine {
736
742
  });
737
743
  }
738
744
  }
745
+ if (isCountProjection) {
746
+ q = this.applyCfValueExistsFilter(q, {
747
+ source: filterSource,
748
+ qualify,
749
+ tenantId: tenantId ?? null,
750
+ key,
751
+ op: f.op,
752
+ value: f.value
753
+ });
754
+ continue;
755
+ }
739
756
  q = this.applyColumnOp(q, expr, f.op, f.value);
740
757
  }
741
- const applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || Boolean(cfValueExprByKey[rf.key]))).filter((group) => group.length > 0);
758
+ const cfLeafApplicable = (key) => isCountProjection ? keySource.has(key) : Boolean(cfValueExprByKey[key]);
759
+ const applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || cfLeafApplicable(rf.key))).filter((group) => group.length > 0);
742
760
  if (applicableGroupFilters.length > 0) {
743
761
  q = q.where((eb) => {
744
762
  const disjuncts = applicableGroupFilters.map((group) => {
745
763
  const parts = group.map((rf) => {
746
764
  if (rf.kind === "column") return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value);
747
- if (rf.kind === "cf") return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
765
+ if (rf.kind === "cf") {
766
+ if (isCountProjection) {
767
+ return this.buildCfValueExistsExpression(eb, {
768
+ source: keySource.get(rf.key),
769
+ qualify,
770
+ tenantId: tenantId ?? null,
771
+ key: rf.key,
772
+ op: rf.op,
773
+ value: rf.value
774
+ });
775
+ }
776
+ return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
777
+ }
748
778
  return this.buildIndexDocOpExpression(eb, {
749
779
  entity: String(entity),
750
780
  field: rf.field,
@@ -761,7 +791,7 @@ class BasicQueryEngine {
761
791
  return disjuncts.length === 1 ? disjuncts[0] : eb.or(disjuncts);
762
792
  });
763
793
  }
764
- if (opts.includeExtensions) {
794
+ if (opts.includeExtensions && !isCountProjection) {
765
795
  const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
766
796
  const allMods = getModules();
767
797
  const allExts = allMods.flatMap((m) => m.entityExtensions || []);
@@ -778,7 +808,7 @@ class BasicQueryEngine {
778
808
  );
779
809
  }
780
810
  }
781
- for (const s of resolvedSorts) {
811
+ for (const s of isCountProjection ? [] : resolvedSorts) {
782
812
  if (s.field.startsWith("cf:")) {
783
813
  const key = s.field.slice(3);
784
814
  const alias = sanitize(`cf:${key}`);
@@ -794,7 +824,7 @@ class BasicQueryEngine {
794
824
  if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
795
825
  }
796
826
  }
797
- const hasJoinedAggregates2 = opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0;
827
+ const hasJoinedAggregates2 = !isCountProjection && (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0);
798
828
  if (hasJoinedAggregates2) {
799
829
  q = q.groupBy(`${table}.id`);
800
830
  }
@@ -809,11 +839,24 @@ class BasicQueryEngine {
809
839
  cfMultiAliasByAlias,
810
840
  resolvedCustomFieldDefinitions
811
841
  } = await buildQuery("full");
812
- const mayMultiplyBaseRows = hasJoinedAggregates || Array.isArray(opts.joins) && opts.joins.length > 0 || Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0;
813
- const countExpr = mayMultiplyBaseRows ? sql`count(distinct ${sql.ref(`${table}.id`)})` : sql`count(*)`;
814
- const countBuilder = hasJoinedAggregates ? qFull.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as("count")) : qFull.clearSelect().clearOrderBy().select(countExpr.as("count"));
815
- const countRow = await countBuilder.executeTakeFirst();
816
- const total = Number(countRow?.count ?? 0);
842
+ const countCap = resolveListCountCap();
843
+ const { builder: countShape } = await buildQuery("count");
844
+ let total;
845
+ let listCountCapWarning;
846
+ if (countCap !== null) {
847
+ const probe = countShape.select(sql`1`.as("one")).limit(countCap + 1);
848
+ const countRow = await db.selectFrom(probe.as("om_count_probe")).select(sql`count(*)`.as("count")).executeTakeFirst();
849
+ const probed = Number(countRow?.count ?? 0);
850
+ if (probed > countCap) {
851
+ total = countCap;
852
+ listCountCapWarning = { entity, cap: countCap };
853
+ } else {
854
+ total = probed;
855
+ }
856
+ } else {
857
+ const countRow = await countShape.select(sql`count(*)`.as("count")).executeTakeFirst();
858
+ total = Number(countRow?.count ?? 0);
859
+ }
817
860
  const svc = encryptionService;
818
861
  const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
819
862
  const decryptRow = async (item) => {
@@ -862,13 +905,15 @@ class BasicQueryEngine {
862
905
  const cap = resolveEncryptedSortMaxRows();
863
906
  let qSort = (await buildQuery("sortKeys")).builder;
864
907
  if (cap !== null) {
865
- qSort = qSort.limit(cap).orderBy(qualify("id"), "asc");
908
+ qSort = qSort.limit(cap + 1).orderBy(qualify("id"), "asc");
866
909
  }
867
- const candidateRows = await qSort.execute();
910
+ const candidateRowsRaw = await qSort.execute();
911
+ const sortTruncated = cap !== null && candidateRowsRaw.length > cap;
912
+ const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw;
868
913
  const decryptedCandidates = decryptPayload ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow) : candidateRows;
869
914
  const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts);
870
915
  const pageIds = orderedCandidates.slice((page - 1) * pageSize, page * pageSize).map((row) => row.id);
871
- if (cap !== null && total > cap) {
916
+ if (sortTruncated && cap !== null) {
872
917
  encryptedSortRowCapWarning = {
873
918
  entity,
874
919
  sortFields: resolvedSorts.map((s) => s.field),
@@ -892,8 +937,10 @@ class BasicQueryEngine {
892
937
  pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
893
938
  }
894
939
  let queryResult = { items: pagedItems, page, pageSize, total };
895
- if (encryptedSortRowCapWarning) {
896
- const meta = { encryptedSortRowCapWarning };
940
+ if (encryptedSortRowCapWarning || listCountCapWarning) {
941
+ const meta = {};
942
+ if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning;
943
+ if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning;
897
944
  queryResult.meta = meta;
898
945
  }
899
946
  if (ext && extensionCtx) {
@@ -938,6 +985,101 @@ class BasicQueryEngine {
938
985
  return builder;
939
986
  }
940
987
  }
988
+ /**
989
+ * Apply a `cf:*` filter as a correlated EXISTS semi-join over
990
+ * `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
991
+ * the count shape's equivalent of the projection path's leftJoin + WHERE.
992
+ * A semi-join returns each base row at most once, so the count query needs
993
+ * no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
994
+ *
995
+ * Predicates satisfied by the *absence* of a value row (`eq null`,
996
+ * `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
997
+ * matching the leftJoin form where a missing row yields a NULL expression.
998
+ */
999
+ applyCfValueExistsFilter(q, opts) {
1000
+ return q.where((eb) => this.buildCfValueExistsExpression(eb, opts));
1001
+ }
1002
+ /**
1003
+ * Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
1004
+ * inside an OR group can compile to an EXISTS predicate on the count shape
1005
+ * instead of being dropped for lacking a `cfValueExprByKey` entry.
1006
+ */
1007
+ buildCfValueExistsExpression(eb, opts) {
1008
+ const { source, qualify, tenantId, key, op, value } = opts;
1009
+ const seq = this.searchAliasSeq++;
1010
+ const valAlias = `cfev_${seq}`;
1011
+ const defAlias = `cfed_${seq}`;
1012
+ const srcAlias = `cfes_${seq}`;
1013
+ const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
1014
+ WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
1015
+ WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
1016
+ WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
1017
+ WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
1018
+ ELSE (${sql.ref(`${valAlias}.value_text`)})::text
1019
+ END`;
1020
+ const buildSub = (eb2) => {
1021
+ let sub = eb2.selectFrom(`custom_field_values as ${valAlias}`).select(sql`1`.as("one")).leftJoin(`custom_field_defs as ${defAlias}`, (jb) => jb.on(`${defAlias}.entity_id`, "=", String(source.entityId)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((jeb) => jeb.or([
1022
+ jeb(`${defAlias}.tenant_id`, "=", tenantId),
1023
+ jeb(`${defAlias}.tenant_id`, "is", null)
1024
+ ]))).where(`${valAlias}.entity_id`, "=", String(source.entityId)).where(`${valAlias}.field_key`, "=", key).where((web) => web.or([
1025
+ web(`${valAlias}.tenant_id`, "=", tenantId),
1026
+ web(`${valAlias}.tenant_id`, "is", null)
1027
+ ]));
1028
+ if (source.hop) {
1029
+ sub = sub.innerJoin(`${source.table} as ${srcAlias}`, (jb) => jb.on(sql`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop.recordIdColumn}`)})::text`)).whereRef(`${srcAlias}.${source.hop.toField}`, "=", qualify(source.hop.fromField));
1030
+ } else {
1031
+ sub = sub.where(sql`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`);
1032
+ }
1033
+ return sub;
1034
+ };
1035
+ const absenceSatisfiable = op === "eq" && value === null || op === "exists" && !value;
1036
+ if (absenceSatisfiable) {
1037
+ return eb.or([
1038
+ eb.not(eb.exists(buildSub(eb))),
1039
+ eb.exists(buildSub(eb).where(sql`${caseExpr} is null`))
1040
+ ]);
1041
+ }
1042
+ let predicate = null;
1043
+ switch (op) {
1044
+ case "eq":
1045
+ predicate = sql`${caseExpr} = ${value}`;
1046
+ break;
1047
+ case "ne":
1048
+ predicate = value === null ? sql`${caseExpr} is not null` : sql`${caseExpr} != ${value}`;
1049
+ break;
1050
+ case "gt":
1051
+ case "gte":
1052
+ case "lt":
1053
+ case "lte": {
1054
+ const operator = sql.raw(op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=");
1055
+ predicate = sql`${caseExpr} ${operator} ${value}`;
1056
+ break;
1057
+ }
1058
+ case "in": {
1059
+ const vals = Array.isArray(value) ? value : [value];
1060
+ predicate = sql`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
1061
+ break;
1062
+ }
1063
+ case "nin": {
1064
+ const vals = Array.isArray(value) ? value : [value];
1065
+ predicate = sql`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
1066
+ break;
1067
+ }
1068
+ case "like":
1069
+ predicate = sql`${caseExpr} like ${value}`;
1070
+ break;
1071
+ case "ilike":
1072
+ predicate = sql`${caseExpr} ilike ${value}`;
1073
+ break;
1074
+ case "exists":
1075
+ predicate = sql`${caseExpr} is not null`;
1076
+ break;
1077
+ default:
1078
+ return eb.val(true);
1079
+ }
1080
+ const captured = predicate;
1081
+ return eb.exists(buildSub(eb).where(captured));
1082
+ }
941
1083
  buildColumnOpExpression(eb, column, op, value) {
942
1084
  switch (op) {
943
1085
  case "eq":
@@ -1119,7 +1261,7 @@ class BasicQueryEngine {
1119
1261
  return sub;
1120
1262
  })());
1121
1263
  }
1122
- configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify) {
1264
+ configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify, attachJoins = true) {
1123
1265
  const sources = [
1124
1266
  {
1125
1267
  entityId: baseEntity,
@@ -1137,14 +1279,22 @@ class BasicQueryEngine {
1137
1279
  if (!join) {
1138
1280
  throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`);
1139
1281
  }
1140
- const joinFn = (join.type ?? "left") === "inner" ? "innerJoin" : "leftJoin";
1141
- next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
1282
+ const joinType = (join.type ?? "left") === "inner" ? "inner" : "left";
1283
+ if (attachJoins) {
1284
+ const joinFn = joinType === "inner" ? "innerJoin" : "leftJoin";
1285
+ next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
1286
+ } else if (joinType === "inner") {
1287
+ next = next.where((eb) => eb.exists(
1288
+ eb.selectFrom(`${joinTable} as ${alias}`).select(sql`1`.as("one")).whereRef(`${alias}.${join.toField}`, "=", qualify(join.fromField))
1289
+ ));
1290
+ }
1142
1291
  const recordColumn = srcOpt.recordIdColumn ?? "id";
1143
1292
  sources.push({
1144
1293
  entityId: srcOpt.entityId,
1145
1294
  alias,
1146
1295
  table: joinTable,
1147
- recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`
1296
+ recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`,
1297
+ hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType }
1148
1298
  });
1149
1299
  });
1150
1300
  return { builder: next, sources };