@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.
@@ -6214,40 +6214,80 @@ function restrictedRecord(record4, target) {
6214
6214
 
6215
6215
  // src/worker-routes-network-records-search.ts
6216
6216
  var import_crm9 = require("@odla-ai/crm");
6217
- var MAX_SAFE_LIKE_BYTES = 50;
6218
- function safePrefix(value) {
6219
- const prefixed = `${value}%`;
6220
- return value.includes("%") || value.includes("_") || new TextEncoder().encode(prefixed).byteLength > MAX_SAFE_LIKE_BYTES ? value : { $like: prefixed };
6217
+ var SEARCH_BATCH_SIZE = 1e3;
6218
+ var MAX_READER_SEARCH_POPULATION = 1e4;
6219
+ var ReaderSearchPopulationError = class extends Error {
6220
+ count;
6221
+ constructor(count) {
6222
+ super(`reader-visible search population exceeds ${MAX_READER_SEARCH_POPULATION} records`);
6223
+ this.name = "ReaderSearchPopulationError";
6224
+ this.count = count;
6225
+ }
6226
+ };
6227
+ function folded(value) {
6228
+ 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();
6229
+ }
6230
+ function terms(value) {
6231
+ return folded(value).split(/\s+/u).filter(Boolean);
6232
+ }
6233
+ function matchesReaderIdentity(record4, search, includeEmail) {
6234
+ const query = terms(search);
6235
+ if (!query.length) return true;
6236
+ const name = folded(record4.name);
6237
+ const visible = [name, ...name.split(/\s+/u).filter(Boolean)];
6238
+ if (includeEmail && record4.primaryEmail) visible.push(folded(record4.primaryEmail));
6239
+ return query.every((term) => visible.some((candidate) => candidate.includes(term)));
6240
+ }
6241
+ function baseWhere(type, stage) {
6242
+ return {
6243
+ and: [
6244
+ { type },
6245
+ { archived: false },
6246
+ ...stage ? [{ stage }] : []
6247
+ ]
6248
+ };
6249
+ }
6250
+ async function listVisibleSearchPopulation(db, where, window, count) {
6251
+ if (count > MAX_READER_SEARCH_POPULATION) throw new ReaderSearchPopulationError(count);
6252
+ const offsets = Array.from(
6253
+ { length: Math.ceil(count / SEARCH_BATCH_SIZE) },
6254
+ (_, index2) => index2 * SEARCH_BATCH_SIZE
6255
+ );
6256
+ const pages = await Promise.all(offsets.map((offset) => db.query({
6257
+ [import_crm9.CRM_NS.record]: { $: {
6258
+ where,
6259
+ order: { [window.sort.field]: window.sort.dir },
6260
+ limit: Math.min(SEARCH_BATCH_SIZE, count - offset),
6261
+ offset
6262
+ } }
6263
+ })));
6264
+ return pages.flatMap((page) => (page[import_crm9.CRM_NS.record] ?? []).map(import_crm9.rowToRecord));
6221
6265
  }
6222
6266
  async function listReaderRecords(db, type, allowed2, emailField, window) {
6223
- const clauses = [
6224
- { type },
6225
- { archived: false },
6226
- ...window.stage ? [{ stage: window.stage }] : []
6227
- ];
6267
+ const where = baseWhere(type, window.stage);
6268
+ const crmDb = db;
6269
+ const aggregate = await crmDb.aggregate(import_crm9.CRM_NS.record, { count: true }, { where });
6270
+ const candidateCount = aggregate.count ?? 0;
6228
6271
  if (window.search) {
6229
- const visible = [{ name: safePrefix(window.search) }];
6230
- if (emailField && allowed2.includes(emailField)) {
6231
- visible.push({ primaryEmail: safePrefix(window.search.toLowerCase()) });
6232
- }
6233
- clauses.push({ or: visible });
6272
+ const population = await listVisibleSearchPopulation(crmDb, where, window, candidateCount);
6273
+ const includeEmail = Boolean(emailField && allowed2.includes(emailField));
6274
+ const matches = population.filter((record4) => matchesReaderIdentity(record4, window.search, includeEmail));
6275
+ return {
6276
+ records: matches.slice(window.offset, window.offset + window.limit),
6277
+ total: matches.length
6278
+ };
6234
6279
  }
6235
- const where = { and: clauses };
6236
- const crmDb = db;
6237
- const [result, aggregate] = await Promise.all([
6238
- crmDb.query({
6239
- [import_crm9.CRM_NS.record]: { $: {
6240
- where,
6241
- order: { [window.sort.field]: window.sort.dir },
6242
- limit: window.limit,
6243
- offset: window.offset
6244
- } }
6245
- }),
6246
- crmDb.aggregate(import_crm9.CRM_NS.record, { count: true }, { where })
6247
- ]);
6280
+ const result = await crmDb.query({
6281
+ [import_crm9.CRM_NS.record]: { $: {
6282
+ where,
6283
+ order: { [window.sort.field]: window.sort.dir },
6284
+ limit: window.limit,
6285
+ offset: window.offset
6286
+ } }
6287
+ });
6248
6288
  return {
6249
6289
  records: (result[import_crm9.CRM_NS.record] ?? []).map(import_crm9.rowToRecord),
6250
- total: aggregate.count ?? 0
6290
+ total: candidateCount
6251
6291
  };
6252
6292
  }
6253
6293
 
@@ -6406,13 +6446,25 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
6406
6446
  if (window.stage && !stages.some((stage) => stage.id === window.stage)) {
6407
6447
  return json({ error: "stage is not defined for this record type" }, 400);
6408
6448
  }
6409
- const page = await listReaderRecords(
6410
- db,
6411
- type,
6412
- allowed2,
6413
- typeDef.emailField,
6414
- window
6415
- );
6449
+ let page;
6450
+ try {
6451
+ page = await listReaderRecords(
6452
+ db,
6453
+ type,
6454
+ allowed2,
6455
+ typeDef.emailField,
6456
+ window
6457
+ );
6458
+ } catch (error) {
6459
+ if (error instanceof ReaderSearchPopulationError) {
6460
+ return json({
6461
+ error: "directory search requires a narrower stage filter",
6462
+ code: "search_population_too_large",
6463
+ maximum: 1e4
6464
+ }, 422);
6465
+ }
6466
+ throw error;
6467
+ }
6416
6468
  const response = {
6417
6469
  version: 1,
6418
6470
  site: { id: ctx.chapter.id, name: ctx.chapter.name },
@@ -6961,6 +7013,9 @@ async function networkSourceKey(sourceSystem, sourceRecordId) {
6961
7013
  async function networkChapterLinkId(personId, chapterId, chapterPersonId) {
6962
7014
  return `chapter_person_${await sha256(`${personId}\0${chapterId}\0${chapterPersonId}`)}`;
6963
7015
  }
7016
+ async function networkChapterPersonSourceKey(chapterId, chapterPersonId) {
7017
+ return `chapter_source_${await sha256(`${chapterId}\0${chapterPersonId}`)}`;
7018
+ }
6964
7019
  function projectNetworkContactPolicy(policy, links) {
6965
7020
  return links.map((link) => ({ ...link, contactPolicy: { ...policy } }));
6966
7021
  }
@@ -7222,10 +7277,11 @@ var MUTATION_ID4 = /^[A-Za-z0-9_-]{16,128}$/;
7222
7277
  function redactedName(displayName) {
7223
7278
  return displayName.split(/\s+/).filter(Boolean).map((part) => `${part[0]?.toUpperCase() ?? ""}\u2026`).join(" ");
7224
7279
  }
7225
- var redactedLink = (link) => ({
7280
+ var redactedLink = async (link) => ({
7226
7281
  id: link.id,
7227
7282
  personId: link.personId,
7228
7283
  chapterId: link.chapterId,
7284
+ sourceKey: await networkChapterPersonSourceKey(link.chapterId, link.chapterPersonId),
7229
7285
  state: link.state,
7230
7286
  createdAt: link.createdAt
7231
7287
  });
@@ -7389,8 +7445,9 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
7389
7445
  }
7390
7446
  }
7391
7447
  const state = await loadNetworkPersonRegistry(db);
7392
- const persons = state.persons.map((person) => {
7448
+ const persons = await Promise.all(state.persons.map(async (person) => {
7393
7449
  const links = state.links.filter((link) => link.personId === person.id);
7450
+ const safeLinks = await Promise.all(links.map(redactedLink));
7394
7451
  const canonicalId = canonicalNetworkPersonId(person.id, state.persons);
7395
7452
  const canonical2 = state.persons.find((candidate) => candidate.id === canonicalId) ?? person;
7396
7453
  return {
@@ -7402,16 +7459,20 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
7402
7459
  revision: person.revision,
7403
7460
  contactPolicy: safePolicy(person.contactPolicy),
7404
7461
  identityClaims: state.claims.filter((claim) => claim.personId === person.id).map(({ id, kind, hint, verifiedAt }) => ({ id, kind, hint: hint ?? null, verifiedAt })),
7405
- chapterLinks: links.map(redactedLink),
7406
- chapterProjections: projectNetworkContactPolicy(canonical2.contactPolicy, links.map(redactedLink)).map(({ contactPolicy, ...projection }) => ({ ...projection, contactPolicy: safePolicy(contactPolicy) }))
7462
+ chapterLinks: safeLinks,
7463
+ chapterProjections: projectNetworkContactPolicy(canonical2.contactPolicy, safeLinks).map(({ contactPolicy, ...projection }) => ({ ...projection, contactPolicy: safePolicy(contactPolicy) }))
7407
7464
  };
7408
- });
7465
+ }));
7409
7466
  const aliases = state.aliases.map(safeAlias);
7410
7467
  return json({
7411
7468
  version: 1,
7412
7469
  mode: "shadow",
7413
7470
  writable: true,
7414
7471
  authoritativeForChapterOperation: false,
7472
+ // loadNetworkPersonRegistry deliberately bounds each collection at 1,000.
7473
+ // Exactly 1,000 links may mean there are more, so fail closed rather than
7474
+ // letting a consumer mistake an omitted explicit link for "source only".
7475
+ chapterLinkProjectionComplete: state.links.length < 1e3,
7415
7476
  configuredChapterIds: ctx.chapter.networkPersonRegistry.chapterIds,
7416
7477
  persons,
7417
7478
  aliases,