@classytic/arc-notifications 0.6.3 → 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.
- package/dist/index.mjs +102 -33
- 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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
32
|
+
"@classytic/arc": ">=2.40.0",
|
|
33
33
|
"@classytic/notifications": ">=2.5.0",
|
|
34
|
-
"@classytic/primitives": ">=0.
|
|
35
|
-
"@classytic/repo-core": ">=0.
|
|
34
|
+
"@classytic/primitives": ">=0.27.0",
|
|
35
|
+
"@classytic/repo-core": ">=0.27.1"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"arc",
|