easy-ping 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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +374 -0
  3. package/dist/adapter-f0OR2DhY.d.cts +200 -0
  4. package/dist/adapter-f0OR2DhY.d.ts +200 -0
  5. package/dist/adapters/drizzle.cjs +440 -0
  6. package/dist/adapters/drizzle.cjs.map +1 -0
  7. package/dist/adapters/drizzle.d.cts +991 -0
  8. package/dist/adapters/drizzle.d.ts +991 -0
  9. package/dist/adapters/drizzle.js +409 -0
  10. package/dist/adapters/drizzle.js.map +1 -0
  11. package/dist/adapters/mongodb.cjs +458 -0
  12. package/dist/adapters/mongodb.cjs.map +1 -0
  13. package/dist/adapters/mongodb.d.cts +32 -0
  14. package/dist/adapters/mongodb.d.ts +32 -0
  15. package/dist/adapters/mongodb.js +376 -0
  16. package/dist/adapters/mongodb.js.map +1 -0
  17. package/dist/browser.cjs +96 -0
  18. package/dist/browser.cjs.map +1 -0
  19. package/dist/browser.d.cts +45 -0
  20. package/dist/browser.d.ts +45 -0
  21. package/dist/browser.js +89 -0
  22. package/dist/browser.js.map +1 -0
  23. package/dist/chunk-BV3U542P.js +11 -0
  24. package/dist/chunk-BV3U542P.js.map +1 -0
  25. package/dist/chunk-NYDVNMKG.js +62 -0
  26. package/dist/chunk-NYDVNMKG.js.map +1 -0
  27. package/dist/chunk-OZSOIX23.js +120 -0
  28. package/dist/chunk-OZSOIX23.js.map +1 -0
  29. package/dist/chunk-PTGHMCCG.js +23 -0
  30. package/dist/chunk-PTGHMCCG.js.map +1 -0
  31. package/dist/chunk-W3BMEPPQ.js +50 -0
  32. package/dist/chunk-W3BMEPPQ.js.map +1 -0
  33. package/dist/client.cjs +160 -0
  34. package/dist/client.cjs.map +1 -0
  35. package/dist/client.d.cts +42 -0
  36. package/dist/client.d.ts +42 -0
  37. package/dist/client.js +158 -0
  38. package/dist/client.js.map +1 -0
  39. package/dist/index.cjs +920 -0
  40. package/dist/index.cjs.map +1 -0
  41. package/dist/index.d.cts +203 -0
  42. package/dist/index.d.ts +203 -0
  43. package/dist/index.js +723 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/node.cjs +49 -0
  46. package/dist/node.cjs.map +1 -0
  47. package/dist/node.d.cts +8 -0
  48. package/dist/node.d.ts +8 -0
  49. package/dist/node.js +46 -0
  50. package/dist/node.js.map +1 -0
  51. package/dist/plugin-Bqj9xAOS.d.cts +217 -0
  52. package/dist/plugin-bTJKmnlq.d.ts +217 -0
  53. package/dist/plugins/digests.cjs +206 -0
  54. package/dist/plugins/digests.cjs.map +1 -0
  55. package/dist/plugins/digests.d.cts +68 -0
  56. package/dist/plugins/digests.d.ts +68 -0
  57. package/dist/plugins/digests.js +201 -0
  58. package/dist/plugins/digests.js.map +1 -0
  59. package/dist/plugins/preferences-client.cjs +23 -0
  60. package/dist/plugins/preferences-client.cjs.map +1 -0
  61. package/dist/plugins/preferences-client.d.cts +29 -0
  62. package/dist/plugins/preferences-client.d.ts +29 -0
  63. package/dist/plugins/preferences-client.js +18 -0
  64. package/dist/plugins/preferences-client.js.map +1 -0
  65. package/dist/plugins/preferences-react.cjs +125 -0
  66. package/dist/plugins/preferences-react.cjs.map +1 -0
  67. package/dist/plugins/preferences-react.d.cts +40 -0
  68. package/dist/plugins/preferences-react.d.ts +40 -0
  69. package/dist/plugins/preferences-react.js +101 -0
  70. package/dist/plugins/preferences-react.js.map +1 -0
  71. package/dist/plugins/preferences.cjs +147 -0
  72. package/dist/plugins/preferences.cjs.map +1 -0
  73. package/dist/plugins/preferences.d.cts +22 -0
  74. package/dist/plugins/preferences.d.ts +22 -0
  75. package/dist/plugins/preferences.js +119 -0
  76. package/dist/plugins/preferences.js.map +1 -0
  77. package/dist/plugins/push.cjs +146 -0
  78. package/dist/plugins/push.cjs.map +1 -0
  79. package/dist/plugins/push.d.cts +60 -0
  80. package/dist/plugins/push.d.ts +60 -0
  81. package/dist/plugins/push.js +144 -0
  82. package/dist/plugins/push.js.map +1 -0
  83. package/dist/provider-CA45WVqt.d.cts +23 -0
  84. package/dist/provider-CA45WVqt.d.ts +23 -0
  85. package/dist/providers/resend.cjs +69 -0
  86. package/dist/providers/resend.cjs.map +1 -0
  87. package/dist/providers/resend.d.cts +20 -0
  88. package/dist/providers/resend.d.ts +20 -0
  89. package/dist/providers/resend.js +66 -0
  90. package/dist/providers/resend.js.map +1 -0
  91. package/dist/providers/web-push.cjs +211 -0
  92. package/dist/providers/web-push.cjs.map +1 -0
  93. package/dist/providers/web-push.d.cts +33 -0
  94. package/dist/providers/web-push.d.ts +33 -0
  95. package/dist/providers/web-push.js +193 -0
  96. package/dist/providers/web-push.js.map +1 -0
  97. package/dist/react.cjs +218 -0
  98. package/dist/react.cjs.map +1 -0
  99. package/dist/react.d.cts +57 -0
  100. package/dist/react.d.ts +57 -0
  101. package/dist/react.js +193 -0
  102. package/dist/react.js.map +1 -0
  103. package/dist/schema.cjs +263 -0
  104. package/dist/schema.cjs.map +1 -0
  105. package/dist/schema.d.cts +180 -0
  106. package/dist/schema.d.ts +180 -0
  107. package/dist/schema.js +200 -0
  108. package/dist/schema.js.map +1 -0
  109. package/dist/testing.cjs +342 -0
  110. package/dist/testing.cjs.map +1 -0
  111. package/dist/testing.d.cts +38 -0
  112. package/dist/testing.d.ts +38 -0
  113. package/dist/testing.js +340 -0
  114. package/dist/testing.js.map +1 -0
  115. package/package.json +168 -0
