@odla-ai/chapter 0.47.2 → 0.47.3

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.
@@ -6190,40 +6190,80 @@ function restrictedRecord(record4, target) {
6190
6190
 
6191
6191
  // src/worker-routes-network-records-search.ts
6192
6192
  import { CRM_NS, rowToRecord } from "@odla-ai/crm";
6193
- var MAX_SAFE_LIKE_BYTES = 50;
6194
- function safePrefix(value) {
6195
- const prefixed = `${value}%`;
6196
- return value.includes("%") || value.includes("_") || new TextEncoder().encode(prefixed).byteLength > MAX_SAFE_LIKE_BYTES ? value : { $like: prefixed };
6193
+ var SEARCH_BATCH_SIZE = 1e3;
6194
+ var MAX_READER_SEARCH_POPULATION = 1e4;
6195
+ var ReaderSearchPopulationError = class extends Error {
6196
+ count;
6197
+ constructor(count) {
6198
+ super(`reader-visible search population exceeds ${MAX_READER_SEARCH_POPULATION} records`);
6199
+ this.name = "ReaderSearchPopulationError";
6200
+ this.count = count;
6201
+ }
6202
+ };
6203
+ function folded(value) {
6204
+ return value.replace(/[øØ]/gu, "o").replace(/[łŁ]/gu, "l").replace(/[đĐðÐ]/gu, "d").replace(/[æÆ]/gu, "ae").replace(/[œŒ]/gu, "oe").replace(/[þÞ]/gu, "th").replace(/ß/gu, "ss").normalize("NFKD").replace(/\p{M}/gu, "").toLocaleLowerCase("en-US").replace(/[^\p{L}\p{N}@._+-]+/gu, " ").trim();
6205
+ }
6206
+ function terms(value) {
6207
+ return folded(value).split(/\s+/u).filter(Boolean);
6208
+ }
6209
+ function matchesReaderIdentity(record4, search, includeEmail) {
6210
+ const query = terms(search);
6211
+ if (!query.length) return true;
6212
+ const name = folded(record4.name);
6213
+ const visible = [name, ...name.split(/\s+/u).filter(Boolean)];
6214
+ if (includeEmail && record4.primaryEmail) visible.push(folded(record4.primaryEmail));
6215
+ return query.every((term) => visible.some((candidate) => candidate.includes(term)));
6216
+ }
6217
+ function baseWhere(type, stage) {
6218
+ return {
6219
+ and: [
6220
+ { type },
6221
+ { archived: false },
6222
+ ...stage ? [{ stage }] : []
6223
+ ]
6224
+ };
6225
+ }
6226
+ async function listVisibleSearchPopulation(db, where, window, count) {
6227
+ if (count > MAX_READER_SEARCH_POPULATION) throw new ReaderSearchPopulationError(count);
6228
+ const offsets = Array.from(
6229
+ { length: Math.ceil(count / SEARCH_BATCH_SIZE) },
6230
+ (_, index2) => index2 * SEARCH_BATCH_SIZE
6231
+ );
6232
+ const pages = await Promise.all(offsets.map((offset) => db.query({
6233
+ [CRM_NS.record]: { $: {
6234
+ where,
6235
+ order: { [window.sort.field]: window.sort.dir },
6236
+ limit: Math.min(SEARCH_BATCH_SIZE, count - offset),
6237
+ offset
6238
+ } }
6239
+ })));
6240
+ return pages.flatMap((page) => (page[CRM_NS.record] ?? []).map(rowToRecord));
6197
6241
  }
6198
6242
  async function listReaderRecords(db, type, allowed2, emailField, window) {
6199
- const clauses = [
6200
- { type },
6201
- { archived: false },
6202
- ...window.stage ? [{ stage: window.stage }] : []
6203
- ];
6243
+ const where = baseWhere(type, window.stage);
6244
+ const crmDb = db;
6245
+ const aggregate = await crmDb.aggregate(CRM_NS.record, { count: true }, { where });
6246
+ const candidateCount = aggregate.count ?? 0;
6204
6247
  if (window.search) {
6205
- const visible = [{ name: safePrefix(window.search) }];
6206
- if (emailField && allowed2.includes(emailField)) {
6207
- visible.push({ primaryEmail: safePrefix(window.search.toLowerCase()) });
6208
- }
6209
- clauses.push({ or: visible });
6248
+ const population = await listVisibleSearchPopulation(crmDb, where, window, candidateCount);
6249
+ const includeEmail = Boolean(emailField && allowed2.includes(emailField));
6250
+ const matches = population.filter((record4) => matchesReaderIdentity(record4, window.search, includeEmail));
6251
+ return {
6252
+ records: matches.slice(window.offset, window.offset + window.limit),
6253
+ total: matches.length
6254
+ };
6210
6255
  }
6211
- const where = { and: clauses };
6212
- const crmDb = db;
6213
- const [result, aggregate] = await Promise.all([
6214
- crmDb.query({
6215
- [CRM_NS.record]: { $: {
6216
- where,
6217
- order: { [window.sort.field]: window.sort.dir },
6218
- limit: window.limit,
6219
- offset: window.offset
6220
- } }
6221
- }),
6222
- crmDb.aggregate(CRM_NS.record, { count: true }, { where })
6223
- ]);
6256
+ const result = await crmDb.query({
6257
+ [CRM_NS.record]: { $: {
6258
+ where,
6259
+ order: { [window.sort.field]: window.sort.dir },
6260
+ limit: window.limit,
6261
+ offset: window.offset
6262
+ } }
6263
+ });
6224
6264
  return {
6225
6265
  records: (result[CRM_NS.record] ?? []).map(rowToRecord),
6226
- total: aggregate.count ?? 0
6266
+ total: candidateCount
6227
6267
  };
6228
6268
  }
6229
6269
 
@@ -6382,13 +6422,25 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
6382
6422
  if (window.stage && !stages.some((stage) => stage.id === window.stage)) {
6383
6423
  return json({ error: "stage is not defined for this record type" }, 400);
6384
6424
  }
6385
- const page = await listReaderRecords(
6386
- db,
6387
- type,
6388
- allowed2,
6389
- typeDef.emailField,
6390
- window
6391
- );
6425
+ let page;
6426
+ try {
6427
+ page = await listReaderRecords(
6428
+ db,
6429
+ type,
6430
+ allowed2,
6431
+ typeDef.emailField,
6432
+ window
6433
+ );
6434
+ } catch (error) {
6435
+ if (error instanceof ReaderSearchPopulationError) {
6436
+ return json({
6437
+ error: "directory search requires a narrower stage filter",
6438
+ code: "search_population_too_large",
6439
+ maximum: 1e4
6440
+ }, 422);
6441
+ }
6442
+ throw error;
6443
+ }
6392
6444
  const response = {
6393
6445
  version: 1,
6394
6446
  site: { id: ctx.chapter.id, name: ctx.chapter.name },