@absolutejs/sync-pack-digest 0.1.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/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # @absolutejs/sync-pack-digest
2
+
3
+ Scheduled per-actor digest emails as a sync pack for
4
+ [`@absolutejs/sync`](https://github.com/absolutejs/sync). Cron-fires a
5
+ schedule that iterates the host's actor list, asks the host to build a
6
+ digest payload, and dispatches it through a host-provided `send` adapter.
7
+ The pack does NOT ship an SMTP client — you bring your own (Resend, SES,
8
+ Postmark, or a CLI for tests).
9
+
10
+ ```bash
11
+ bun add @absolutejs/sync-pack-digest
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```ts
17
+ import { createSyncEngine } from '@absolutejs/sync/engine';
18
+ import { createDigestPack } from '@absolutejs/sync-pack-digest';
19
+
20
+ const engine = createSyncEngine();
21
+ engine.registerPack(
22
+ createDigestPack({
23
+ // Cron pattern for the digest fire. Default '0 8 * * 1' (Mon 8am).
24
+ cron: '0 8 * * 1',
25
+
26
+ // REQUIRED: yield the actor ids to iterate per fire. The host's
27
+ // data layer is the source of truth for "who exists" — the pack
28
+ // just maintains per-actor cursors.
29
+ listActors: () => db.users.allActiveIds(),
30
+
31
+ // REQUIRED: build the payload for one actor. `since` is the actor's
32
+ // last successful send (or null on first-ever). Return null to skip
33
+ // this actor silently (no content this week).
34
+ buildDigest: async (actorId, since) => {
35
+ const items = await db.feed.since(actorId, since ?? new Date(0));
36
+ if (items.length === 0) return null;
37
+ return {
38
+ to: await db.users.email(actorId),
39
+ subject: `Your weekly digest`,
40
+ body: render(items),
41
+ };
42
+ },
43
+
44
+ // REQUIRED: host's transport adapter. Pack catches throws per
45
+ // actor so one bad send doesn't block the rest.
46
+ send: async (msg) => await resend.send(msg),
47
+
48
+ // OPTIONAL: back-pressure. Default 1000.
49
+ maxActorsPerFire: 1000,
50
+
51
+ // OPTIONAL: per-actor cool-down. Default 168 hours = 1 week. Match
52
+ // to your cron — weekly cron + 168h = each actor gets one digest
53
+ // per fire; daily cron + 168h = each actor gets at most one per week.
54
+ minHoursBetweenDigests: 168,
55
+
56
+ // OPTIONAL: surface failures (default logs to console.error).
57
+ onActorFailure: ({ actorId, phase, error }) => {
58
+ logger.warn({ actorId, phase, error }, 'digest failure');
59
+ },
60
+
61
+ // OPTIONAL: outer schedule retry (sync 1.9.0+). Defaults to no
62
+ // retry — per-actor failures are handled internally; this is for
63
+ // transient infra failures (DB, etc) of the whole fire. The next
64
+ // cron fire catches up anyway.
65
+ // retry: { maxAttempts: 3, backoff: exponentialBackoff() },
66
+ })
67
+ );
68
+ ```
69
+
70
+ ## The pack exposes
71
+
72
+ | Surface | Name | What it does |
73
+ | ----------- | ----------------- | -------------------------------------------------------------------------- |
74
+ | Collection | `digest_cursors` | Subscribe (no params) — each actor sees their own `lastSentAt` cursor |
75
+ | Schedule | `digest:fire` | Cron handler. Call `engine.runSchedule('digest:fire')` directly for tests |
76
+
77
+ ## Failure semantics
78
+
79
+ Three failure phases, each independent per actor:
80
+
81
+ | Phase | Cause | Effect |
82
+ | ------------- | ------------------------------ | ------------------------------------------------- |
83
+ | `buildDigest` | Host's content builder threw | `onActorFailure` fires; cursor unchanged; skip |
84
+ | `send` | Host's transport adapter threw | `onActorFailure` fires; cursor unchanged; skip |
85
+ | `cursor` | Cursor update failed post-send | `onActorFailure` fires (email already went out) |
86
+
87
+ A failure on actor N doesn't affect actor N+1; the schedule keeps going.
88
+
89
+ The `cursor` phase is the one to watch: the email already went out but the
90
+ cursor didn't update, so the actor may double-receive next fire. The pack
91
+ logs through `onActorFailure` so the operator notices.
92
+
93
+ ## Cursor row
94
+
95
+ ```ts
96
+ type DigestCursor = {
97
+ id: string; // = actorId
98
+ actorId: string;
99
+ lastSentAt: number; // epoch ms
100
+ lastSubject: string;
101
+ };
102
+ ```
103
+
104
+ The collection is scoped per actor by default (each actor sees only their
105
+ own cursor — useful for a "last digest: 5 days ago" UI). Hosts who want
106
+ an admin view can register their own permissions on top — host wins-last.
107
+
108
+ ## Storage
109
+
110
+ Default: per-instance in-memory cursor store. For a persistent backend,
111
+ pass a custom `store`:
112
+
113
+ ```ts
114
+ import {
115
+ createDigestPack,
116
+ type DigestStore,
117
+ } from '@absolutejs/sync-pack-digest';
118
+
119
+ const store: DigestStore = {
120
+ getById: (id) => /* SELECT * FROM digest_cursors WHERE id = $1 */,
121
+ reader: { all: () => /* SELECT * FROM digest_cursors */ },
122
+ writer: { insert, update, delete },
123
+ };
124
+ ```
125
+
126
+ ## Multiple instances
127
+
128
+ Pass a `prefix` to coexist with other digest pack instances (e.g. one per
129
+ product surface, with different crons / templates):
130
+
131
+ ```ts
132
+ engine.registerPack(createDigestPack({ prefix: 'team_', /* ... */ }));
133
+ engine.registerPack(createDigestPack({ prefix: 'cust_', /* ... */ }));
134
+
135
+ // Tables: team_digest_cursors, cust_digest_cursors
136
+ // Schedules: team_digest:fire, cust_digest:fire
137
+ // Collections: team_digest_cursors, cust_digest_cursors
138
+ ```
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `@absolutejs/sync-pack-digest` — scheduled per-actor digest pack for
3
+ * `@absolutejs/sync`.
4
+ *
5
+ * Cron-fires a schedule that iterates the host's actor list, asks the host
6
+ * to build a digest payload for each actor since their last cursor, and
7
+ * dispatches it through a host-provided `send` adapter. Owns one table
8
+ * (`digest_cursors`) with one row per actor; nothing else.
9
+ *
10
+ * Per-actor failures are caught and logged so one bad send doesn't block
11
+ * the rest. The whole-handler retry shim from sync 1.9.0 is wired but off
12
+ * by default (digest fires are usually fine to skip on transient infra
13
+ * failures — the next cron fire catches up).
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { createSyncEngine } from '@absolutejs/sync/engine';
18
+ * import { createDigestPack } from '@absolutejs/sync-pack-digest';
19
+ *
20
+ * const engine = createSyncEngine();
21
+ * engine.registerPack(createDigestPack({
22
+ * cron: '0 8 * * 1', // Mondays 8am
23
+ * listActors: () => db.users.allActiveIds(),
24
+ * buildDigest: async (actorId, since) => {
25
+ * const items = await db.feed.since(actorId, since ?? new Date(0));
26
+ * if (items.length === 0) return null;
27
+ * return {
28
+ * to: await db.users.email(actorId),
29
+ * subject: `Your weekly digest`,
30
+ * body: render(items),
31
+ * };
32
+ * },
33
+ * send: async (msg) => await resend.send(msg),
34
+ * }));
35
+ * ```
36
+ */
37
+ import { type CollectionContext, type RetryPolicy, type SyncPack, type TableReader, type TableWriter } from '@absolutejs/sync/engine';
38
+ /** One row per actor — created on first successful send, updated after each. */
39
+ export type DigestCursor = {
40
+ /** Row id (= actorId). */
41
+ id: string;
42
+ actorId: string;
43
+ /** Epoch ms of the most recent successful send. */
44
+ lastSentAt: number;
45
+ /** Subject line of the most recent successful send (for observability). */
46
+ lastSubject: string;
47
+ };
48
+ /** The payload the host's `buildDigest` returns. Returning `null` skips. */
49
+ export type DigestPayload = {
50
+ to: string;
51
+ subject: string;
52
+ body: string;
53
+ };
54
+ /** Storage adapter the pack uses for the cursors table. */
55
+ export type DigestStore = {
56
+ reader: TableReader<CollectionContext>;
57
+ writer: TableWriter<DigestCursor, CollectionContext, unknown>;
58
+ getById: (id: string) => DigestCursor | undefined;
59
+ };
60
+ /** Default in-memory store. */
61
+ export declare const createInMemoryDigestStore: () => DigestStore;
62
+ /** Factory config. `send`, `buildDigest`, and `listActors` are required. */
63
+ export type DigestPackConfig<Ctx = CollectionContext> = {
64
+ /**
65
+ * Prefix applied to the owned table name AND every collection/schedule
66
+ * the pack exposes. Default `""`.
67
+ */
68
+ prefix?: string;
69
+ /**
70
+ * Cron pattern for the digest fire (`@elysiajs/cron` / croner syntax).
71
+ * Default `"0 8 * * 1"` (Mondays 08:00).
72
+ */
73
+ cron?: string;
74
+ /**
75
+ * Host-supplied email sender. The pack does NOT own transport (no SMTP
76
+ * client, no Resend/SES/Postmark dep). Throws are caught per-actor.
77
+ */
78
+ send: (msg: DigestPayload) => Promise<void>;
79
+ /**
80
+ * Build the digest payload for one actor. `since` is the actor's last
81
+ * successful send timestamp, or `null` if they've never received one.
82
+ * Return `null` to skip this fire silently (no content for this actor).
83
+ */
84
+ buildDigest: (actorId: string, since: Date | null) => Promise<DigestPayload | null>;
85
+ /**
86
+ * Yield the actor ids the schedule should iterate per fire. The host's
87
+ * own data layer is the source of truth for "who exists" — the pack
88
+ * just maintains per-actor cursors.
89
+ */
90
+ listActors: () => Iterable<string> | Promise<Iterable<string>>;
91
+ /**
92
+ * Cap on actors processed per fire (back-pressure). The rest wait for
93
+ * the next cron fire. Default `1000`.
94
+ */
95
+ maxActorsPerFire?: number;
96
+ /**
97
+ * Minimum hours between digests for a given actor. An actor whose
98
+ * cursor is fresher than this is skipped on the fire. Default `168`
99
+ * (one week — matches the weekly default cron).
100
+ */
101
+ minHoursBetweenDigests?: number;
102
+ /**
103
+ * Extract the current actor id from ctx — used to scope the cursor
104
+ * collection so each actor sees their own row. Default
105
+ * `(ctx) => ctx.userId`.
106
+ */
107
+ getActorId?: (ctx: Ctx) => string | undefined;
108
+ /**
109
+ * Outer schedule retry policy. The pack catches per-actor failures
110
+ * already, so this only retries the whole handler on classified
111
+ * transient infrastructure failures (e.g. DB serialization). Default
112
+ * undefined — digest fires miss skip on infra failure and catch up
113
+ * next cron.
114
+ */
115
+ retry?: RetryPolicy;
116
+ /** Override the storage adapter. Default: per-instance in-memory. */
117
+ store?: DigestStore;
118
+ /** Wall-clock function. Default `Date.now`. */
119
+ now?: () => number;
120
+ /**
121
+ * Hook for surfacing per-actor failures. Defaults to `console.error`.
122
+ * The pack swallows the error after this call so the schedule keeps
123
+ * going for the rest of the actor list.
124
+ */
125
+ onActorFailure?: (info: {
126
+ actorId: string;
127
+ phase: 'buildDigest' | 'send' | 'cursor';
128
+ error: unknown;
129
+ }) => void;
130
+ };
131
+ /**
132
+ * Build a {@link SyncPack} that exposes scheduled per-actor digests. Each
133
+ * call returns a fresh pack with its own cursor store (unless overridden).
134
+ */
135
+ export declare const createDigestPack: <Ctx = CollectionContext>(config: DigestPackConfig<Ctx>) => SyncPack;
package/dist/index.js ADDED
@@ -0,0 +1,189 @@
1
+ // @bun
2
+ // src/index.ts
3
+ import {
4
+ defineCollection,
5
+ defineSchedule,
6
+ defineSchema,
7
+ defineSyncPack,
8
+ field
9
+ } from "@absolutejs/sync/engine";
10
+ var createInMemoryDigestStore = () => {
11
+ const rows = new Map;
12
+ return {
13
+ getById: (id) => rows.get(id),
14
+ reader: {
15
+ all: () => [...rows.values()]
16
+ },
17
+ writer: {
18
+ delete: (row) => {
19
+ rows.delete(row.id);
20
+ },
21
+ insert: (data) => {
22
+ rows.set(data.id, data);
23
+ return data;
24
+ },
25
+ update: (data) => {
26
+ const prior = rows.get(data.id);
27
+ const merged = { ...prior ?? {}, ...data };
28
+ rows.set(data.id, merged);
29
+ return merged;
30
+ }
31
+ }
32
+ };
33
+ };
34
+ var DEFAULT_CRON = "0 8 * * 1";
35
+ var DEFAULT_MAX_ACTORS_PER_FIRE = 1000;
36
+ var DEFAULT_MIN_HOURS_BETWEEN_DIGESTS = 168;
37
+ var resolveActor = (getActorId, ctx) => getActorId(ctx);
38
+ var createDigestPack = (config) => {
39
+ const prefix = config.prefix ?? "";
40
+ const table = `${prefix}digest_cursors`;
41
+ const collectionName = table;
42
+ const scheduleName = `${prefix}digest:fire`;
43
+ const cron = config.cron ?? DEFAULT_CRON;
44
+ const maxActorsPerFire = config.maxActorsPerFire ?? DEFAULT_MAX_ACTORS_PER_FIRE;
45
+ const minIntervalMs = (config.minHoursBetweenDigests ?? DEFAULT_MIN_HOURS_BETWEEN_DIGESTS) * 60 * 60 * 1000;
46
+ const store = config.store ?? createInMemoryDigestStore();
47
+ const now = config.now ?? Date.now;
48
+ const getActorId = config.getActorId ?? ((ctx) => ctx.userId);
49
+ const onActorFailure = config.onActorFailure ?? ((info) => {
50
+ console.error(`[sync-pack-digest] actor "${info.actorId}" failed at ${info.phase}:`, info.error);
51
+ });
52
+ const pack = {
53
+ name: "@absolutejs/sync-pack-digest",
54
+ ownsTables: [table],
55
+ readsTables: [],
56
+ version: "0.1.0",
57
+ schemas: defineSchema({
58
+ [table]: {
59
+ fields: {
60
+ id: field.string,
61
+ actorId: field.string,
62
+ lastSentAt: field.number,
63
+ lastSubject: field.string
64
+ }
65
+ }
66
+ }),
67
+ readers: { [table]: store.reader },
68
+ writers: { [table]: store.writer },
69
+ permissions: {
70
+ [table]: {
71
+ read: (ctx, row) => {
72
+ const callerId = resolveActor(getActorId, ctx);
73
+ return callerId !== undefined && row.actorId === callerId;
74
+ },
75
+ insert: (ctx, row) => {
76
+ const callerId = resolveActor(getActorId, ctx);
77
+ return callerId !== undefined && row.actorId === callerId;
78
+ },
79
+ update: (ctx, row) => {
80
+ const callerId = resolveActor(getActorId, ctx);
81
+ return callerId !== undefined && row.actorId === callerId;
82
+ },
83
+ delete: (ctx, row) => {
84
+ const callerId = resolveActor(getActorId, ctx);
85
+ if (callerId === undefined)
86
+ return false;
87
+ if (row.actorId !== undefined) {
88
+ return row.actorId === callerId;
89
+ }
90
+ const id = row.id;
91
+ if (id === undefined)
92
+ return false;
93
+ return store.getById(id)?.actorId === callerId;
94
+ }
95
+ }
96
+ },
97
+ collections: [
98
+ defineCollection({
99
+ name: collectionName,
100
+ tables: [table],
101
+ key: (row) => row.id,
102
+ hydrate: (_params, ctx) => {
103
+ const callerId = resolveActor(getActorId, ctx);
104
+ if (callerId === undefined)
105
+ return [];
106
+ return store.reader.all(ctx).filter((row) => row.actorId === callerId);
107
+ },
108
+ match: (row, _params, ctx) => {
109
+ const callerId = resolveActor(getActorId, ctx);
110
+ return callerId !== undefined && row.actorId === callerId;
111
+ },
112
+ authorize: (_params, ctx) => resolveActor(getActorId, ctx) !== undefined
113
+ })
114
+ ],
115
+ schedules: [
116
+ defineSchedule({
117
+ name: scheduleName,
118
+ pattern: cron,
119
+ ...config.retry !== undefined ? { retry: config.retry } : {},
120
+ run: async ({ actions }) => {
121
+ const t = now();
122
+ const actors = await config.listActors();
123
+ let processed = 0;
124
+ for (const actorId of actors) {
125
+ if (processed >= maxActorsPerFire)
126
+ break;
127
+ const cursor = store.getById(actorId);
128
+ if (cursor !== undefined && t - cursor.lastSentAt < minIntervalMs) {
129
+ continue;
130
+ }
131
+ let payload;
132
+ try {
133
+ const since = cursor === undefined ? null : new Date(cursor.lastSentAt);
134
+ payload = await config.buildDigest(actorId, since);
135
+ } catch (error) {
136
+ onActorFailure({
137
+ actorId,
138
+ error,
139
+ phase: "buildDigest"
140
+ });
141
+ continue;
142
+ }
143
+ if (payload === null) {
144
+ continue;
145
+ }
146
+ try {
147
+ await config.send(payload);
148
+ } catch (error) {
149
+ onActorFailure({
150
+ actorId,
151
+ error,
152
+ phase: "send"
153
+ });
154
+ continue;
155
+ }
156
+ processed++;
157
+ try {
158
+ const next = {
159
+ id: actorId,
160
+ actorId,
161
+ lastSentAt: t,
162
+ lastSubject: payload.subject
163
+ };
164
+ if (cursor === undefined) {
165
+ await actions.insert(table, next);
166
+ } else {
167
+ await actions.update(table, next);
168
+ }
169
+ } catch (error) {
170
+ onActorFailure({
171
+ actorId,
172
+ error,
173
+ phase: "cursor"
174
+ });
175
+ }
176
+ }
177
+ }
178
+ })
179
+ ]
180
+ };
181
+ return defineSyncPack(pack);
182
+ };
183
+ export {
184
+ createInMemoryDigestStore,
185
+ createDigestPack
186
+ };
187
+
188
+ //# debugId=AE598FAB1C126FB164756E2164756E21
189
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * `@absolutejs/sync-pack-digest` — scheduled per-actor digest pack for\n * `@absolutejs/sync`.\n *\n * Cron-fires a schedule that iterates the host's actor list, asks the host\n * to build a digest payload for each actor since their last cursor, and\n * dispatches it through a host-provided `send` adapter. Owns one table\n * (`digest_cursors`) with one row per actor; nothing else.\n *\n * Per-actor failures are caught and logged so one bad send doesn't block\n * the rest. The whole-handler retry shim from sync 1.9.0 is wired but off\n * by default (digest fires are usually fine to skip on transient infra\n * failures — the next cron fire catches up).\n *\n * @example\n * ```ts\n * import { createSyncEngine } from '@absolutejs/sync/engine';\n * import { createDigestPack } from '@absolutejs/sync-pack-digest';\n *\n * const engine = createSyncEngine();\n * engine.registerPack(createDigestPack({\n * cron: '0 8 * * 1', // Mondays 8am\n * listActors: () => db.users.allActiveIds(),\n * buildDigest: async (actorId, since) => {\n * const items = await db.feed.since(actorId, since ?? new Date(0));\n * if (items.length === 0) return null;\n * return {\n * to: await db.users.email(actorId),\n * subject: `Your weekly digest`,\n * body: render(items),\n * };\n * },\n * send: async (msg) => await resend.send(msg),\n * }));\n * ```\n */\n\nimport {\n\tdefineCollection,\n\tdefineSchedule,\n\tdefineSchema,\n\tdefineSyncPack,\n\tfield,\n\ttype CollectionContext,\n\ttype RetryPolicy,\n\ttype SyncPack,\n\ttype TableReader,\n\ttype TableWriter\n} from '@absolutejs/sync/engine';\n\n/** One row per actor — created on first successful send, updated after each. */\nexport type DigestCursor = {\n\t/** Row id (= actorId). */\n\tid: string;\n\tactorId: string;\n\t/** Epoch ms of the most recent successful send. */\n\tlastSentAt: number;\n\t/** Subject line of the most recent successful send (for observability). */\n\tlastSubject: string;\n};\n\n/** The payload the host's `buildDigest` returns. Returning `null` skips. */\nexport type DigestPayload = {\n\tto: string;\n\tsubject: string;\n\tbody: string;\n};\n\n/** Storage adapter the pack uses for the cursors table. */\nexport type DigestStore = {\n\treader: TableReader<CollectionContext>;\n\twriter: TableWriter<DigestCursor, CollectionContext, unknown>;\n\tgetById: (id: string) => DigestCursor | undefined;\n};\n\n/** Default in-memory store. */\nexport const createInMemoryDigestStore = (): DigestStore => {\n\tconst rows = new Map<string, DigestCursor>();\n\treturn {\n\t\tgetById: (id) => rows.get(id),\n\t\treader: {\n\t\t\tall: () => [...rows.values()]\n\t\t},\n\t\twriter: {\n\t\t\tdelete: (row) => {\n\t\t\t\trows.delete((row as { id: string }).id);\n\t\t\t},\n\t\t\tinsert: (data) => {\n\t\t\t\trows.set(data.id, data);\n\t\t\t\treturn data;\n\t\t\t},\n\t\t\tupdate: (data) => {\n\t\t\t\tconst prior = rows.get(data.id);\n\t\t\t\tconst merged = { ...(prior ?? {}), ...data } as DigestCursor;\n\t\t\t\trows.set(data.id, merged);\n\t\t\t\treturn merged;\n\t\t\t}\n\t\t}\n\t};\n};\n\nconst DEFAULT_CRON = '0 8 * * 1';\nconst DEFAULT_MAX_ACTORS_PER_FIRE = 1000;\nconst DEFAULT_MIN_HOURS_BETWEEN_DIGESTS = 168;\n\n/** Factory config. `send`, `buildDigest`, and `listActors` are required. */\nexport type DigestPackConfig<Ctx = CollectionContext> = {\n\t/**\n\t * Prefix applied to the owned table name AND every collection/schedule\n\t * the pack exposes. Default `\"\"`.\n\t */\n\tprefix?: string;\n\t/**\n\t * Cron pattern for the digest fire (`@elysiajs/cron` / croner syntax).\n\t * Default `\"0 8 * * 1\"` (Mondays 08:00).\n\t */\n\tcron?: string;\n\t/**\n\t * Host-supplied email sender. The pack does NOT own transport (no SMTP\n\t * client, no Resend/SES/Postmark dep). Throws are caught per-actor.\n\t */\n\tsend: (msg: DigestPayload) => Promise<void>;\n\t/**\n\t * Build the digest payload for one actor. `since` is the actor's last\n\t * successful send timestamp, or `null` if they've never received one.\n\t * Return `null` to skip this fire silently (no content for this actor).\n\t */\n\tbuildDigest: (\n\t\tactorId: string,\n\t\tsince: Date | null\n\t) => Promise<DigestPayload | null>;\n\t/**\n\t * Yield the actor ids the schedule should iterate per fire. The host's\n\t * own data layer is the source of truth for \"who exists\" — the pack\n\t * just maintains per-actor cursors.\n\t */\n\tlistActors: () =>\n\t\t| Iterable<string>\n\t\t| Promise<Iterable<string>>;\n\t/**\n\t * Cap on actors processed per fire (back-pressure). The rest wait for\n\t * the next cron fire. Default `1000`.\n\t */\n\tmaxActorsPerFire?: number;\n\t/**\n\t * Minimum hours between digests for a given actor. An actor whose\n\t * cursor is fresher than this is skipped on the fire. Default `168`\n\t * (one week — matches the weekly default cron).\n\t */\n\tminHoursBetweenDigests?: number;\n\t/**\n\t * Extract the current actor id from ctx — used to scope the cursor\n\t * collection so each actor sees their own row. Default\n\t * `(ctx) => ctx.userId`.\n\t */\n\tgetActorId?: (ctx: Ctx) => string | undefined;\n\t/**\n\t * Outer schedule retry policy. The pack catches per-actor failures\n\t * already, so this only retries the whole handler on classified\n\t * transient infrastructure failures (e.g. DB serialization). Default\n\t * undefined — digest fires miss skip on infra failure and catch up\n\t * next cron.\n\t */\n\tretry?: RetryPolicy;\n\t/** Override the storage adapter. Default: per-instance in-memory. */\n\tstore?: DigestStore;\n\t/** Wall-clock function. Default `Date.now`. */\n\tnow?: () => number;\n\t/**\n\t * Hook for surfacing per-actor failures. Defaults to `console.error`.\n\t * The pack swallows the error after this call so the schedule keeps\n\t * going for the rest of the actor list.\n\t */\n\tonActorFailure?: (info: {\n\t\tactorId: string;\n\t\tphase: 'buildDigest' | 'send' | 'cursor';\n\t\terror: unknown;\n\t}) => void;\n};\n\nconst resolveActor = (\n\tgetActorId: NonNullable<DigestPackConfig['getActorId']>,\n\tctx: unknown\n): string | undefined => getActorId(ctx as CollectionContext);\n\n/**\n * Build a {@link SyncPack} that exposes scheduled per-actor digests. Each\n * call returns a fresh pack with its own cursor store (unless overridden).\n */\nexport const createDigestPack = <Ctx = CollectionContext>(\n\tconfig: DigestPackConfig<Ctx>\n): SyncPack => {\n\tconst prefix = config.prefix ?? '';\n\tconst table = `${prefix}digest_cursors`;\n\tconst collectionName = table;\n\tconst scheduleName = `${prefix}digest:fire`;\n\tconst cron = config.cron ?? DEFAULT_CRON;\n\tconst maxActorsPerFire =\n\t\tconfig.maxActorsPerFire ?? DEFAULT_MAX_ACTORS_PER_FIRE;\n\tconst minIntervalMs =\n\t\t(config.minHoursBetweenDigests ??\n\t\t\tDEFAULT_MIN_HOURS_BETWEEN_DIGESTS) *\n\t\t60 *\n\t\t60 *\n\t\t1000;\n\tconst store = config.store ?? createInMemoryDigestStore();\n\tconst now = config.now ?? Date.now;\n\tconst getActorId = (config.getActorId ??\n\t\t((ctx: CollectionContext) =>\n\t\t\t(ctx as { userId?: string }).userId)) as (\n\t\tctx: CollectionContext\n\t) => string | undefined;\n\tconst onActorFailure =\n\t\tconfig.onActorFailure ??\n\t\t((info) => {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.error(\n\t\t\t\t`[sync-pack-digest] actor \"${info.actorId}\" failed at ${info.phase}:`,\n\t\t\t\tinfo.error\n\t\t\t);\n\t\t});\n\n\tconst pack: SyncPack = {\n\t\tname: '@absolutejs/sync-pack-digest',\n\t\townsTables: [table],\n\t\treadsTables: [],\n\t\tversion: '0.1.0',\n\n\t\tschemas: defineSchema({\n\t\t\t[table]: {\n\t\t\t\tfields: {\n\t\t\t\t\tid: field.string,\n\t\t\t\t\tactorId: field.string,\n\t\t\t\t\tlastSentAt: field.number,\n\t\t\t\t\tlastSubject: field.string\n\t\t\t\t}\n\t\t\t}\n\t\t}),\n\n\t\treaders: { [table]: store.reader },\n\t\twriters: { [table]: store.writer },\n\n\t\tpermissions: {\n\t\t\t[table]: {\n\t\t\t\t// Each actor sees only their own cursor — the cursor row\n\t\t\t\t// surfaces \"your last digest was at X\" to the user without\n\t\t\t\t// leaking other actors' state. Hosts who want a global\n\t\t\t\t// admin view register their own permissions on top.\n\t\t\t\tread: (ctx: unknown, row: DigestCursor) => {\n\t\t\t\t\tconst callerId = resolveActor(getActorId, ctx);\n\t\t\t\t\treturn callerId !== undefined && row.actorId === callerId;\n\t\t\t\t},\n\t\t\t\t// Writes flow only through the pack's schedule; deny\n\t\t\t\t// outside writers by stamping actorId === caller, which\n\t\t\t\t// fails for any non-actor (the schedule runs trusted and\n\t\t\t\t// bypasses these checks).\n\t\t\t\tinsert: (ctx: unknown, row: DigestCursor) => {\n\t\t\t\t\tconst callerId = resolveActor(getActorId, ctx);\n\t\t\t\t\treturn callerId !== undefined && row.actorId === callerId;\n\t\t\t\t},\n\t\t\t\tupdate: (ctx: unknown, row: DigestCursor) => {\n\t\t\t\t\tconst callerId = resolveActor(getActorId, ctx);\n\t\t\t\t\treturn callerId !== undefined && row.actorId === callerId;\n\t\t\t\t},\n\t\t\t\tdelete: (ctx: unknown, row: DigestCursor) => {\n\t\t\t\t\tconst callerId = resolveActor(getActorId, ctx);\n\t\t\t\t\tif (callerId === undefined) return false;\n\t\t\t\t\tif ((row as { actorId?: string }).actorId !== undefined) {\n\t\t\t\t\t\treturn (row as { actorId: string }).actorId === callerId;\n\t\t\t\t\t}\n\t\t\t\t\tconst id = (row as { id?: string }).id;\n\t\t\t\t\tif (id === undefined) return false;\n\t\t\t\t\treturn store.getById(id)?.actorId === callerId;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\tcollections: [\n\t\t\tdefineCollection<DigestCursor, void, CollectionContext>({\n\t\t\t\tname: collectionName,\n\t\t\t\ttables: [table],\n\t\t\t\tkey: (row) => row.id,\n\t\t\t\thydrate: (_params, ctx) => {\n\t\t\t\t\tconst callerId = resolveActor(getActorId, ctx);\n\t\t\t\t\tif (callerId === undefined) return [];\n\t\t\t\t\treturn (store.reader.all(ctx) as DigestCursor[]).filter(\n\t\t\t\t\t\t(row) => row.actorId === callerId\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tmatch: (row, _params, ctx) => {\n\t\t\t\t\tconst callerId = resolveActor(getActorId, ctx);\n\t\t\t\t\treturn (\n\t\t\t\t\t\tcallerId !== undefined && row.actorId === callerId\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t\tauthorize: (_params, ctx) =>\n\t\t\t\t\tresolveActor(getActorId, ctx) !== undefined\n\t\t\t})\n\t\t],\n\n\t\tschedules: [\n\t\t\tdefineSchedule({\n\t\t\t\tname: scheduleName,\n\t\t\t\tpattern: cron,\n\t\t\t\t...(config.retry !== undefined ? { retry: config.retry } : {}),\n\t\t\t\trun: async ({ actions }) => {\n\t\t\t\t\tconst t = now();\n\t\t\t\t\tconst actors = await config.listActors();\n\t\t\t\t\tlet processed = 0;\n\t\t\t\t\tfor (const actorId of actors) {\n\t\t\t\t\t\tif (processed >= maxActorsPerFire) break;\n\t\t\t\t\t\tconst cursor = store.getById(actorId);\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tcursor !== undefined &&\n\t\t\t\t\t\t\tt - cursor.lastSentAt < minIntervalMs\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t// Not yet due for this actor.\n\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlet payload: DigestPayload | null;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst since =\n\t\t\t\t\t\t\t\tcursor === undefined\n\t\t\t\t\t\t\t\t\t? null\n\t\t\t\t\t\t\t\t\t: new Date(cursor.lastSentAt);\n\t\t\t\t\t\t\tpayload = await config.buildDigest(actorId, since);\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\tonActorFailure({\n\t\t\t\t\t\t\t\tactorId,\n\t\t\t\t\t\t\t\terror,\n\t\t\t\t\t\t\t\tphase: 'buildDigest'\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (payload === null) {\n\t\t\t\t\t\t\t// Host signalled no content for this actor — skip\n\t\t\t\t\t\t\t// without bumping the cursor.\n\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait config.send(payload);\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\tonActorFailure({\n\t\t\t\t\t\t\t\tactorId,\n\t\t\t\t\t\t\t\terror,\n\t\t\t\t\t\t\t\tphase: 'send'\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tprocessed++;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst next: DigestCursor = {\n\t\t\t\t\t\t\t\tid: actorId,\n\t\t\t\t\t\t\t\tactorId,\n\t\t\t\t\t\t\t\tlastSentAt: t,\n\t\t\t\t\t\t\t\tlastSubject: payload.subject\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tif (cursor === undefined) {\n\t\t\t\t\t\t\t\tawait actions.insert(table, next);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tawait actions.update(table, next);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t// The email already went out — losing the cursor\n\t\t\t\t\t\t\t// update means the actor may double-receive on\n\t\t\t\t\t\t\t// the next fire. We log so the operator sees it.\n\t\t\t\t\t\t\tonActorFailure({\n\t\t\t\t\t\t\t\tactorId,\n\t\t\t\t\t\t\t\terror,\n\t\t\t\t\t\t\t\tphase: 'cursor'\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})\n\t\t]\n\t};\n\n\treturn defineSyncPack(pack);\n};\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "AE598FAB1C126FB164756E2164756E21",
9
+ "names": []
10
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@absolutejs/sync-pack-digest",
3
+ "version": "0.1.0",
4
+ "description": "Scheduled per-actor digest pack for @absolutejs/sync — cron-fired, cursor-managed, transport-agnostic",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/absolutejs/sync-packs.git",
8
+ "directory": "digest"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "type": "module",
14
+ "license": "CC BY-NC 4.0",
15
+ "author": "Alex Kahn",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "absolutejs",
21
+ "sync",
22
+ "sync-pack",
23
+ "digest",
24
+ "scheduled",
25
+ "email"
26
+ ],
27
+ "scripts": {
28
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap --target=bun --external @absolutejs/sync && tsc --project tsconfig.build.json",
29
+ "test": "bun test",
30
+ "typecheck": "tsc --noEmit",
31
+ "format": "prettier --write \"./**/*.{ts,json,md}\"",
32
+ "release": "bun run format && bun run typecheck && bun run test && bun run build && bun publish"
33
+ },
34
+ "peerDependencies": {
35
+ "@absolutejs/sync": ">= 1.9.1"
36
+ },
37
+ "devDependencies": {
38
+ "@absolutejs/sync": "^1.9.1",
39
+ "@types/bun": "1.3.14",
40
+ "prettier": "3.5.3",
41
+ "typescript": "5.8.3"
42
+ },
43
+ "exports": {
44
+ ".": {
45
+ "types": "./dist/index.d.ts",
46
+ "import": "./dist/index.js",
47
+ "default": "./dist/index.js"
48
+ }
49
+ },
50
+ "files": ["dist", "README.md"]
51
+ }