@@ -0,0 +1,217 @@
1
+ import { R as Recipient, C as Channel, f as DeliveryOutcome, k as PluginStore } from './adapter-f0OR2DhY.js';
2
+
3
+ declare class EasyPingError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ /** Thrown when a payload fails its Standard Schema. Developer error. */
7
+ declare class ValidationError extends EasyPingError {
8
+ readonly issues: readonly {
9
+ message: string;
10
+ path?: string;
11
+ }[];
12
+ constructor(type: string, issues: readonly {
13
+ message: string;
14
+ path?: string;
15
+ }[]);
16
+ }
17
+ /** Thrown at startup for a malformed config. Never at send time. */
18
+ declare class ConfigError extends EasyPingError {
19
+ constructor(message: string);
20
+ }
21
+ type Logger = {
22
+ warn: (message: string, meta?: Record<string, unknown>) => void;
23
+ error: (message: string, meta?: Record<string, unknown>) => void;
24
+ };
25
+ declare const consoleLogger: Logger;
26
+
27
+ type Promisable<T> = T | Promise<T>;
28
+ type PrepareContext = {
29
+ type: string;
30
+ recipients: readonly Recipient[];
31
+ };
32
+ type BeforeSendContext = {
33
+ type: string;
34
+ recipient: Recipient;
35
+ payload: unknown;
36
+ actorId: string | null;
37
+ dedupeKey: string | null;
38
+ /** Whatever this plugin's own prepare() returned for this send. */
39
+ prepared: unknown;
40
+ };
41
+ type BeforeSendResult = {
42
+ action: "continue";
43
+ payload?: unknown;
44
+ dedupeKey?: string;
45
+ } | {
46
+ action: "cancel";
47
+ reason: string;
48
+ };
49
+ type ChannelDecision = {
50
+ channel: Channel;
51
+ action: "send" | "skip" | "defer";
52
+ /** defer only. Written to notification_delivery.not_before. */
53
+ notBefore?: Date | undefined;
54
+ };
55
+ type ResolveChannelsContext = {
56
+ type: string;
57
+ recipient: Recipient;
58
+ payload: unknown;
59
+ decisions: readonly ChannelDecision[];
60
+ /** Whatever this plugin's own prepare() returned for this send. */
61
+ prepared: unknown;
62
+ };
63
+ type DeliverContext = {
64
+ channel: Channel;
65
+ deliveryId: string;
66
+ attempt: number;
67
+ recipient: Recipient;
68
+ notification: {
69
+ id: string;
70
+ userId: string;
71
+ type: string;
72
+ payload: unknown;
73
+ };
74
+ /** Aborted on the runner's timeout. */
75
+ signal: AbortSignal;
76
+ };
77
+ type AfterSendContext = {
78
+ type: string;
79
+ payload: unknown;
80
+ /** Committed rows. Ids exist only at this point. */
81
+ notifications: readonly {
82
+ id: string;
83
+ userId: string;
84
+ }[];
85
+ /** Whatever this plugin's own prepare() returned for this send. */
86
+ prepared: unknown;
87
+ };
88
+ type AfterDeliverContext = {
89
+ deliveryId: string;
90
+ notificationId: string;
91
+ userId: string;
92
+ channel: Channel;
93
+ outcome: DeliveryOutcome;
94
+ attempt: number;
95
+ isTerminal: boolean;
96
+ };
97
+ /** beforeSend and resolveChannels fail closed; afterDeliver fails open. */
98
+ type PluginHooks = {
99
+ /** Once per send, for bulk loading — the per-recipient hooks would be an N+1. */
100
+ prepare?: (ctx: PrepareContext) => Promisable<unknown>;
101
+ beforeSend?: (ctx: BeforeSendContext) => Promisable<BeforeSendResult>;
102
+ resolveChannels?: (ctx: ResolveChannelsContext) => Promisable<readonly ChannelDecision[]>;
103
+ /**
104
+ * Once per send, after the rows are committed. The only hook that sees
105
+ * notification ids, which digests needs to bucket an entry against one.
106
+ * Fails open: the notification is already written.
107
+ */
108
+ afterSend?: (ctx: AfterSendContext) => Promisable<void>;
109
+ /**
110
+ * Delivers one of the plugin's declared `channels`. Core carries inApp and
111
+ * email; everything else arrives here, which is how push, sms and slack are
112
+ * added without core learning about them.
113
+ */
114
+ deliver?: (ctx: DeliverContext) => Promisable<DeliveryOutcome>;
115
+ afterDeliver?: (ctx: AfterDeliverContext) => Promisable<void>;
116
+ };
117
+ type FieldType = "string" | "number" | "boolean" | "date" | "json";
118
+ type FieldDeclaration = {
119
+ type: FieldType;
120
+ required?: boolean;
121
+ default?: string | number | boolean;
122
+ /** date fields only. Renders as defaultNow() / CURRENT_TIMESTAMP. */
123
+ defaultNow?: boolean;
124
+ };
125
+ type IndexDeclaration = {
126
+ on: readonly string[];
127
+ unique?: boolean;
128
+ name?: string;
129
+ };
130
+ type TableDeclaration = {
131
+ tableName: string;
132
+ fields: Record<string, FieldDeclaration>;
133
+ primaryKey?: readonly string[];
134
+ indexes?: readonly IndexDeclaration[];
135
+ };
136
+ type SchemaDeclaration = Record<string, TableDeclaration>;
137
+ type RouteScope = {
138
+ type: "user";
139
+ } | {
140
+ type: "machine";
141
+ } | {
142
+ type: "signed";
143
+ purpose: string;
144
+ } | {
145
+ type: "custom";
146
+ justification: string;
147
+ };
148
+ type RouteContext = {
149
+ request: Request;
150
+ /** Resolved for user-scoped routes, so a handler cannot forget to scope. */
151
+ userId: string | null;
152
+ /** Verified before dispatch, so a handler cannot forget to check. */
153
+ claims: {
154
+ uid: string;
155
+ purpose: string;
156
+ exp: number;
157
+ data?: Record<string, string>;
158
+ } | null;
159
+ params: Record<string, string>;
160
+ };
161
+ type RouteDefinition = {
162
+ path: string;
163
+ method: "GET" | "POST";
164
+ scope: RouteScope;
165
+ handler: (ctx: RouteContext) => Promise<Response>;
166
+ };
167
+ /** Handed to a plugin at startup so it need not be given the database twice. */
168
+ type PluginInitContext = {
169
+ /** Scoped to the tables this plugin declares in schema(). */
170
+ store: PluginStore;
171
+ /** The top-level signing secret, for plugins issuing signed links. */
172
+ secret: string;
173
+ logger: Logger;
174
+ /** The app's resolver, for plugins that need timezones or addresses. */
175
+ getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;
176
+ /**
177
+ * The instance's own send(). A plugin composing a message (a digest, say)
178
+ * routes it back through the pipeline rather than reaching for a provider,
179
+ * so it inherits retry, backoff and idempotency for free.
180
+ */
181
+ send: (type: string, args: {
182
+ to: string | readonly string[];
183
+ payload: unknown;
184
+ dedupeKey?: string;
185
+ }) => Promise<unknown>;
186
+ };
187
+ type EasyPingPlugin<TId extends string = string, TSchema extends SchemaDeclaration = SchemaDeclaration> = {
188
+ id: TId;
189
+ dependsOn?: readonly string[];
190
+ schema?: TSchema;
191
+ routes?: readonly RouteDefinition[];
192
+ hooks?: PluginHooks;
193
+ /** Channels this plugin delivers. Makes them usable in a notification. */
194
+ channels?: readonly Channel[];
195
+ /** Called once by easyPing() before any hook or route can run. */
196
+ init?: (ctx: PluginInitContext) => void;
197
+ };
198
+ type AnyPlugin = EasyPingPlugin<string, SchemaDeclaration>;
199
+ declare function definePlugin<TId extends string, TSchema extends SchemaDeclaration>(plugin: EasyPingPlugin<TId, TSchema>): EasyPingPlugin<TId, TSchema>;
200
+ type ClientFetch = (path: string, init?: RequestInit) => Promise<unknown>;
201
+ type ClientPlugin<TId extends string = string, TActions extends Record<string, unknown> = Record<string, unknown>> = {
202
+ id: TId;
203
+ /** Phantom. Links client types to the server plugin so a mismatch is a type error. */
204
+ $InferServerPlugin?: AnyPlugin;
205
+ actions: (fetch: ClientFetch) => TActions;
206
+ };
207
+ type AnyClientPlugin = ClientPlugin<string, Record<string, unknown>>;
208
+ declare function defineClientPlugin<TId extends string, TActions extends Record<string, unknown>>(plugin: ClientPlugin<TId, TActions>): ClientPlugin<TId, TActions>;
209
+ type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
210
+ /** Naked type parameter, so the conditional distributes over the union. */
211
+ type ExtractActions<TPlugin> = TPlugin extends {
212
+ actions: (fetch: never) => infer TActions;
213
+ } ? TActions : never;
214
+ /** Indexes the tuple rather than recursing: a fold blows the instantiation depth limit. */
215
+ type ActionsOf<TPlugins extends readonly AnyClientPlugin[]> = UnionToIntersection<ExtractActions<TPlugins[number]>>;
216
+
217
+ export { type AnyPlugin as A, type BeforeSendContext as B, type ChannelDecision as C, EasyPingError as E, type FieldDeclaration as F, type Logger as L, type PluginHooks as P, type ResolveChannelsContext as R, type SchemaDeclaration as S, type TableDeclaration as T, type UnionToIntersection as U, ValidationError as V, type ActionsOf as a, type AfterDeliverContext as b, type AnyClientPlugin as c, type BeforeSendResult as d, type ClientFetch as e, type ClientPlugin as f, ConfigError as g, type EasyPingPlugin as h, type FieldType as i, type PrepareContext as j, type Promisable as k, type RouteContext as l, type RouteDefinition as m, type RouteScope as n, consoleLogger as o, defineClientPlugin as p, definePlugin as q };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ // src/plugins/digests/schedule.ts
4
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
5
+ function localMoment(timezone, now) {
6
+ const formatter = new Intl.DateTimeFormat("en-CA", {
7
+ timeZone: timezone,
8
+ year: "numeric",
9
+ month: "2-digit",
10
+ day: "2-digit",
11
+ hour: "2-digit",
12
+ hourCycle: "h23",
13
+ weekday: "short"
14
+ });
15
+ const parts = {};
16
+ for (const part of formatter.formatToParts(now)) parts[part.type] = part.value;
17
+ return {
18
+ date: `${parts.year}-${parts.month}-${parts.day}`,
19
+ hour: Number(parts.hour ?? 0),
20
+ weekday: Math.max(0, WEEKDAYS.indexOf(parts.weekday ?? "Sun"))
21
+ };
22
+ }
23
+ var shiftDays = (date, days) => {
24
+ const shifted = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
25
+ shifted.setUTCDate(shifted.getUTCDate() - days);
26
+ return shifted.toISOString().slice(0, 10);
27
+ };
28
+ function periodKey(window, moment, sendWeekday) {
29
+ if (window === "daily") return moment.date;
30
+ const since = (moment.weekday - sendWeekday + 7) % 7;
31
+ return shiftDays(moment.date, since);
32
+ }
33
+ function isDue(window, moment, sendHour, sendWeekday) {
34
+ if (window === "daily") return moment.hour >= sendHour;
35
+ const period = periodKey("weekly", moment, sendWeekday);
36
+ return moment.date > period || moment.hour >= sendHour;
37
+ }
38
+
39
+ // src/plugins/digests/index.ts
40
+ var ENTRY = "notification_digest_entry";
41
+ var STATE = "notification_digest_state";
42
+ var isDigestWindow = (frequency) => frequency === "daily" || frequency === "weekly";
43
+ function digests(options = {}) {
44
+ const digestType = options.digestType ?? "digest";
45
+ const sendHour = options.sendHour ?? 9;
46
+ const sendWeekday = options.sendWeekday ?? 1;
47
+ const channel = options.channel ?? "email";
48
+ const maxUsersPerRun = options.maxUsersPerRun ?? 500;
49
+ let ctx;
50
+ async function windowFor(userIds, type) {
51
+ const rows = await ctx.store.find("notification_preference", { userId: { in: [...userIds] }, type, channel });
52
+ const windows = /* @__PURE__ */ new Map();
53
+ for (const row of rows) {
54
+ if (row.enabled && isDigestWindow(row.frequency)) windows.set(row.userId, row.frequency);
55
+ }
56
+ return windows;
57
+ }
58
+ return {
59
+ id: "digests",
60
+ dependsOn: ["preferences"],
61
+ init: (context) => {
62
+ ctx = context;
63
+ },
64
+ schema: {
65
+ digestEntry: {
66
+ tableName: ENTRY,
67
+ fields: {
68
+ id: { type: "string", required: true },
69
+ userId: { type: "string", required: true },
70
+ type: { type: "string", required: true },
71
+ notificationId: { type: "string", required: true },
72
+ payload: { type: "json" },
73
+ window: { type: "string", required: true },
74
+ createdAt: { type: "date", required: true, defaultNow: true }
75
+ },
76
+ primaryKey: ["id"],
77
+ indexes: [{ on: ["userId", "window"], name: "digest_entry_user_idx" }]
78
+ },
79
+ digestState: {
80
+ tableName: STATE,
81
+ fields: {
82
+ userId: { type: "string", required: true },
83
+ window: { type: "string", required: true },
84
+ /** Last period actually sent. The idempotency key for the schedule. */
85
+ lastPeriod: { type: "string", required: true },
86
+ updatedAt: { type: "date", required: true, defaultNow: true }
87
+ },
88
+ primaryKey: ["userId", "window"]
89
+ },
90
+ // Read-only here; owned by the preferences plugin.
91
+ notificationPreference: {
92
+ tableName: "notification_preference",
93
+ fields: {
94
+ userId: { type: "string", required: true },
95
+ type: { type: "string", required: true },
96
+ channel: { type: "string", required: true },
97
+ enabled: { type: "boolean", required: true },
98
+ frequency: { type: "string", required: true }
99
+ },
100
+ primaryKey: ["userId", "type", "channel"]
101
+ }
102
+ },
103
+ hooks: {
104
+ prepare: async (context) => {
105
+ if (context.type === digestType) return { deferred: /* @__PURE__ */ new Map() };
106
+ return {
107
+ deferred: await windowFor(
108
+ context.recipients.map((recipient) => recipient.userId),
109
+ context.type
110
+ )
111
+ };
112
+ },
113
+ resolveChannels: (context) => {
114
+ const prepared = context.prepared;
115
+ if (!prepared?.deferred.has(context.recipient.userId)) return context.decisions;
116
+ return context.decisions.map(
117
+ (decision) => decision.channel === channel ? { ...decision, action: "skip" } : decision
118
+ );
119
+ },
120
+ // The only hook that sees notification ids.
121
+ afterSend: async (context) => {
122
+ const prepared = context.prepared;
123
+ if (!prepared || prepared.deferred.size === 0) return;
124
+ const entries = context.notifications.filter((notification) => prepared.deferred.has(notification.userId)).map((notification) => ({
125
+ id: crypto.randomUUID(),
126
+ userId: notification.userId,
127
+ type: context.type,
128
+ notificationId: notification.id,
129
+ payload: context.payload,
130
+ window: prepared.deferred.get(notification.userId),
131
+ createdAt: /* @__PURE__ */ new Date()
132
+ }));
133
+ if (entries.length > 0) await ctx.store.insert(ENTRY, entries);
134
+ }
135
+ },
136
+ routes: [
137
+ {
138
+ /**
139
+ * Point an hourly cron here. Hourly rather than daily because "9am"
140
+ * is 24 different UTC moments, and which ones shift with DST.
141
+ */
142
+ path: "/digests/cron",
143
+ method: "POST",
144
+ scope: { type: "machine" },
145
+ handler: async () => {
146
+ const now = /* @__PURE__ */ new Date();
147
+ const pending = await ctx.store.find(ENTRY, {}, { limit: 1e4 });
148
+ if (pending.length === 0) return Response.json({ sent: 0, users: 0 });
149
+ const byUser = /* @__PURE__ */ new Map();
150
+ for (const entry of pending) {
151
+ const bucket = byUser.get(entry.userId);
152
+ if (bucket) bucket.push(entry);
153
+ else byUser.set(entry.userId, [entry]);
154
+ }
155
+ const userIds = [...byUser.keys()].slice(0, maxUsersPerRun);
156
+ const recipients = await ctx.getRecipients(userIds);
157
+ const zones = new Map(recipients.map((r) => [r.userId, r.timezone]));
158
+ const states = await ctx.store.find(STATE, { userId: { in: userIds } });
159
+ const lastPeriods = new Map(states.map((s) => [`${s.userId} ${s.window}`, s.lastPeriod]));
160
+ let sent = 0;
161
+ for (const userId of userIds) {
162
+ const entries = byUser.get(userId) ?? [];
163
+ const moment = localMoment(zones.get(userId) ?? "UTC", now);
164
+ for (const window of ["daily", "weekly"]) {
165
+ const forWindow = entries.filter((entry) => entry.window === window);
166
+ if (forWindow.length === 0) continue;
167
+ const period = periodKey(window, moment, sendWeekday);
168
+ if (lastPeriods.get(`${userId} ${window}`) === period) continue;
169
+ if (!isDue(window, moment, sendHour, sendWeekday)) continue;
170
+ await ctx.send(digestType, {
171
+ to: userId,
172
+ payload: {
173
+ window,
174
+ period,
175
+ items: forWindow.map((entry) => ({
176
+ type: entry.type,
177
+ payload: entry.payload,
178
+ createdAt: entry.createdAt
179
+ }))
180
+ },
181
+ dedupeKey: `digest:${window}:${period}:${userId}`
182
+ });
183
+ await ctx.store.upsert(
184
+ STATE,
185
+ [{ userId, window, lastPeriod: period, updatedAt: /* @__PURE__ */ new Date() }],
186
+ { onConflict: ["userId", "window"] }
187
+ );
188
+ await ctx.store.remove(ENTRY, {
189
+ id: { in: forWindow.map((entry) => entry.id) }
190
+ });
191
+ sent += 1;
192
+ }
193
+ }
194
+ return Response.json({ sent, users: userIds.length });
195
+ }
196
+ }
197
+ ]
198
+ };
199
+ }
200
+
201
+ exports.digests = digests;
202
+ exports.isDue = isDue;
203
+ exports.localMoment = localMoment;
204
+ exports.periodKey = periodKey;
205
+ //# sourceMappingURL=digests.cjs.map
206
+ //# sourceMappingURL=digests.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":"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","file":"digests.cjs","sourcesContent":["export type DigestWindow = \"daily\" | \"weekly\";\n\nexport type LocalMoment = {\n /** YYYY-MM-DD in the recipient's zone. */\n date: string;\n hour: number;\n /** 0 = Sunday. */\n weekday: number;\n};\n\nconst WEEKDAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\n\n/**\n * Reads the wall clock in a recipient's zone.\n *\n * hourCycle h23 rather than hour12:false — the latter renders midnight as \"24\"\n * in several locales, which silently makes the send-hour comparison wrong for\n * one hour a day.\n */\nexport function localMoment(timezone: string, now: Date): LocalMoment {\n const formatter = new Intl.DateTimeFormat(\"en-CA\", {\n timeZone: timezone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n hourCycle: \"h23\",\n weekday: \"short\",\n });\n\n const parts: Record<string, string> = {};\n for (const part of formatter.formatToParts(now)) parts[part.type] = part.value;\n\n return {\n date: `${parts.year}-${parts.month}-${parts.day}`,\n hour: Number(parts.hour ?? 0),\n weekday: Math.max(0, WEEKDAYS.indexOf(parts.weekday ?? \"Sun\")),\n };\n}\n\nconst shiftDays = (date: string, days: number): string => {\n const shifted = new Date(`${date}T00:00:00Z`);\n shifted.setUTCDate(shifted.getUTCDate() - days);\n return shifted.toISOString().slice(0, 10);\n};\n\n/**\n * Identifies the digest period a moment falls in.\n *\n * Scheduling keys off this string rather than bucketing users by UTC offset.\n * Offsets shift twice a year, and getting that wrong sends someone two digests\n * or none. A period key cannot: it is derived from the local calendar, so DST\n * is already accounted for and a missed cron run simply catches up.\n */\nexport function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string {\n if (window === \"daily\") return moment.date;\n\n const since = (moment.weekday - sendWeekday + 7) % 7;\n return shiftDays(moment.date, since);\n}\n\n/** Whether the send moment for the current period has arrived. */\nexport function isDue(\n window: DigestWindow,\n moment: LocalMoment,\n sendHour: number,\n sendWeekday: number,\n): boolean {\n if (window === \"daily\") return moment.hour >= sendHour;\n\n const period = periodKey(\"weekly\", moment, sendWeekday);\n // Past the send day means a run was missed; catch up rather than skip a week.\n return moment.date > period || moment.hour >= sendHour;\n}\n","import type {\n AfterSendContext,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport type { Frequency } from \"../../core/types\";\nimport { type DigestWindow, isDue, localMoment, periodKey } from \"./schedule\";\n\nconst ENTRY = \"notification_digest_entry\";\nconst STATE = \"notification_digest_state\";\n\nexport type DigestEntry = {\n id: string;\n userId: string;\n type: string;\n notificationId: string;\n payload: unknown;\n window: DigestWindow;\n createdAt: Date;\n};\n\nexport type DigestsOptions = {\n /**\n * The notification type the composed digest is sent as. The app defines it\n * with an email template taking `{ items }`.\n */\n digestType?: string;\n /** Local hour at which a digest becomes due. */\n sendHour?: number;\n /** Weekly digests only; 0 = Sunday. */\n sendWeekday?: number;\n /** Channels a digest defers. Only email makes sense today. */\n channel?: \"email\";\n /** Safety valve on one cron run. */\n maxUsersPerRun?: number;\n};\n\ntype Prepared = {\n /** userId -> window, for recipients whose preference defers this type. */\n deferred: Map<string, DigestWindow>;\n};\n\nconst isDigestWindow = (frequency: Frequency): frequency is DigestWindow =>\n frequency === \"daily\" || frequency === \"weekly\";\n\n/**\n * Collects notifications a user has chosen to receive on a schedule, then\n * sends them as one message.\n *\n * Deliberately not implemented with `defer`: deferring N emails to 9am sends N\n * emails at 9am. A digest is one email containing N items, so the channel is\n * skipped and the item is bucketed instead.\n *\n * Depends on `preferences` for the per-type frequency it reads.\n */\nexport function digests(options: DigestsOptions = {}): EasyPingPlugin<\"digests\"> {\n const digestType = options.digestType ?? \"digest\";\n const sendHour = options.sendHour ?? 9;\n const sendWeekday = options.sendWeekday ?? 1;\n const channel = options.channel ?? \"email\";\n const maxUsersPerRun = options.maxUsersPerRun ?? 500;\n\n let ctx: PluginInitContext;\n\n /** Reads the frequency the preferences plugin stored for this type. */\n async function windowFor(\n userIds: readonly string[],\n type: string,\n ): Promise<Map<string, DigestWindow>> {\n const rows = await ctx.store.find<{\n userId: string;\n type: string;\n channel: string;\n frequency: Frequency;\n enabled: boolean;\n }>(\"notification_preference\", { userId: { in: [...userIds] }, type, channel });\n\n const windows = new Map<string, DigestWindow>();\n for (const row of rows) {\n if (row.enabled && isDigestWindow(row.frequency)) windows.set(row.userId, row.frequency);\n }\n return windows;\n }\n\n return {\n id: \"digests\",\n dependsOn: [\"preferences\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n digestEntry: {\n tableName: ENTRY,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n notificationId: { type: \"string\", required: true },\n payload: { type: \"json\" },\n window: { type: \"string\", required: true },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [{ on: [\"userId\", \"window\"], name: \"digest_entry_user_idx\" }],\n },\n\n digestState: {\n tableName: STATE,\n fields: {\n userId: { type: \"string\", required: true },\n window: { type: \"string\", required: true },\n /** Last period actually sent. The idempotency key for the schedule. */\n lastPeriod: { type: \"string\", required: true },\n updatedAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"userId\", \"window\"],\n },\n\n // Read-only here; owned by the preferences plugin.\n notificationPreference: {\n tableName: \"notification_preference\",\n fields: {\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n enabled: { type: \"boolean\", required: true },\n frequency: { type: \"string\", required: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\n },\n\n hooks: {\n prepare: async (context: PrepareContext): Promise<Prepared> => {\n if (context.type === digestType) return { deferred: new Map() };\n\n return {\n deferred: await windowFor(\n context.recipients.map((recipient) => recipient.userId),\n context.type,\n ),\n };\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n const prepared = context.prepared as Prepared | undefined;\n if (!prepared?.deferred.has(context.recipient.userId)) return context.decisions;\n\n return context.decisions.map((decision) =>\n decision.channel === channel ? { ...decision, action: \"skip\" as const } : decision,\n );\n },\n\n // The only hook that sees notification ids.\n afterSend: async (context: AfterSendContext) => {\n const prepared = context.prepared as Prepared | undefined;\n if (!prepared || prepared.deferred.size === 0) return;\n\n const entries = context.notifications\n .filter((notification) => prepared.deferred.has(notification.userId))\n .map((notification) => ({\n id: crypto.randomUUID(),\n userId: notification.userId,\n type: context.type,\n notificationId: notification.id,\n payload: context.payload,\n window: prepared.deferred.get(notification.userId) as DigestWindow,\n createdAt: new Date(),\n }));\n\n if (entries.length > 0) await ctx.store.insert(ENTRY, entries);\n },\n },\n\n routes: [\n {\n /**\n * Point an hourly cron here. Hourly rather than daily because \"9am\"\n * is 24 different UTC moments, and which ones shift with DST.\n */\n path: \"/digests/cron\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const now = new Date();\n\n const pending = await ctx.store.find<DigestEntry>(ENTRY, {}, { limit: 10_000 });\n if (pending.length === 0) return Response.json({ sent: 0, users: 0 });\n\n const byUser = new Map<string, DigestEntry[]>();\n for (const entry of pending) {\n const bucket = byUser.get(entry.userId);\n if (bucket) bucket.push(entry);\n else byUser.set(entry.userId, [entry]);\n }\n\n const userIds = [...byUser.keys()].slice(0, maxUsersPerRun);\n const recipients = await ctx.getRecipients(userIds);\n const zones = new Map(recipients.map((r) => [r.userId, r.timezone]));\n\n const states = await ctx.store.find<{\n userId: string;\n window: DigestWindow;\n lastPeriod: string;\n }>(STATE, { userId: { in: userIds } });\n\n const lastPeriods = new Map(states.map((s) => [`${s.userId} ${s.window}`, s.lastPeriod]));\n\n let sent = 0;\n\n for (const userId of userIds) {\n const entries = byUser.get(userId) ?? [];\n const moment = localMoment(zones.get(userId) ?? \"UTC\", now);\n\n for (const window of [\"daily\", \"weekly\"] as const) {\n const forWindow = entries.filter((entry) => entry.window === window);\n if (forWindow.length === 0) continue;\n\n const period = periodKey(window, moment, sendWeekday);\n // The schedule is idempotent on this comparison, not on timing:\n // a cron that runs twice in an hour cannot send twice.\n if (lastPeriods.get(`${userId} ${window}`) === period) continue;\n if (!isDue(window, moment, sendHour, sendWeekday)) continue;\n\n await ctx.send(digestType, {\n to: userId,\n payload: {\n window,\n period,\n items: forWindow.map((entry) => ({\n type: entry.type,\n payload: entry.payload,\n createdAt: entry.createdAt,\n })),\n },\n dedupeKey: `digest:${window}:${period}:${userId}`,\n });\n\n await ctx.store.upsert(\n STATE,\n [{ userId, window, lastPeriod: period, updatedAt: new Date() }],\n { onConflict: [\"userId\", \"window\"] },\n );\n\n // Only after the send is recorded, so a crash re-sends rather\n // than dropping the batch.\n await ctx.store.remove(ENTRY, {\n id: { in: forWindow.map((entry) => entry.id) },\n });\n\n sent += 1;\n }\n }\n\n return Response.json({ sent, users: userIds.length });\n },\n },\n ],\n };\n}\n\nexport type { DigestWindow, LocalMoment } from \"./schedule\";\nexport { isDue, localMoment, periodKey } from \"./schedule\";\n"]}
@@ -0,0 +1,68 @@
1
+ import { h as EasyPingPlugin } from '../plugin-Bqj9xAOS.cjs';
2
+ import '../adapter-f0OR2DhY.cjs';
3
+
4
+ type DigestWindow = "daily" | "weekly";
5
+ type LocalMoment = {
6
+ /** YYYY-MM-DD in the recipient's zone. */
7
+ date: string;
8
+ hour: number;
9
+ /** 0 = Sunday. */
10
+ weekday: number;
11
+ };
12
+ /**
13
+ * Reads the wall clock in a recipient's zone.
14
+ *
15
+ * hourCycle h23 rather than hour12:false — the latter renders midnight as "24"
16
+ * in several locales, which silently makes the send-hour comparison wrong for
17
+ * one hour a day.
18
+ */
19
+ declare function localMoment(timezone: string, now: Date): LocalMoment;
20
+ /**
21
+ * Identifies the digest period a moment falls in.
22
+ *
23
+ * Scheduling keys off this string rather than bucketing users by UTC offset.
24
+ * Offsets shift twice a year, and getting that wrong sends someone two digests
25
+ * or none. A period key cannot: it is derived from the local calendar, so DST
26
+ * is already accounted for and a missed cron run simply catches up.
27
+ */
28
+ declare function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string;
29
+ /** Whether the send moment for the current period has arrived. */
30
+ declare function isDue(window: DigestWindow, moment: LocalMoment, sendHour: number, sendWeekday: number): boolean;
31
+
32
+ type DigestEntry = {
33
+ id: string;
34
+ userId: string;
35
+ type: string;
36
+ notificationId: string;
37
+ payload: unknown;
38
+ window: DigestWindow;
39
+ createdAt: Date;
40
+ };
41
+ type DigestsOptions = {
42
+ /**
43
+ * The notification type the composed digest is sent as. The app defines it
44
+ * with an email template taking `{ items }`.
45
+ */
46
+ digestType?: string;
47
+ /** Local hour at which a digest becomes due. */
48
+ sendHour?: number;
49
+ /** Weekly digests only; 0 = Sunday. */
50
+ sendWeekday?: number;
51
+ /** Channels a digest defers. Only email makes sense today. */
52
+ channel?: "email";
53
+ /** Safety valve on one cron run. */
54
+ maxUsersPerRun?: number;
55
+ };
56
+ /**
57
+ * Collects notifications a user has chosen to receive on a schedule, then
58
+ * sends them as one message.
59
+ *
60
+ * Deliberately not implemented with `defer`: deferring N emails to 9am sends N
61
+ * emails at 9am. A digest is one email containing N items, so the channel is
62
+ * skipped and the item is bucketed instead.
63
+ *
64
+ * Depends on `preferences` for the per-type frequency it reads.
65
+ */
66
+ declare function digests(options?: DigestsOptions): EasyPingPlugin<"digests">;
67
+
68
+ export { type DigestEntry, type DigestWindow, type DigestsOptions, type LocalMoment, digests, isDue, localMoment, periodKey };
@@ -0,0 +1,68 @@
1
+ import { h as EasyPingPlugin } from '../plugin-bTJKmnlq.js';
2
+ import '../adapter-f0OR2DhY.js';
3
+
4
+ type DigestWindow = "daily" | "weekly";
5
+ type LocalMoment = {
6
+ /** YYYY-MM-DD in the recipient's zone. */
7
+ date: string;
8
+ hour: number;
9
+ /** 0 = Sunday. */
10
+ weekday: number;
11
+ };
12
+ /**
13
+ * Reads the wall clock in a recipient's zone.
14
+ *
15
+ * hourCycle h23 rather than hour12:false — the latter renders midnight as "24"
16
+ * in several locales, which silently makes the send-hour comparison wrong for
17
+ * one hour a day.
18
+ */
19
+ declare function localMoment(timezone: string, now: Date): LocalMoment;
20
+ /**
21
+ * Identifies the digest period a moment falls in.
22
+ *
23
+ * Scheduling keys off this string rather than bucketing users by UTC offset.
24
+ * Offsets shift twice a year, and getting that wrong sends someone two digests
25
+ * or none. A period key cannot: it is derived from the local calendar, so DST
26
+ * is already accounted for and a missed cron run simply catches up.
27
+ */
28
+ declare function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string;
29
+ /** Whether the send moment for the current period has arrived. */
30
+ declare function isDue(window: DigestWindow, moment: LocalMoment, sendHour: number, sendWeekday: number): boolean;
31
+
32
+ type DigestEntry = {
33
+ id: string;
34
+ userId: string;
35
+ type: string;
36
+ notificationId: string;
37
+ payload: unknown;
38
+ window: DigestWindow;
39
+ createdAt: Date;
40
+ };
41
+ type DigestsOptions = {
42
+ /**
43
+ * The notification type the composed digest is sent as. The app defines it
44
+ * with an email template taking `{ items }`.
45
+ */
46
+ digestType?: string;
47
+ /** Local hour at which a digest becomes due. */
48
+ sendHour?: number;
49
+ /** Weekly digests only; 0 = Sunday. */
50
+ sendWeekday?: number;
51
+ /** Channels a digest defers. Only email makes sense today. */
52
+ channel?: "email";
53
+ /** Safety valve on one cron run. */
54
+ maxUsersPerRun?: number;
55
+ };
56
+ /**
57
+ * Collects notifications a user has chosen to receive on a schedule, then
58
+ * sends them as one message.
59
+ *
60
+ * Deliberately not implemented with `defer`: deferring N emails to 9am sends N
61
+ * emails at 9am. A digest is one email containing N items, so the channel is
62
+ * skipped and the item is bucketed instead.
63
+ *
64
+ * Depends on `preferences` for the per-type frequency it reads.
65
+ */
66
+ declare function digests(options?: DigestsOptions): EasyPingPlugin<"digests">;
67
+
68
+ export { type DigestEntry, type DigestWindow, type DigestsOptions, type LocalMoment, digests, isDue, localMoment, periodKey };