@odla-ai/chapter 0.47.2 → 0.47.4

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 },
@@ -6941,6 +6993,9 @@ async function networkSourceKey(sourceSystem, sourceRecordId) {
6941
6993
  async function networkChapterLinkId(personId, chapterId, chapterPersonId) {
6942
6994
  return `chapter_person_${await sha256(`${personId}\0${chapterId}\0${chapterPersonId}`)}`;
6943
6995
  }
6996
+ async function networkChapterPersonSourceKey(chapterId, chapterPersonId) {
6997
+ return `chapter_source_${await sha256(`${chapterId}\0${chapterPersonId}`)}`;
6998
+ }
6944
6999
  function projectNetworkContactPolicy(policy, links) {
6945
7000
  return links.map((link) => ({ ...link, contactPolicy: { ...policy } }));
6946
7001
  }
@@ -7202,10 +7257,11 @@ var MUTATION_ID4 = /^[A-Za-z0-9_-]{16,128}$/;
7202
7257
  function redactedName(displayName) {
7203
7258
  return displayName.split(/\s+/).filter(Boolean).map((part) => `${part[0]?.toUpperCase() ?? ""}\u2026`).join(" ");
7204
7259
  }
7205
- var redactedLink = (link) => ({
7260
+ var redactedLink = async (link) => ({
7206
7261
  id: link.id,
7207
7262
  personId: link.personId,
7208
7263
  chapterId: link.chapterId,
7264
+ sourceKey: await networkChapterPersonSourceKey(link.chapterId, link.chapterPersonId),
7209
7265
  state: link.state,
7210
7266
  createdAt: link.createdAt
7211
7267
  });
@@ -7369,8 +7425,9 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
7369
7425
  }
7370
7426
  }
7371
7427
  const state = await loadNetworkPersonRegistry(db);
7372
- const persons = state.persons.map((person) => {
7428
+ const persons = await Promise.all(state.persons.map(async (person) => {
7373
7429
  const links = state.links.filter((link) => link.personId === person.id);
7430
+ const safeLinks = await Promise.all(links.map(redactedLink));
7374
7431
  const canonicalId = canonicalNetworkPersonId(person.id, state.persons);
7375
7432
  const canonical2 = state.persons.find((candidate) => candidate.id === canonicalId) ?? person;
7376
7433
  return {
@@ -7382,16 +7439,20 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
7382
7439
  revision: person.revision,
7383
7440
  contactPolicy: safePolicy(person.contactPolicy),
7384
7441
  identityClaims: state.claims.filter((claim) => claim.personId === person.id).map(({ id, kind, hint, verifiedAt }) => ({ id, kind, hint: hint ?? null, verifiedAt })),
7385
- chapterLinks: links.map(redactedLink),
7386
- chapterProjections: projectNetworkContactPolicy(canonical2.contactPolicy, links.map(redactedLink)).map(({ contactPolicy, ...projection }) => ({ ...projection, contactPolicy: safePolicy(contactPolicy) }))
7442
+ chapterLinks: safeLinks,
7443
+ chapterProjections: projectNetworkContactPolicy(canonical2.contactPolicy, safeLinks).map(({ contactPolicy, ...projection }) => ({ ...projection, contactPolicy: safePolicy(contactPolicy) }))
7387
7444
  };
7388
- });
7445
+ }));
7389
7446
  const aliases = state.aliases.map(safeAlias);
7390
7447
  return json({
7391
7448
  version: 1,
7392
7449
  mode: "shadow",
7393
7450
  writable: true,
7394
7451
  authoritativeForChapterOperation: false,
7452
+ // loadNetworkPersonRegistry deliberately bounds each collection at 1,000.
7453
+ // Exactly 1,000 links may mean there are more, so fail closed rather than
7454
+ // letting a consumer mistake an omitted explicit link for "source only".
7455
+ chapterLinkProjectionComplete: state.links.length < 1e3,
7395
7456
  configuredChapterIds: ctx.chapter.networkPersonRegistry.chapterIds,
7396
7457
  persons,
7397
7458
  aliases,