@classytic/arc-notifications 0.6.2 → 0.8.0

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 (2) hide show
  1. package/dist/index.mjs +102 -33
  2. package/package.json +4 -4
package/dist/index.mjs CHANGED
@@ -1,7 +1,9 @@
1
+ import { createTtlMemo } from "@classytic/repo-core/cache";
1
2
  import { BaseChannel, NotificationService, createSimpleResolver } from "@classytic/notifications";
2
3
  import { defineResource } from "@classytic/arc";
3
4
  import { NotFoundError } from "@classytic/arc/utils";
4
5
  import { defineJob } from "@classytic/arc/integrations/jobs";
6
+ import { scopedValue } from "@classytic/arc/context";
5
7
  import { defineModule } from "@classytic/arc/factory";
6
8
  //#region src/backfill.ts
7
9
  function createInAppBackfill(options) {
@@ -114,23 +116,26 @@ const IN_APP_CHANNEL = "in-app";
114
116
  * TTL window.
115
117
  */
116
118
  function createCachedTriggerOverrideStore(load, options = {}) {
117
- const ttlMs = options.ttlMs ?? 3e4;
118
- const cache = /* @__PURE__ */ new Map();
119
+ /**
120
+ * `createTtlMemo`, never a hand-rolled map: the map wrote its entry AFTER
121
+ * the await, so the N channel expansions a parallel host dispatches together
122
+ * all missed. Single flight is what makes the TTL mean anything here.
123
+ */
124
+ const memo = createTtlMemo(load, {
125
+ ttlMs: options.ttlMs ?? 3e4,
126
+ keyOf: (organizationId) => organizationId ?? "__company__"
127
+ });
119
128
  return {
120
- async forOrganization(organizationId) {
121
- const key = organizationId ?? "__company__";
122
- const hit = cache.get(key);
123
- if (hit && Date.now() - hit.at < ttlMs) return hit.rows;
124
- const rows = await load(organizationId);
125
- cache.set(key, {
126
- at: Date.now(),
127
- rows
128
- });
129
- return rows;
129
+ forOrganization(organizationId) {
130
+ return memo.get(organizationId);
130
131
  },
131
- /** Call after a write so an admin sees their change immediately. */
132
+ /**
133
+ * Call after a write so an admin sees their change immediately. The
134
+ * in-process clear completes before this returns; the promise it wraps
135
+ * only exists to satisfy the adapter contract.
136
+ */
132
137
  invalidate() {
133
- cache.clear();
138
+ memo.invalidate();
134
139
  }
135
140
  };
136
141
  }
@@ -277,23 +282,16 @@ function createNotificationChannelCatalog(connectorChannels = []) {
277
282
  */
278
283
  function createDbFirstResolver(options) {
279
284
  const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
280
- const cache = /* @__PURE__ */ new Map();
281
- const lookup = async (key) => {
282
- if (cacheTtlMs > 0) {
283
- const hit = cache.get(key);
284
- if (hit && Date.now() - hit.at < cacheTtlMs) return hit.doc;
285
- }
286
- const doc = await store.getByKey(key);
287
- if (cacheTtlMs > 0) cache.set(key, {
288
- doc,
289
- at: Date.now()
290
- });
291
- return doc;
292
- };
285
+ /**
286
+ * Single-flight memo: one event's channel expansions resolve the same key in
287
+ * the same tick, and a `null` row (no draft) is an answer worth keeping. A
288
+ * store failure rejects every waiter and caches nothing.
289
+ */
290
+ const lookup = createTtlMemo((key) => store.getByKey(key), { ttlMs: cacheTtlMs });
293
291
  const resolver = (async (templateId, data) => {
294
292
  let doc = null;
295
293
  try {
296
- doc = await lookup(templateId);
294
+ doc = await lookup.get(templateId);
297
295
  } catch (err) {
298
296
  onError?.(err, templateId);
299
297
  if (!fallback) throw err;
@@ -308,8 +306,7 @@ function createDbFirstResolver(options) {
308
306
  return fallback(templateId, data);
309
307
  });
310
308
  resolver.invalidate = (key) => {
311
- if (key === void 0) cache.clear();
312
- else cache.delete(key);
309
+ lookup.invalidate(key);
313
310
  };
314
311
  return resolver;
315
312
  }
@@ -853,6 +850,31 @@ function notificationsModule(config) {
853
850
  }
854
851
  });
855
852
  }
853
+ const audienceSlot = Symbol("arc-notifications.resolvedAudience");
854
+ /** Content key for an audience — key order must not fragment the memo. */
855
+ function audienceKey(audience) {
856
+ return JSON.stringify(audience, (_k, v) => v !== null && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : 1)) : v);
857
+ }
858
+ /**
859
+ * Resolve an audience ONCE per dispatch, shared by every expansion that asks.
860
+ *
861
+ * The resolver is a database read in every real host (members by role, then
862
+ * users for contact); N channel expansions of one trigger hand it the same
863
+ * audience. Keyed on CONTENT, so two triggers asking the same question share
864
+ * too. A rejected resolution is dropped so the next expansion retries.
865
+ * Outside a work scope nothing is shared.
866
+ */
867
+ function resolveAudience(resolver, audience, event) {
868
+ const memo = scopedValue(audienceSlot, () => /* @__PURE__ */ new Map());
869
+ if (!memo) return Promise.resolve(resolver(audience, event));
870
+ const key = audienceKey(audience);
871
+ const pending = memo.get(key);
872
+ if (pending) return pending;
873
+ const created = Promise.resolve(resolver(audience, event));
874
+ memo.set(key, created);
875
+ created.catch(() => memo.delete(key));
876
+ return created;
877
+ }
856
878
  /** One event → N sends. Exported for direct unit testing. */
