@reachy/audience-module 1.0.18 → 1.0.20

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 (34) hide show
  1. package/.gitlab/merge_request_templates/Default.md +31 -0
  2. package/.gitlab-ci.yml +59 -49
  3. package/CLAUDE.md +134 -0
  4. package/dist/AudienceModule.d.ts.map +1 -1
  5. package/dist/AudienceModule.js +1 -0
  6. package/dist/AudienceModule.js.map +1 -1
  7. package/dist/engine/V2AudienceEngine.d.ts +5 -0
  8. package/dist/engine/V2AudienceEngine.d.ts.map +1 -1
  9. package/dist/engine/V2AudienceEngine.js +210 -72
  10. package/dist/engine/V2AudienceEngine.js.map +1 -1
  11. package/dist/executors/ClickHouseEventQueryExecutor.d.ts +23 -0
  12. package/dist/executors/ClickHouseEventQueryExecutor.d.ts.map +1 -0
  13. package/dist/executors/ClickHouseEventQueryExecutor.js +803 -0
  14. package/dist/executors/ClickHouseEventQueryExecutor.js.map +1 -0
  15. package/dist/repositories/SupabaseContactRepository.d.ts +1 -0
  16. package/dist/repositories/SupabaseContactRepository.d.ts.map +1 -1
  17. package/dist/repositories/SupabaseContactRepository.js +1 -0
  18. package/dist/repositories/SupabaseContactRepository.js.map +1 -1
  19. package/dist/types/index.d.ts +1 -0
  20. package/dist/types/index.d.ts.map +1 -1
  21. package/jest.config.js +8 -0
  22. package/package.json +7 -2
  23. package/src/AudienceModule.ts +1 -0
  24. package/src/__tests__/AudienceModule.test.ts +382 -0
  25. package/src/__tests__/CriteriaParser.test.ts +130 -0
  26. package/src/__tests__/QueryBuilder.test.ts +198 -0
  27. package/src/__tests__/RfmEngine.test.ts +284 -0
  28. package/src/__tests__/RfmSegmentBuilder.test.ts +210 -0
  29. package/src/__tests__/StaticAudienceExecutor.test.ts +134 -0
  30. package/src/__tests__/SupabaseContactRepository.test.ts +81 -0
  31. package/src/engine/V2AudienceEngine.ts +240 -85
  32. package/src/executors/ClickHouseEventQueryExecutor.ts +853 -0
  33. package/src/repositories/SupabaseContactRepository.ts +2 -1
  34. package/src/types/index.ts +6 -0
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.V2AudienceEngine = void 0;
4
4
  const CriteriaParser_1 = require("../builders/CriteriaParser");
5
+ const ClickHouseEventQueryExecutor_1 = require("../executors/ClickHouseEventQueryExecutor");
5
6
  const getByPath = (obj, dottedPath) => {
6
7
  if (!obj || !dottedPath)
7
8
  return undefined;
@@ -315,13 +316,22 @@ const applyEventRuleFilters = (rows, filters, opts) => {
315
316
  };
316
317
  class V2AudienceEngine {
317
318
  constructor(params) {
319
+ this.chExecutor = null;
318
320
  this.supabase = params.supabaseClient;
319
321
  this.debug = !!params.debug;
320
322
  this.logger = params.logger || console;
323
+ if (params.clickhouseClient) {
324
+ this.chExecutor = new ClickHouseEventQueryExecutor_1.ClickHouseEventQueryExecutor(params.clickhouseClient, params.logger || console);
325
+ if (this.debug)
326
+ this.logger.log('[V2AudienceEngine] ClickHouse executor initialized');
327
+ }
321
328
  }
322
- async getContactIdsByAudienceCriteriaV2(organizationId, projectId, criteriaRaw) {
323
- let criteria = CriteriaParser_1.CriteriaParser.parse(criteriaRaw);
324
- let projectTimezone = 'UTC';
329
+ async getProjectTimezone(organizationId, projectId) {
330
+ const cacheKey = `${organizationId}:${projectId}`;
331
+ const cached = V2AudienceEngine._tzCache.get(cacheKey);
332
+ if (cached && Date.now() - cached.fetchedAt < V2AudienceEngine.TZ_CACHE_TTL) {
333
+ return cached.tz;
334
+ }
325
335
  try {
326
336
  const { data } = await this.supabase
327
337
  .from('projects')
@@ -330,12 +340,20 @@ class V2AudienceEngine {
330
340
  .eq('id', projectId)
331
341
  .single();
332
342
  const tzCandidate = data?.settings?.timezone;
333
- if (typeof tzCandidate === 'string' && isValidIanaTimezone(tzCandidate)) {
334
- projectTimezone = tzCandidate.trim();
335
- }
343
+ const tz = (typeof tzCandidate === 'string' && isValidIanaTimezone(tzCandidate))
344
+ ? tzCandidate.trim()
345
+ : 'UTC';
346
+ V2AudienceEngine._tzCache.set(cacheKey, { tz, fetchedAt: Date.now() });
347
+ return tz;
336
348
  }
337
349
  catch {
350
+ V2AudienceEngine._tzCache.set(cacheKey, { tz: 'UTC', fetchedAt: Date.now() });
351
+ return 'UTC';
338
352
  }
353
+ }
354
+ async getContactIdsByAudienceCriteriaV2(organizationId, projectId, criteriaRaw) {
355
+ let criteria = CriteriaParser_1.CriteriaParser.parse(criteriaRaw);
356
+ const projectTimezone = await this.getProjectTimezone(organizationId, projectId);
339
357
  if ((!Array.isArray(criteria.groups) || criteria.groups.length === 0) &&
340
358
  (Array.isArray(criteria.filters) || Array.isArray(criteria.conditions))) {
341
359
  criteria = coerceCriteriaToGroups(criteria);
@@ -351,7 +369,8 @@ class V2AudienceEngine {
351
369
  return new Set();
352
370
  }
353
371
  const typeId = String(criteria?.type || '');
354
- const fetchAll = async (baseQuery, pageSize) => {
372
+ const MAX_FETCH_ROWS = 500000;
373
+ const fetchAll = async (baseQuery, pageSize, maxRows = MAX_FETCH_ROWS) => {
355
374
  let offset = 0;
356
375
  let out = [];
357
376
  while (true) {
@@ -361,35 +380,72 @@ class V2AudienceEngine {
361
380
  if (!data || data.length === 0)
362
381
  break;
363
382
  out = out.concat(data);
383
+ if (out.length >= maxRows) {
384
+ this.dlog('fetchAll: max rows reached', { maxRows, fetched: out.length });
385
+ break;
386
+ }
364
387
  if (data.length < pageSize)
365
388
  break;
366
389
  offset += pageSize;
367
390
  }
368
391
  return out;
369
392
  };
370
- const allContacts = await fetchAll(this.supabase
371
- .from('contacts')
372
- .select('id, reachy_id, email')
373
- .eq('organization_id', organizationId)
374
- .eq('project_id', projectId), 1000);
375
- const allContactIds = new Set((allContacts || []).map((c) => c.id));
376
- const reachyIdToContactId = new Map();
377
- const contactIdToReachyId = new Map();
378
- const emailToContactId = new Map();
379
- for (const c of allContacts || []) {
380
- const rid = c?.reachy_id ? String(c.reachy_id).trim() : '';
381
- const cid = c?.id ? String(c.id).trim() : '';
382
- if (rid && cid) {
383
- reachyIdToContactId.set(rid, cid);
384
- contactIdToReachyId.set(cid, rid);
393
+ const groups = criteria.groups;
394
+ const hasNegate = groups.some((g) => (g.rules || []).some((r) => r.negate));
395
+ const hasEventRules = groups.some((g) => (g.rules || []).some((r) => r.kind === 'event'));
396
+ let allContactIds = new Set();
397
+ let reachyIdToContactId = new Map();
398
+ let contactIdToReachyId = new Map();
399
+ let emailToContactId = new Map();
400
+ let _contactsLoaded = false;
401
+ const ensureContactsLoaded = async () => {
402
+ if (_contactsLoaded)
403
+ return;
404
+ _contactsLoaded = true;
405
+ const allContacts = await fetchAll(this.supabase
406
+ .from('contacts')
407
+ .select('id, reachy_id, email')
408
+ .eq('organization_id', organizationId)
409
+ .eq('project_id', projectId), 1000);
410
+ allContactIds = new Set((allContacts || []).map((c) => c.id));
411
+ for (const c of allContacts || []) {
412
+ const rid = c?.reachy_id ? String(c.reachy_id).trim() : '';
413
+ const cid = c?.id ? String(c.id).trim() : '';
414
+ if (rid && cid) {
415
+ reachyIdToContactId.set(rid, cid);
416
+ contactIdToReachyId.set(cid, rid);
417
+ }
418
+ const email = c?.email ? String(c.email).trim().toLowerCase() : '';
419
+ if (email && cid)
420
+ emailToContactId.set(email, cid);
385
421
  }
386
- const email = c?.email ? String(c.email).trim().toLowerCase() : '';
387
- if (email && cid)
388
- emailToContactId.set(email, cid);
422
+ };
423
+ if (hasNegate || hasEventRules) {
424
+ await ensureContactsLoaded();
389
425
  }
390
- const union = (a, b) => new Set([...a, ...b]);
391
- const intersect = (a, b) => new Set([...a].filter(x => b.has(x)));
392
- const diff = (a, b) => new Set([...a].filter(x => !b.has(x)));
426
+ const union = (a, b) => {
427
+ const result = new Set(a);
428
+ for (const x of b)
429
+ result.add(x);
430
+ return result;
431
+ };
432
+ const intersect = (a, b) => {
433
+ const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];
434
+ const result = new Set();
435
+ for (const x of smaller) {
436
+ if (larger.has(x))
437
+ result.add(x);
438
+ }
439
+ return result;
440
+ };
441
+ const diff = (a, b) => {
442
+ const result = new Set();
443
+ for (const x of a) {
444
+ if (!b.has(x))
445
+ result.add(x);
446
+ }
447
+ return result;
448
+ };
393
449
  const resolveEventContactId = (row) => {
394
450
  const rawReachyId = row?.reachy_id ? String(row.reachy_id).trim() : '';
395
451
  if (rawReachyId) {
@@ -416,12 +472,33 @@ class V2AudienceEngine {
416
472
  };
417
473
  const evalEventRule = async (rule) => {
418
474
  const cfg = criteria?.config || {};
475
+ if (this.chExecutor) {
476
+ try {
477
+ return await this.chExecutor.execute(rule, criteria, organizationId, projectId, projectTimezone, reachyIdToContactId, contactIdToReachyId, allContactIds);
478
+ }
479
+ catch (chErr) {
480
+ this.logger.warn('[V2AudienceEngine] ClickHouse evalEventRule failed, falling back to Supabase:', chErr);
481
+ }
482
+ }
419
483
  const effectiveEventName = cfg.eventType && String(cfg.eventType).trim() !== ''
420
484
  ? String(cfg.eventType)
421
485
  : String(rule.eventName);
486
+ const ruleFiltersPrecheck = Array.isArray(rule.filters) ? rule.filters : [];
487
+ const needsEventData = !!((rule.interest && rule.interest.key) ||
488
+ ruleFiltersPrecheck.some((f) => ['event_property', 'time_of_day', 'day_of_week', 'day_of_month'].includes(String(f?.type || '').trim())) ||
489
+ (rule.frequency && rule.frequency.type && rule.frequency.type !== 'count') ||
490
+ typeId === 'live-page-count');
491
+ const needsTimestamp = !!(ruleFiltersPrecheck.some((f) => ['first_time', 'last_time', 'time_of_day', 'day_of_week', 'day_of_month'].includes(String(f?.type || '').trim())));
492
+ let selectFields = 'contact_id, reachy_id';
493
+ if (needsEventData)
494
+ selectFields += ', event_data';
495
+ if (needsTimestamp)
496
+ selectFields += ', event_timestamp';
497
+ if (needsEventData || needsTimestamp)
498
+ selectFields += ', event_name';
422
499
  let query = this.supabase
423
500
  .from('contact_events')
424
- .select('contact_id, reachy_id, event_data, event_timestamp, event_name')
501
+ .select(selectFields)
425
502
  .eq('organization_id', organizationId)
426
503
  .eq('project_id', projectId)
427
504
  .eq('event_name', effectiveEventName);
@@ -622,7 +699,8 @@ class V2AudienceEngine {
622
699
  if (this.debug)
623
700
  this.logger.warn('[AUDIENCE_MODULE_ENGINE] event attributes filter error');
624
701
  }
625
- const data = await fetchAll(query, 500);
702
+ const MAX_EVENT_ROWS = 200000;
703
+ const data = await fetchAll(query, 1000, MAX_EVENT_ROWS);
626
704
  if (!data || data.length === 0)
627
705
  return new Set();
628
706
  let rows = data;
@@ -1208,41 +1286,78 @@ class V2AudienceEngine {
1208
1286
  return new Set();
1209
1287
  return new Set(data.map((r) => r.id));
1210
1288
  };
1289
+ const evalRule = (rule) => rule.kind === 'event' ? evalEventRule(rule) : evalPropertyRule(rule);
1211
1290
  const evalGroup = async (group) => {
1291
+ const rules = group.rules || [];
1292
+ if (rules.length === 0)
1293
+ return new Set();
1294
+ const op = group.operator;
1295
+ if (op === 'OR') {
1296
+ const results = await Promise.all(rules.map(async (rule) => {
1297
+ const set = await evalRule(rule);
1298
+ return rule.negate ? diff(allContactIds, set) : set;
1299
+ }));
1300
+ let acc = results[0];
1301
+ for (let i = 1; i < results.length; i++) {
1302
+ acc = union(acc, results[i]);
1303
+ }
1304
+ return acc;
1305
+ }
1212
1306
  let acc = null;
1213
- for (const rule of group.rules || []) {
1214
- const set = rule.kind === 'event' ? await evalEventRule(rule) : await evalPropertyRule(rule);
1215
- const shouldNegate = rule.negate;
1216
- const s = shouldNegate ? diff(allContactIds, set) : set;
1307
+ for (const rule of rules) {
1308
+ const set = await evalRule(rule);
1309
+ const s = rule.negate ? diff(allContactIds, set) : set;
1217
1310
  if (acc == null) {
1218
1311
  acc = s;
1219
1312
  }
1220
1313
  else {
1221
- if (group.operator === 'AND')
1314
+ if (op === 'AND')
1222
1315
  acc = intersect(acc, s);
1223
- else if (group.operator === 'OR')
1224
- acc = union(acc, s);
1225
- else if (group.operator === 'NOT')
1316
+ else if (op === 'NOT')
1226
1317
  acc = diff(acc, s);
1227
1318
  }
1319
+ if (acc.size === 0)
1320
+ return acc;
1228
1321
  }
1229
1322
  return acc || new Set();
1230
1323
  };
1324
+ const allGroups = criteria.groups;
1231
1325
  let result = null;
1232
- for (let i = 0; i < criteria.groups.length; i++) {
1233
- const group = criteria.groups[i];
1234
- const gset = await evalGroup(group);
1235
- if (result == null) {
1236
- result = gset;
1326
+ const allCombineWithOr = allGroups.length > 1 && allGroups.every((g, i) => {
1327
+ if (i === 0)
1328
+ return true;
1329
+ return String(g.combineOperator || g.operator || 'AND').toUpperCase() === 'OR';
1330
+ });
1331
+ if (allCombineWithOr) {
1332
+ const groupResults = await Promise.all(allGroups.map((g) => evalGroup(g)));
1333
+ result = groupResults[0];
1334
+ for (let i = 1; i < groupResults.length; i++) {
1335
+ result = union(result, groupResults[i]);
1237
1336
  }
1238
- else {
1239
- const betweenOp = String(group.combineOperator || group.operator || 'AND').toUpperCase();
1240
- if (betweenOp === 'AND')
1241
- result = intersect(result, gset);
1242
- else if (betweenOp === 'OR')
1243
- result = union(result, gset);
1244
- else if (betweenOp === 'NOT')
1245
- result = diff(result, gset);
1337
+ }
1338
+ else {
1339
+ for (let i = 0; i < allGroups.length; i++) {
1340
+ const group = allGroups[i];
1341
+ const gset = await evalGroup(group);
1342
+ if (result == null) {
1343
+ result = gset;
1344
+ }
1345
+ else {
1346
+ const betweenOp = String(group.combineOperator || group.operator || 'AND').toUpperCase();
1347
+ if (betweenOp === 'AND')
1348
+ result = intersect(result, gset);
1349
+ else if (betweenOp === 'OR')
1350
+ result = union(result, gset);
1351
+ else if (betweenOp === 'NOT')
1352
+ result = diff(result, gset);
1353
+ }
1354
+ if (result.size === 0) {
1355
+ const nextOp = i + 1 < allGroups.length
1356
+ ? String((allGroups[i + 1].combineOperator || allGroups[i + 1].operator || 'AND')).toUpperCase()
1357
+ : '';
1358
+ if (nextOp === 'AND' || nextOp === 'NOT')
1359
+ break;
1360
+ }
1246
1361
  }
1247
1362
  }
1248
1363
  const finalSet = result || new Set();
@@ -1259,21 +1374,7 @@ class V2AudienceEngine {
1259
1374
  return false;
1260
1375
  }
1261
1376
  const typeId = String(criteria?.type || '');
1262
- let projectTimezone = 'UTC';
1263
- try {
1264
- const { data } = await this.supabase
1265
- .from('projects')
1266
- .select('settings')
1267
- .eq('organization_id', organizationId)
1268
- .eq('id', projectId)
1269
- .single();
1270
- const tzCandidate = data?.settings?.timezone;
1271
- if (typeof tzCandidate === 'string' && isValidIanaTimezone(tzCandidate)) {
1272
- projectTimezone = tzCandidate.trim();
1273
- }
1274
- }
1275
- catch {
1276
- }
1377
+ const projectTimezone = await this.getProjectTimezone(organizationId, projectId);
1277
1378
  const { data: contactRow } = await this.supabase
1278
1379
  .from('contacts')
1279
1380
  .select('id, reachy_id, email')
@@ -1284,6 +1385,22 @@ class V2AudienceEngine {
1284
1385
  const reachyId = contactRow?.reachy_id ? String(contactRow.reachy_id).trim() : '';
1285
1386
  const evalEventRuleForContact = async (rule) => {
1286
1387
  const cfg = criteria?.config || {};
1388
+ if (this.chExecutor) {
1389
+ try {
1390
+ const singleContactIdToReachyId = new Map();
1391
+ const singleReachyIdToContactId = new Map();
1392
+ const singleAllContactIds = new Set([contactId]);
1393
+ if (reachyId) {
1394
+ singleReachyIdToContactId.set(reachyId, contactId);
1395
+ singleContactIdToReachyId.set(contactId, reachyId);
1396
+ }
1397
+ const matchSet = await this.chExecutor.execute(rule, criteria, organizationId, projectId, projectTimezone, singleReachyIdToContactId, singleContactIdToReachyId, singleAllContactIds);
1398
+ return matchSet.has(contactId);
1399
+ }
1400
+ catch (chErr) {
1401
+ this.logger.warn('[V2AudienceEngine] ClickHouse evalEventRuleForContact failed, falling back to Supabase:', chErr);
1402
+ }
1403
+ }
1287
1404
  const effectiveEventName = cfg.eventType && String(cfg.eventType).trim() !== ''
1288
1405
  ? String(cfg.eventType)
1289
1406
  : String(rule.eventName);
@@ -1948,27 +2065,37 @@ class V2AudienceEngine {
1948
2065
  return res;
1949
2066
  };
1950
2067
  const evalGroupForContact = async (group) => {
2068
+ const rules = group.rules || [];
2069
+ if (rules.length === 0)
2070
+ return false;
2071
+ const op = group.operator;
1951
2072
  let acc = null;
1952
- for (const rule of group.rules || []) {
2073
+ for (const rule of rules) {
1953
2074
  const r = await evalRuleForContact(rule);
1954
- if (acc == null)
2075
+ if (acc == null) {
1955
2076
  acc = r;
2077
+ }
1956
2078
  else {
1957
- if (group.operator === 'AND')
2079
+ if (op === 'AND')
1958
2080
  acc = acc && r;
1959
- else if (group.operator === 'OR')
2081
+ else if (op === 'OR')
1960
2082
  acc = acc || r;
1961
- else if (group.operator === 'NOT')
2083
+ else if (op === 'NOT')
1962
2084
  acc = acc && !r;
1963
2085
  }
2086
+ if (op === 'AND' && acc === false)
2087
+ return false;
2088
+ if (op === 'OR' && acc === true)
2089
+ return true;
1964
2090
  }
1965
2091
  return !!acc;
1966
2092
  };
1967
2093
  let result = null;
1968
2094
  for (const group of criteria.groups) {
1969
2095
  const g = await evalGroupForContact(group);
1970
- if (result == null)
2096
+ if (result == null) {
1971
2097
  result = g;
2098
+ }
1972
2099
  else {
1973
2100
  const betweenOp = String(group.combineOperator || group.operator || 'AND').toUpperCase();
1974
2101
  if (betweenOp === 'AND')
@@ -1978,6 +2105,15 @@ class V2AudienceEngine {
1978
2105
  else if (betweenOp === 'NOT')
1979
2106
  result = result && !g;
1980
2107
  }
2108
+ if (result === false) {
2109
+ const remaining = criteria.groups.slice(criteria.groups.indexOf(group) + 1);
2110
+ const allAnd = remaining.every((g2) => {
2111
+ const op2 = String(g2.combineOperator || g2.operator || 'AND').toUpperCase();
2112
+ return op2 === 'AND' || op2 === 'NOT';
2113
+ });
2114
+ if (allAnd)
2115
+ return false;
2116
+ }
1981
2117
  }
1982
2118
  return !!result;
1983
2119
  }
@@ -1988,6 +2124,8 @@ class V2AudienceEngine {
1988
2124
  }
1989
2125
  }
1990
2126
  exports.V2AudienceEngine = V2AudienceEngine;
2127
+ V2AudienceEngine._tzCache = new Map();
2128
+ V2AudienceEngine.TZ_CACHE_TTL = 5 * 60 * 1000;
1991
2129
  function jsonTextAccessor(base, key) {
1992
2130
  const safeKey = String(key || '').trim();
1993
2131
  return `${base}->>${safeKey}`;