857
879
  async function dispatchTrigger(service, trigger, event, opts) {
858
880
  let resolved;
@@ -860,7 +882,7 @@ async function dispatchTrigger(service, trigger, event, opts) {
860
882
  else if (trigger.audience) {
861
883
  const audience = await trigger.audience(event);
862
884
  if (audience === null || audience === void 0) return;
863
- resolved = opts?.recipientResolver ? await opts.recipientResolver(audience, event) : void 0;
885
+ resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
864
886
  }
865
887
  if (!resolved) return;
866
888
  const recipients = Array.isArray(resolved) ? resolved : [resolved];
@@ -1154,6 +1176,33 @@ function templatesResource(options) {
1154
1176
  //#endregion
1155
1177
  //#region src/triggerAdapter.ts
1156
1178
  /**
1179
+ * Trigger registry → `notifyOn` config.
1180
+ *
1181
+ * The host writes a flat, readable registry of `NotificationTrigger`s (what
1182
+ * fires, who gets it, what it says). The module wants `NotifyTrigger`s
1183
+ * (channels, audience resolver, data resolver, in-app block). This is the
1184
+ * conversion, and it is identical in every host — only the registry DATA differs.
1185
+ *
1186
+ * Moved here from `@spinekit/core/notifications` (2026-08-14) so it returns the
1187
+ * REAL `NotifyTrigger` instead of a hand-maintained structural mirror.
1188
+ *
1189
+ * ## What is BAKED IN
1190
+ * - customer-audience triggers get NO in-app channel. Buyers are not dashboard
1191
+ * users; a feed row for them is a row nobody can ever read.
1192
+ * - a trigger's `channels` list appends those channels unconditionally.
1193
+ * Hub-backed channels no-op when no provider is connected, so naming a
1194
+ * channel is always safe — the provider's presence IS the gate. Channel
1195
+ * names are OPEN: this package never enumerates the product's connectors.
1196
+ * - `extract` returning `null` yields a `null` audience, which the module
1197
+ * treats as "skip". This is how a trigger filters a broad event.
1198
+ * - a customer trigger whose `extract` returns no `recipient` also skips,
1199
+ * rather than dispatching to nobody.
1200
+ *
1201
+ * ## What stays HOST
1202
+ * - the registry itself (business copy, roles, which events matter)
1203
+ * - any entity kinds beyond the defaults, via `entityIdVars`
1204
+ */
1205
+ /**
1157
1206
  * `entityType` → the `extract` variable holding that entity's id.
1158
1207
  *
1159
1208
  * The in-app row wants an `entityId` for deep-linking and badge grouping, but
@@ -1351,14 +1400,34 @@ function toGatedNotifyTriggers(t, options, overrides) {
1351
1400
  message: t.template.message,
1352
1401
  ...t.priority ? { priority: t.priority } : {}
1353
1402
  };
1403
+ /**
1404
+ * The effective settings resolve ONCE per event per trigger, shared by every
1405
+ * channel expansion below, memoised on the work scope arc enters per
1406
+ * dispatch. The slot is per TRIGGER (a fresh symbol per call), so two
1407
+ * triggers on one event never share a resolution even when they share a
1408
+ * type. Outside a scope there is nothing to share — one read per expansion,
1409
+ * slower, never wrong.
1410
+ */
1411
+ const slot = Symbol("arc-notifications.effectiveSettings");
1412
+ const effectiveFor = (organizationId) => {
1413
+ const load = async () => resolveEffectiveSettings(type, organizationId, await overrides.store.forOrganization(organizationId), defaults);
1414
+ const memo = scopedValue(slot, () => /* @__PURE__ */ new Map());
1415
+ if (!memo) return load();
1416
+ const key = organizationId ?? "__company__";
1417
+ const pending = memo.get(key);
1418
+ if (pending) return pending;
1419
+ const created = load();
1420
+ memo.set(key, created);
1421
+ created.catch(() => memo.delete(key));
1422
+ return created;
1423
+ };
1354
1424
  return candidateChannels(t, options.catalog).map((channel) => ({
1355
1425
  ...base,
1356
1426
  channels: [channel],
1357
1427
  audience: async (e) => {
1358
1428
  const ex = t.extract(e.payload ?? {});
1359
1429
  if (!ex) return null;
1360
- const rows = await overrides.store.forOrganization(ex.organizationId ?? null);
1361
- const eff = resolveEffectiveSettings(type, ex.organizationId ?? null, rows, defaults);
1430
+ const eff = await effectiveFor(ex.organizationId ?? null);
1362
1431
  /**
1363
1432
  * Report a skip so it is COUNTABLE, then skip.
1364
1433
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-notifications",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Notifications module for @classytic/arc — domain events → multi-channel notifications via @classytic/notifications, with DB-draftable templates, repo-backed delivery log, and a templates resource",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,10 +29,10 @@
29
29
  "prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
30
30
  },
31
31
  "peerDependencies": {
32
- "@classytic/arc": ">=2.35.0",
32
+ "@classytic/arc": ">=2.40.0",
33
33
  "@classytic/notifications": ">=2.5.0",
34
- "@classytic/primitives": ">=0.25.0",
35
- "@classytic/repo-core": ">=0.25.0"
34
+ "@classytic/primitives": ">=0.27.0",
35
+ "@classytic/repo-core": ">=0.27.1"
36
36
  },
37
37
  "keywords": [
38
38
  "arc",