easy-ping 0.3.0 → 0.5.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 +174 -82
- package/dist/adapter-B7R4rXtJ.d.cts +17 -0
- package/dist/adapter-B7R4rXtJ.d.ts +17 -0
- package/dist/{adapter-BfLe2J0t.d.cts → adapter-D3lTy-4E.d.cts} +6 -32
- package/dist/{adapter-BfLe2J0t.d.ts → adapter-D3lTy-4E.d.ts} +6 -32
- package/dist/adapters/drizzle.cjs +4 -4
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +35 -1
- package/dist/adapters/drizzle.d.ts +35 -1
- package/dist/adapters/drizzle.js +5 -5
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +8 -7
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +4 -17
- package/dist/adapters/mongodb.d.ts +4 -17
- package/dist/adapters/mongodb.js +7 -7
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/mysql.cjs +605 -0
- package/dist/adapters/mysql.cjs.map +1 -0
- package/dist/adapters/mysql.d.cts +38 -0
- package/dist/adapters/mysql.d.ts +38 -0
- package/dist/adapters/mysql.js +51 -0
- package/dist/adapters/mysql.js.map +1 -0
- package/dist/adapters/postgres.cjs +19 -11
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +9 -54
- package/dist/adapters/postgres.d.ts +9 -54
- package/dist/adapters/postgres.js +6 -41
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/adapters/sqlite.cjs +593 -0
- package/dist/adapters/sqlite.cjs.map +1 -0
- package/dist/adapters/sqlite.d.cts +37 -0
- package/dist/adapters/sqlite.d.ts +37 -0
- package/dist/adapters/sqlite.js +53 -0
- package/dist/adapters/sqlite.js.map +1 -0
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +2 -14
- package/dist/browser.d.ts +2 -14
- package/dist/browser.js.map +1 -1
- package/dist/chunk-2EYKPF5A.js +393 -0
- package/dist/chunk-2EYKPF5A.js.map +1 -0
- package/dist/{chunk-OZSOIX23.js → chunk-3VOQOGDR.js} +54 -12
- package/dist/chunk-3VOQOGDR.js.map +1 -0
- package/dist/{chunk-NYDVNMKG.js → chunk-AEYYTE5I.js} +4 -3
- package/dist/chunk-AEYYTE5I.js.map +1 -0
- package/dist/chunk-BECMTNK7.js +315 -0
- package/dist/chunk-BECMTNK7.js.map +1 -0
- package/dist/chunk-CK5UPVYP.js +48 -0
- package/dist/chunk-CK5UPVYP.js.map +1 -0
- package/dist/{chunk-BV3U542P.js → chunk-E4IZ2CQC.js} +2 -2
- package/dist/chunk-E4IZ2CQC.js.map +1 -0
- package/dist/chunk-GJNLOQJS.js +226 -0
- package/dist/chunk-GJNLOQJS.js.map +1 -0
- package/dist/chunk-HY47TQMY.js +52 -0
- package/dist/chunk-HY47TQMY.js.map +1 -0
- package/dist/chunk-M4R77BJD.js +64 -0
- package/dist/chunk-M4R77BJD.js.map +1 -0
- package/dist/{chunk-RBDW4S6F.js → chunk-XIDVLYNQ.js} +14 -4
- package/dist/chunk-XIDVLYNQ.js.map +1 -0
- package/dist/client.cjs.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +362 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -37
- package/dist/index.d.ts +56 -37
- package/dist/index.js +120 -359
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +27 -6
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +8 -2
- package/dist/node.d.ts +8 -2
- package/dist/node.js +26 -6
- package/dist/node.js.map +1 -1
- package/dist/{plugin-CmPOFVmn.d.ts → plugin-CHd2ZiGC.d.cts} +37 -25
- package/dist/{plugin-f5KaFWSN.d.cts → plugin-Cg9usuUi.d.ts} +37 -25
- package/dist/plugins/digests.cjs +1 -4
- package/dist/plugins/digests.cjs.map +1 -1
- package/dist/plugins/digests.d.cts +6 -31
- package/dist/plugins/digests.d.ts +6 -31
- package/dist/plugins/digests.js +1 -4
- package/dist/plugins/digests.js.map +1 -1
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +2 -2
- package/dist/plugins/preferences-client.d.ts +2 -2
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences-react.cjs.map +1 -1
- package/dist/plugins/preferences-react.js.map +1 -1
- package/dist/plugins/preferences.cjs +101 -17
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +4 -2
- package/dist/plugins/preferences.d.ts +4 -2
- package/dist/plugins/preferences.js +36 -9
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +198 -40
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +20 -18
- package/dist/plugins/push.d.ts +20 -18
- package/dist/plugins/push.js +141 -41
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs +37 -11
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +10 -11
- package/dist/providers/web-push.d.ts +10 -11
- package/dist/providers/web-push.js +37 -12
- package/dist/providers/web-push.js.map +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -4
- package/dist/react.d.ts +1 -4
- package/dist/react.js.map +1 -1
- package/dist/schema.cjs +121 -2
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +25 -14
- package/dist/schema.d.ts +25 -14
- package/dist/schema.js +4 -2
- package/dist/testing.cjs +3 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -22
- package/dist/testing.d.ts +4 -22
- package/dist/testing.js +3 -19
- package/dist/testing.js.map +1 -1
- package/package.json +12 -1
- package/dist/chunk-BV3U542P.js.map +0 -1
- package/dist/chunk-NYDVNMKG.js.map +0 -1
- package/dist/chunk-OZSOIX23.js.map +0 -1
- package/dist/chunk-RBDW4S6F.js.map +0 -1
- package/dist/chunk-W3BMEPPQ.js +0 -50
- package/dist/chunk-W3BMEPPQ.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { R as Recipient, C as Channel, f as DeliveryOutcome, k as PluginStore } from './adapter-
|
|
1
|
+
import { R as Recipient, C as Channel, f as DeliveryOutcome, k as PluginStore } from './adapter-D3lTy-4E.js';
|
|
2
2
|
|
|
3
3
|
declare class EasyPingError extends Error {
|
|
4
4
|
constructor(message: string);
|
|
@@ -24,6 +24,33 @@ type Logger = {
|
|
|
24
24
|
};
|
|
25
25
|
declare const consoleLogger: Logger;
|
|
26
26
|
|
|
27
|
+
type TokenClaims = {
|
|
28
|
+
/** Subject — the user the token acts for. */
|
|
29
|
+
uid: string;
|
|
30
|
+
/** What the token is allowed to do. Verification requires an exact match. */
|
|
31
|
+
purpose: string;
|
|
32
|
+
/** Expiry, epoch seconds. */
|
|
33
|
+
exp: number;
|
|
34
|
+
/** Issued at, epoch seconds. Set by signToken; lets a route refuse a token older than a later change. */
|
|
35
|
+
iat?: number;
|
|
36
|
+
/** Free-form, purpose-specific. */
|
|
37
|
+
data?: Record<string, string>;
|
|
38
|
+
};
|
|
39
|
+
declare function signToken(secret: string, claims: TokenClaims, now?: Date): Promise<string>;
|
|
40
|
+
/** null for every failure mode. Distinguishing them tells an attacker which part to fix. */
|
|
41
|
+
declare function verifyToken(secret: string, token: string, purpose: string, now?: Date): Promise<TokenClaims | null>;
|
|
42
|
+
declare const DEFAULT_TOKEN_TTL_SECONDS: number;
|
|
43
|
+
/** Ninety days. A link that must outlive this should be re-issued, not made immortal. */
|
|
44
|
+
declare const MAX_TOKEN_TTL_SECONDS: number;
|
|
45
|
+
declare const expiresIn: (seconds: number, now?: Date) => number;
|
|
46
|
+
type SignRequest = {
|
|
47
|
+
uid: string;
|
|
48
|
+
purpose: string;
|
|
49
|
+
data?: Record<string, string>;
|
|
50
|
+
/** Defaults to 30 days, capped at 90. */
|
|
51
|
+
ttlSeconds?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
27
54
|
type Promisable<T> = T | Promise<T>;
|
|
28
55
|
type PrepareContext = {
|
|
29
56
|
type: string;
|
|
@@ -100,17 +127,9 @@ type PluginHooks = {
|
|
|
100
127
|
prepare?: (ctx: PrepareContext) => Promisable<unknown>;
|
|
101
128
|
beforeSend?: (ctx: BeforeSendContext) => Promisable<BeforeSendResult>;
|
|
102
129
|
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
|
-
*/
|
|
130
|
+
/** Once per send, after commit. The only hook that sees notification ids. Fails open. */
|
|
108
131
|
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
|
-
*/
|
|
132
|
+
/** Delivers one of the plugin's declared `channels`. Core carries inApp and email. */
|
|
114
133
|
deliver?: (ctx: DeliverContext) => Promisable<DeliveryOutcome>;
|
|
115
134
|
afterDeliver?: (ctx: AfterDeliverContext) => Promisable<void>;
|
|
116
135
|
};
|
|
@@ -150,12 +169,7 @@ type RouteContext = {
|
|
|
150
169
|
/** Resolved for user-scoped routes, so a handler cannot forget to scope. */
|
|
151
170
|
userId: string | null;
|
|
152
171
|
/** 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;
|
|
172
|
+
claims: TokenClaims | null;
|
|
159
173
|
params: Record<string, string>;
|
|
160
174
|
};
|
|
161
175
|
type RouteDefinition = {
|
|
@@ -168,16 +182,14 @@ type RouteDefinition = {
|
|
|
168
182
|
type PluginInitContext = {
|
|
169
183
|
/** Scoped to the tables this plugin declares in schema(). */
|
|
170
184
|
store: PluginStore;
|
|
171
|
-
/**
|
|
172
|
-
|
|
185
|
+
/** Mints tokens for this plugin's own `signed` routes only. The secret itself is never handed out. */
|
|
186
|
+
sign: (request: SignRequest) => Promise<string>;
|
|
173
187
|
logger: Logger;
|
|
188
|
+
/** Configured notification names, so a route can refuse to store rows for types that do not exist. */
|
|
189
|
+
notificationTypes: readonly string[];
|
|
174
190
|
/** The app's resolver, for plugins that need timezones or addresses. */
|
|
175
191
|
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
|
-
*/
|
|
192
|
+
/** The instance's own send(), so a composed message inherits retry, backoff and idempotency. */
|
|
181
193
|
send: (type: string, args: {
|
|
182
194
|
to: string | readonly string[];
|
|
183
195
|
payload: unknown;
|
|
@@ -214,4 +226,4 @@ type ExtractActions<TPlugin> = TPlugin extends {
|
|
|
214
226
|
/** Indexes the tuple rather than recursing: a fold blows the instantiation depth limit. */
|
|
215
227
|
type ActionsOf<TPlugins extends readonly AnyClientPlugin[]> = UnionToIntersection<ExtractActions<TPlugins[number]>>;
|
|
216
228
|
|
|
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
|
|
229
|
+
export { type AnyPlugin as A, type BeforeSendContext as B, type ChannelDecision as C, DEFAULT_TOKEN_TTL_SECONDS as D, EasyPingError as E, type FieldDeclaration as F, type Logger as L, MAX_TOKEN_TTL_SECONDS as M, type Promisable as P, type RouteScope 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 PluginHooks as j, type PrepareContext as k, type ResolveChannelsContext as l, type RouteContext as m, type RouteDefinition as n, type SignRequest as o, type TokenClaims as p, consoleLogger as q, defineClientPlugin as r, definePlugin as s, expiresIn as t, signToken as u, verifyToken as v };
|
package/dist/plugins/digests.cjs
CHANGED
|
@@ -135,10 +135,7 @@ function digests(options = {}) {
|
|
|
135
135
|
},
|
|
136
136
|
routes: [
|
|
137
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
|
-
*/
|
|
138
|
+
/** Point an hourly cron here: "9am" is 24 different UTC moments that shift with DST. */
|
|
142
139
|
path: "/digests/cron",
|
|
143
140
|
method: "POST",
|
|
144
141
|
scope: { type: "machine" },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":";;;AAUA,IAAM,QAAA,GAAW,CAAC,KAAA,EAAO,KAAA,EAAO,OAAO,KAAA,EAAO,KAAA,EAAO,OAAO,KAAK,CAAA;AAS1D,SAAS,WAAA,CAAY,UAAkB,GAAA,EAAwB;AACpE,EAAA,MAAM,SAAA,GAAY,IAAI,IAAA,CAAK,cAAA,CAAe,OAAA,EAAS;AAAA,IACjD,QAAA,EAAU,QAAA;AAAA,IACV,IAAA,EAAM,SAAA;AAAA,IACN,KAAA,EAAO,SAAA;AAAA,IACP,GAAA,EAAK,SAAA;AAAA,IACL,IAAA,EAAM,SAAA;AAAA,IACN,SAAA,EAAW,KAAA;AAAA,IACX,OAAA,EAAS;AAAA,GACV,CAAA;AAED,EAAA,MAAM,QAAgC,EAAC;AACvC,EAAA,KAAA,MAAW,IAAA,IAAQ,UAAU,aAAA,CAAc,GAAG,GAAG,KAAA,CAAM,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA,CAAK,KAAA;AAEzE,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,GAAG,KAAA,CAAM,IAAI,IAAI,KAAA,CAAM,KAAK,CAAA,CAAA,EAAI,KAAA,CAAM,GAAG,CAAA,CAAA;AAAA,IAC/C,IAAA,EAAM,MAAA,CAAO,KAAA,CAAM,IAAA,IAAQ,CAAC,CAAA;AAAA,IAC5B,OAAA,EAAS,KAAK,GAAA,CAAI,CAAA,EAAG,SAAS,OAAA,CAAQ,KAAA,CAAM,OAAA,IAAW,KAAK,CAAC;AAAA,GAC/D;AACF;AAEA,IAAM,SAAA,GAAY,CAAC,IAAA,EAAc,IAAA,KAAyB;AACxD,EAAA,MAAM,OAAA,mBAAU,IAAI,IAAA,CAAK,CAAA,EAAG,IAAI,CAAA,UAAA,CAAY,CAAA;AAC5C,EAAA,OAAA,CAAQ,UAAA,CAAW,OAAA,CAAQ,UAAA,EAAW,GAAI,IAAI,CAAA;AAC9C,EAAA,OAAO,OAAA,CAAQ,WAAA,EAAY,CAAE,KAAA,CAAM,GAAG,EAAE,CAAA;AAC1C,CAAA;AAUO,SAAS,SAAA,CAAU,MAAA,EAAsB,MAAA,EAAqB,WAAA,EAA6B;AAChG,EAAA,IAAI,MAAA,KAAW,OAAA,EAAS,OAAO,MAAA,CAAO,IAAA;AAEtC,EAAA,MAAM,KAAA,GAAA,CAAS,MAAA,CAAO,OAAA,GAAU,WAAA,GAAc,CAAA,IAAK,CAAA;AACnD,EAAA,OAAO,SAAA,CAAU,MAAA,CAAO,IAAA,EAAM,KAAK,CAAA;AACrC;AAGO,SAAS,KAAA,CACd,MAAA,EACA,MAAA,EACA,QAAA,EACA,WAAA,EACS;AACT,EAAA,IAAI,MAAA,KAAW,OAAA,EAAS,OAAO,MAAA,CAAO,IAAA,IAAQ,QAAA;AAE9C,EAAA,MAAM,MAAA,GAAS,SAAA,CAAU,QAAA,EAAU,MAAA,EAAQ,WAAW,CAAA;AAEtD,EAAA,OAAO,MAAA,CAAO,IAAA,GAAO,MAAA,IAAU,MAAA,CAAO,IAAA,IAAQ,QAAA;AAChD;;;AC/DA,IAAM,KAAA,GAAQ,2BAAA;AACd,IAAM,KAAA,GAAQ,2BAAA;AAiCd,IAAM,cAAA,GAAiB,CAAC,SAAA,KACtB,SAAA,KAAc,WAAW,SAAA,KAAc,QAAA;AAYlC,SAAS,OAAA,CAAQ,OAAA,GAA0B,EAAC,EAA8B;AAC/E,EAAA,MAAM,UAAA,GAAa,QAAQ,UAAA,IAAc,QAAA;AACzC,EAAA,MAAM,QAAA,GAAW,QAAQ,QAAA,IAAY,CAAA;AACrC,EAAA,MAAM,WAAA,GAAc,QAAQ,WAAA,IAAe,CAAA;AAC3C,EAAA,MAAM,OAAA,GAAU,QAAQ,OAAA,IAAW,OAAA;AACnC,EAAA,MAAM,cAAA,GAAiB,QAAQ,cAAA,IAAkB,GAAA;AAEjD,EAAA,IAAI,GAAA;AAGJ,EAAA,eAAe,SAAA,CACb,SACA,IAAA,EACoC;AACpC,IAAA,MAAM,OAAO,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAM1B,2BAA2B,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,CAAC,GAAG,OAAO,GAAE,EAAG,IAAA,EAAM,SAAS,CAAA;AAE7E,IAAA,MAAM,OAAA,uBAAc,GAAA,EAA0B;AAC9C,IAAA,KAAA,MAAW,OAAO,IAAA,EAAM;AACtB,MAAA,IAAI,GAAA,CAAI,OAAA,IAAW,cAAA,CAAe,GAAA,CAAI,SAAS,CAAA,EAAG,OAAA,CAAQ,GAAA,CAAI,GAAA,CAAI,MAAA,EAAQ,GAAA,CAAI,SAAS,CAAA;AAAA,IACzF;AACA,IAAA,OAAO,OAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,EAAA,EAAI,SAAA;AAAA,IACJ,SAAA,EAAW,CAAC,aAAa,CAAA;AAAA,IAEzB,IAAA,EAAM,CAAC,OAAA,KAAY;AACjB,MAAA,GAAA,GAAM,OAAA;AAAA,IACR,CAAA;AAAA,IAEA,MAAA,EAAQ;AAAA,MACN,WAAA,EAAa;AAAA,QACX,SAAA,EAAW,KAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,EAAA,EAAI,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACrC,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,IAAA,EAAM,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACvC,cAAA,EAAgB,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACjD,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAO;AAAA,UACxB,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,WAAW,EAAE,IAAA,EAAM,QAAQ,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA;AAAK,SAC9D;AAAA,QACA,UAAA,EAAY,CAAC,IAAI,CAAA;AAAA,QACjB,OAAA,EAAS,CAAC,EAAE,EAAA,EAAI,CAAC,UAAU,QAAQ,CAAA,EAAG,IAAA,EAAM,uBAAA,EAAyB;AAAA,OACvE;AAAA,MAEA,WAAA,EAAa;AAAA,QACX,SAAA,EAAW,KAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA;AAAA,UAEzC,UAAA,EAAY,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UAC7C,WAAW,EAAE,IAAA,EAAM,QAAQ,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA;AAAK,SAC9D;AAAA,QACA,UAAA,EAAY,CAAC,QAAA,EAAU,QAAQ;AAAA,OACjC;AAAA;AAAA,MAGA,sBAAA,EAAwB;AAAA,QACtB,SAAA,EAAW,yBAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,IAAA,EAAM,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACvC,OAAA,EAAS,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UAC1C,OAAA,EAAS,EAAE,IAAA,EAAM,SAAA,EAAW,UAAU,IAAA,EAAK;AAAA,UAC3C,SAAA,EAAW,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA;AAAK,SAC9C;AAAA,QACA,UAAA,EAAY,CAAC,QAAA,EAAU,MAAA,EAAQ,SAAS;AAAA;AAC1C,KACF;AAAA,IAEA,KAAA,EAAO;AAAA,MACL,OAAA,EAAS,OAAO,OAAA,KAA+C;AAC7D,QAAA,IAAI,OAAA,CAAQ,SAAS,UAAA,EAAY,OAAO,EAAE,QAAA,kBAAU,IAAI,KAAI,EAAE;AAE9D,QAAA,OAAO;AAAA,UACL,UAAU,MAAM,SAAA;AAAA,YACd,QAAQ,UAAA,CAAW,GAAA,CAAI,CAAC,SAAA,KAAc,UAAU,MAAM,CAAA;AAAA,YACtD,OAAA,CAAQ;AAAA;AACV,SACF;AAAA,MACF,CAAA;AAAA,MAEA,eAAA,EAAiB,CAAC,OAAA,KAAoC;AACpD,QAAA,MAAM,WAAW,OAAA,CAAQ,QAAA;AACzB,QAAA,IAAI,CAAC,UAAU,QAAA,CAAS,GAAA,CAAI,QAAQ,SAAA,CAAU,MAAM,CAAA,EAAG,OAAO,OAAA,CAAQ,SAAA;AAEtE,QAAA,OAAO,QAAQ,SAAA,CAAU,GAAA;AAAA,UAAI,CAAC,QAAA,KAC5B,QAAA,CAAS,OAAA,KAAY,OAAA,GAAU,EAAE,GAAG,QAAA,EAAU,MAAA,EAAQ,MAAA,EAAgB,GAAI;AAAA,SAC5E;AAAA,MACF,CAAA;AAAA;AAAA,MAGA,SAAA,EAAW,OAAO,OAAA,KAA8B;AAC9C,QAAA,MAAM,WAAW,OAAA,CAAQ,QAAA;AACzB,QAAA,IAAI,CAAC,QAAA,IAAY,QAAA,CAAS,QAAA,CAAS,SAAS,CAAA,EAAG;AAE/C,QAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,aAAA,CACrB,MAAA,CAAO,CAAC,YAAA,KAAiB,QAAA,CAAS,QAAA,CAAS,GAAA,CAAI,aAAa,MAAM,CAAC,CAAA,CACnE,GAAA,CAAI,CAAC,YAAA,MAAkB;AAAA,UACtB,EAAA,EAAI,OAAO,UAAA,EAAW;AAAA,UACtB,QAAQ,YAAA,CAAa,MAAA;AAAA,UACrB,MAAM,OAAA,CAAQ,IAAA;AAAA,UACd,gBAAgB,YAAA,CAAa,EAAA;AAAA,UAC7B,SAAS,OAAA,CAAQ,OAAA;AAAA,UACjB,MAAA,EAAQ,QAAA,CAAS,QAAA,CAAS,GAAA,CAAI,aAAa,MAAM,CAAA;AAAA,UACjD,SAAA,sBAAe,IAAA;AAAK,SACtB,CAAE,CAAA;AAEJ,QAAA,IAAI,OAAA,CAAQ,SAAS,CAAA,EAAG,MAAM,IAAI,KAAA,CAAM,MAAA,CAAO,OAAO,OAAO,CAAA;AAAA,MAC/D;AAAA,KACF;AAAA,IAEA,MAAA,EAAQ;AAAA,MACN;AAAA;AAAA;AAAA;AAAA;AAAA,QAKE,IAAA,EAAM,eAAA;AAAA,QACN,MAAA,EAAQ,MAAA;AAAA,QACR,KAAA,EAAO,EAAE,IAAA,EAAM,SAAA,EAAU;AAAA,QACzB,SAAS,YAAY;AACnB,UAAA,MAAM,GAAA,uBAAU,IAAA,EAAK;AAErB,UAAA,MAAM,OAAA,GAAU,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAAkB,KAAA,EAAO,EAAC,EAAG,EAAE,KAAA,EAAO,GAAA,EAAQ,CAAA;AAC9E,UAAA,IAAI,OAAA,CAAQ,MAAA,KAAW,CAAA,EAAG,OAAO,QAAA,CAAS,IAAA,CAAK,EAAE,IAAA,EAAM,CAAA,EAAG,KAAA,EAAO,CAAA,EAAG,CAAA;AAEpE,UAAA,MAAM,MAAA,uBAAa,GAAA,EAA2B;AAC9C,UAAA,KAAA,MAAW,SAAS,OAAA,EAAS;AAC3B,YAAA,MAAM,MAAA,GAAS,MAAA,CAAO,GAAA,CAAI,KAAA,CAAM,MAAM,CAAA;AACtC,YAAA,IAAI,MAAA,EAAQ,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA;AAAA,wBACjB,GAAA,CAAI,KAAA,CAAM,MAAA,EAAQ,CAAC,KAAK,CAAC,CAAA;AAAA,UACvC;AAEA,UAAA,MAAM,OAAA,GAAU,CAAC,GAAG,MAAA,CAAO,MAAM,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,cAAc,CAAA;AAC1D,UAAA,MAAM,UAAA,GAAa,MAAM,GAAA,CAAI,aAAA,CAAc,OAAO,CAAA;AAClD,UAAA,MAAM,KAAA,GAAQ,IAAI,GAAA,CAAI,UAAA,CAAW,GAAA,CAAI,CAAC,CAAA,KAAM,CAAC,CAAA,CAAE,MAAA,EAAQ,CAAA,CAAE,QAAQ,CAAC,CAAC,CAAA;AAEnE,UAAA,MAAM,MAAA,GAAS,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAI5B,KAAA,EAAO,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,OAAA,EAAQ,EAAG,CAAA;AAErC,UAAA,MAAM,cAAc,IAAI,GAAA,CAAI,OAAO,GAAA,CAAI,CAAC,MAAM,CAAC,CAAA,EAAG,CAAA,CAAE,MAAM,IAAI,CAAA,CAAE,MAAM,IAAI,CAAA,CAAE,UAAU,CAAC,CAAC,CAAA;AAExF,UAAA,IAAI,IAAA,GAAO,CAAA;AAEX,UAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAC5B,YAAA,MAAM,OAAA,GAAU,MAAA,CAAO,GAAA,CAAI,MAAM,KAAK,EAAC;AACvC,YAAA,MAAM,SAAS,WAAA,CAAY,KAAA,CAAM,IAAI,MAAM,CAAA,IAAK,OAAO,GAAG,CAAA;AAE1D,YAAA,KAAA,MAAW,MAAA,IAAU,CAAC,OAAA,EAAS,QAAQ,CAAA,EAAY;AACjD,cAAA,MAAM,YAAY,OAAA,CAAQ,MAAA,CAAO,CAAC,KAAA,KAAU,KAAA,CAAM,WAAW,MAAM,CAAA;AACnE,cAAA,IAAI,SAAA,CAAU,WAAW,CAAA,EAAG;AAE5B,cAAA,MAAM,MAAA,GAAS,SAAA,CAAU,MAAA,EAAQ,MAAA,EAAQ,WAAW,CAAA;AAGpD,cAAA,IAAI,WAAA,CAAY,IAAI,CAAA,EAAG,MAAM,IAAI,MAAM,CAAA,CAAE,MAAM,MAAA,EAAQ;AACvD,cAAA,IAAI,CAAC,KAAA,CAAM,MAAA,EAAQ,MAAA,EAAQ,QAAA,EAAU,WAAW,CAAA,EAAG;AAEnD,cAAA,MAAM,GAAA,CAAI,KAAK,UAAA,EAAY;AAAA,gBACzB,EAAA,EAAI,MAAA;AAAA,gBACJ,OAAA,EAAS;AAAA,kBACP,MAAA;AAAA,kBACA,MAAA;AAAA,kBACA,KAAA,EAAO,SAAA,CAAU,GAAA,CAAI,CAAC,KAAA,MAAW;AAAA,oBAC/B,MAAM,KAAA,CAAM,IAAA;AAAA,oBACZ,SAAS,KAAA,CAAM,OAAA;AAAA,oBACf,WAAW,KAAA,CAAM;AAAA,mBACnB,CAAE;AAAA,iBACJ;AAAA,gBACA,WAAW,CAAA,OAAA,EAAU,MAAM,CAAA,CAAA,EAAI,MAAM,IAAI,MAAM,CAAA;AAAA,eAChD,CAAA;AAED,cAAA,MAAM,IAAI,KAAA,CAAM,MAAA;AAAA,gBACd,KAAA;AAAA,gBACA,CAAC,EAAE,MAAA,EAAQ,MAAA,EAAQ,UAAA,EAAY,QAAQ,SAAA,kBAAW,IAAI,IAAA,EAAK,EAAG,CAAA;AAAA,gBAC9D,EAAE,UAAA,EAAY,CAAC,QAAA,EAAU,QAAQ,CAAA;AAAE,eACrC;AAIA,cAAA,MAAM,GAAA,CAAI,KAAA,CAAM,MAAA,CAAO,KAAA,EAAO;AAAA,gBAC5B,EAAA,EAAI,EAAE,EAAA,EAAI,SAAA,CAAU,IAAI,CAAC,KAAA,KAAU,KAAA,CAAM,EAAE,CAAA;AAAE,eAC9C,CAAA;AAED,cAAA,IAAA,IAAQ,CAAA;AAAA,YACV;AAAA,UACF;AAEA,UAAA,OAAO,SAAS,IAAA,CAAK,EAAE,MAAM,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AAAA,QACtD;AAAA;AACF;AACF,GACF;AACF","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"]}
|
|
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/** Wall clock in the recipient's zone. hourCycle h23: hour12:false renders midnight as \"24\" in some locales. */\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/** The digest period a moment falls in, keyed off the local calendar so DST cannot double or skip a send. */\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 /** The type the composed digest is sent as; the app defines its template over `{ items }`. */\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/** One email containing N items, rather than N deferred emails at 9am. Depends on `preferences` for the frequency. */\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 /** Point an hourly cron here: \"9am\" is 24 different UTC moments that shift with DST. */\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 // Idempotent on the period key, so a cron that runs twice 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: a crash re-sends rather than drops.\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"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { h as EasyPingPlugin } from '../plugin-
|
|
2
|
-
import '../adapter-
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-CHd2ZiGC.cjs';
|
|
2
|
+
import '../adapter-D3lTy-4E.cjs';
|
|
3
3
|
|
|
4
4
|
type DigestWindow = "daily" | "weekly";
|
|
5
5
|
type LocalMoment = {
|
|
@@ -9,22 +9,9 @@ type LocalMoment = {
|
|
|
9
9
|
/** 0 = Sunday. */
|
|
10
10
|
weekday: number;
|
|
11
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
|
-
*/
|
|
12
|
+
/** Wall clock in the recipient's zone. hourCycle h23: hour12:false renders midnight as "24" in some locales. */
|
|
19
13
|
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
|
-
*/
|
|
14
|
+
/** The digest period a moment falls in, keyed off the local calendar so DST cannot double or skip a send. */
|
|
28
15
|
declare function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string;
|
|
29
16
|
/** Whether the send moment for the current period has arrived. */
|
|
30
17
|
declare function isDue(window: DigestWindow, moment: LocalMoment, sendHour: number, sendWeekday: number): boolean;
|
|
@@ -39,10 +26,7 @@ type DigestEntry = {
|
|
|
39
26
|
createdAt: Date;
|
|
40
27
|
};
|
|
41
28
|
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
|
-
*/
|
|
29
|
+
/** The type the composed digest is sent as; the app defines its template over `{ items }`. */
|
|
46
30
|
digestType?: string;
|
|
47
31
|
/** Local hour at which a digest becomes due. */
|
|
48
32
|
sendHour?: number;
|
|
@@ -53,16 +37,7 @@ type DigestsOptions = {
|
|
|
53
37
|
/** Safety valve on one cron run. */
|
|
54
38
|
maxUsersPerRun?: number;
|
|
55
39
|
};
|
|
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
|
-
*/
|
|
40
|
+
/** One email containing N items, rather than N deferred emails at 9am. Depends on `preferences` for the frequency. */
|
|
66
41
|
declare function digests(options?: DigestsOptions): EasyPingPlugin<"digests">;
|
|
67
42
|
|
|
68
43
|
export { type DigestEntry, type DigestWindow, type DigestsOptions, type LocalMoment, digests, isDue, localMoment, periodKey };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { h as EasyPingPlugin } from '../plugin-
|
|
2
|
-
import '../adapter-
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-Cg9usuUi.js';
|
|
2
|
+
import '../adapter-D3lTy-4E.js';
|
|
3
3
|
|
|
4
4
|
type DigestWindow = "daily" | "weekly";
|
|
5
5
|
type LocalMoment = {
|
|
@@ -9,22 +9,9 @@ type LocalMoment = {
|
|
|
9
9
|
/** 0 = Sunday. */
|
|
10
10
|
weekday: number;
|
|
11
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
|
-
*/
|
|
12
|
+
/** Wall clock in the recipient's zone. hourCycle h23: hour12:false renders midnight as "24" in some locales. */
|
|
19
13
|
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
|
-
*/
|
|
14
|
+
/** The digest period a moment falls in, keyed off the local calendar so DST cannot double or skip a send. */
|
|
28
15
|
declare function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string;
|
|
29
16
|
/** Whether the send moment for the current period has arrived. */
|
|
30
17
|
declare function isDue(window: DigestWindow, moment: LocalMoment, sendHour: number, sendWeekday: number): boolean;
|
|
@@ -39,10 +26,7 @@ type DigestEntry = {
|
|
|
39
26
|
createdAt: Date;
|
|
40
27
|
};
|
|
41
28
|
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
|
-
*/
|
|
29
|
+
/** The type the composed digest is sent as; the app defines its template over `{ items }`. */
|
|
46
30
|
digestType?: string;
|
|
47
31
|
/** Local hour at which a digest becomes due. */
|
|
48
32
|
sendHour?: number;
|
|
@@ -53,16 +37,7 @@ type DigestsOptions = {
|
|
|
53
37
|
/** Safety valve on one cron run. */
|
|
54
38
|
maxUsersPerRun?: number;
|
|
55
39
|
};
|
|
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
|
-
*/
|
|
40
|
+
/** One email containing N items, rather than N deferred emails at 9am. Depends on `preferences` for the frequency. */
|
|
66
41
|
declare function digests(options?: DigestsOptions): EasyPingPlugin<"digests">;
|
|
67
42
|
|
|
68
43
|
export { type DigestEntry, type DigestWindow, type DigestsOptions, type LocalMoment, digests, isDue, localMoment, periodKey };
|
package/dist/plugins/digests.js
CHANGED
|
@@ -133,10 +133,7 @@ function digests(options = {}) {
|
|
|
133
133
|
},
|
|
134
134
|
routes: [
|
|
135
135
|
{
|
|
136
|
-
/**
|
|
137
|
-
* Point an hourly cron here. Hourly rather than daily because "9am"
|
|
138
|
-
* is 24 different UTC moments, and which ones shift with DST.
|
|
139
|
-
*/
|
|
136
|
+
/** Point an hourly cron here: "9am" is 24 different UTC moments that shift with DST. */
|
|
140
137
|
path: "/digests/cron",
|
|
141
138
|
method: "POST",
|
|
142
139
|
scope: { type: "machine" },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":"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","file":"digests.js","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"]}
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":"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","file":"digests.js","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/** Wall clock in the recipient's zone. hourCycle h23: hour12:false renders midnight as \"24\" in some locales. */\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/** The digest period a moment falls in, keyed off the local calendar so DST cannot double or skip a send. */\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 /** The type the composed digest is sent as; the app defines its template over `{ items }`. */\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/** One email containing N items, rather than N deferred emails at 9am. Depends on `preferences` for the frequency. */\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 /** Point an hourly cron here: \"9am\" is 24 different UTC moments that shift with DST. */\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 // Idempotent on the period key, so a cron that runs twice 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: a crash re-sends rather than drops.\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"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/core/plugin.ts","../../src/plugins/preferences/client.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"sources":["../../src/core/plugin.ts","../../src/plugins/preferences/client.ts"],"names":[],"mappings":";;;AA2LO,SAAS,mBACd,MAAA,EAC6B;AAC7B,EAAA,OAAO,MAAA;AACT;;;AC3KO,IAAM,iBAAA,GAAoB,MAC/B,kBAAA,CAAmB;AAAA,EACjB,EAAA,EAAI,aAAA;AAAA,EACJ,oBAAoB,EAAC;AAAA,EAErB,OAAA,EAAS,CAAC,KAAA,MAAW;AAAA,IACnB,eAAA,EAAiB,YAId,MAAM,KAAA,CAAM,cAAc,CAAA;AAAA,IAK7B,gBAAA,EAAkB,OAAO,KAAA,KACtB,MAAM,MAAM,cAAA,EAAgB;AAAA,MAC3B,MAAA,EAAQ,MAAA;AAAA,MACR,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,KAAK;AAAA,KAC3B;AAAA,GACL;AACF,CAAC","file":"preferences-client.cjs","sourcesContent":["import type { DeliveryOutcome } from \"./adapter\";\nimport type { Logger } from \"./errors\";\nimport type { PluginStore } from \"./store\";\nimport type { SignRequest, TokenClaims } from \"./tokens\";\nimport type { Channel, Recipient } from \"./types\";\n\nexport type Promisable<T> = T | Promise<T>;\n\nexport type PrepareContext = {\n type: string;\n recipients: readonly Recipient[];\n};\n\nexport type BeforeSendContext = {\n type: string;\n recipient: Recipient;\n payload: unknown;\n actorId: string | null;\n dedupeKey: string | null;\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type BeforeSendResult =\n | { action: \"continue\"; payload?: unknown; dedupeKey?: string }\n | { action: \"cancel\"; reason: string };\n\nexport type ChannelDecision = {\n channel: Channel;\n action: \"send\" | \"skip\" | \"defer\";\n /** defer only. Written to notification_delivery.not_before. */\n notBefore?: Date | undefined;\n};\n\nexport type ResolveChannelsContext = {\n type: string;\n recipient: Recipient;\n payload: unknown;\n decisions: readonly ChannelDecision[];\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type DeliverContext = {\n channel: Channel;\n deliveryId: string;\n attempt: number;\n recipient: Recipient;\n notification: { id: string; userId: string; type: string; payload: unknown };\n /** Aborted on the runner's timeout. */\n signal: AbortSignal;\n};\n\nexport type AfterSendContext = {\n type: string;\n payload: unknown;\n /** Committed rows. Ids exist only at this point. */\n notifications: readonly { id: string; userId: string }[];\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type AfterDeliverContext = {\n deliveryId: string;\n notificationId: string;\n userId: string;\n channel: Channel;\n outcome: DeliveryOutcome;\n attempt: number;\n isTerminal: boolean;\n};\n\n/** beforeSend and resolveChannels fail closed; afterDeliver fails open. */\nexport type PluginHooks = {\n /** Once per send, for bulk loading — the per-recipient hooks would be an N+1. */\n prepare?: (ctx: PrepareContext) => Promisable<unknown>;\n beforeSend?: (ctx: BeforeSendContext) => Promisable<BeforeSendResult>;\n resolveChannels?: (ctx: ResolveChannelsContext) => Promisable<readonly ChannelDecision[]>;\n /** Once per send, after commit. The only hook that sees notification ids. Fails open. */\n afterSend?: (ctx: AfterSendContext) => Promisable<void>;\n /** Delivers one of the plugin's declared `channels`. Core carries inApp and email. */\n deliver?: (ctx: DeliverContext) => Promisable<DeliveryOutcome>;\n afterDeliver?: (ctx: AfterDeliverContext) => Promisable<void>;\n};\n\nexport type FieldType = \"string\" | \"number\" | \"boolean\" | \"date\" | \"json\";\n\nexport type FieldDeclaration = {\n type: FieldType;\n required?: boolean;\n default?: string | number | boolean;\n /** date fields only. Renders as defaultNow() / CURRENT_TIMESTAMP. */\n defaultNow?: boolean;\n};\n\nexport type IndexDeclaration = {\n on: readonly string[];\n unique?: boolean;\n name?: string;\n};\n\nexport type TableDeclaration = {\n tableName: string;\n fields: Record<string, FieldDeclaration>;\n primaryKey?: readonly string[];\n indexes?: readonly IndexDeclaration[];\n};\n\nexport type SchemaDeclaration = Record<string, TableDeclaration>;\n\nexport type RouteScope =\n | { type: \"user\" }\n | { type: \"machine\" }\n | { type: \"signed\"; purpose: string }\n | { type: \"custom\"; justification: string };\n\nexport type RouteContext = {\n request: Request;\n /** Resolved for user-scoped routes, so a handler cannot forget to scope. */\n userId: string | null;\n /** Verified before dispatch, so a handler cannot forget to check. */\n claims: TokenClaims | null;\n params: Record<string, string>;\n};\n\nexport type RouteDefinition = {\n path: string;\n method: \"GET\" | \"POST\";\n scope: RouteScope;\n handler: (ctx: RouteContext) => Promise<Response>;\n};\n\n/** Handed to a plugin at startup so it need not be given the database twice. */\nexport type PluginInitContext = {\n /** Scoped to the tables this plugin declares in schema(). */\n store: PluginStore;\n /** Mints tokens for this plugin's own `signed` routes only. The secret itself is never handed out. */\n sign: (request: SignRequest) => Promise<string>;\n logger: Logger;\n /** Configured notification names, so a route can refuse to store rows for types that do not exist. */\n notificationTypes: readonly string[];\n /** The app's resolver, for plugins that need timezones or addresses. */\n getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;\n /** The instance's own send(), so a composed message inherits retry, backoff and idempotency. */\n send: (\n type: string,\n args: { to: string | readonly string[]; payload: unknown; dedupeKey?: string },\n ) => Promise<unknown>;\n};\n\nexport type EasyPingPlugin<\n TId extends string = string,\n TSchema extends SchemaDeclaration = SchemaDeclaration,\n> = {\n id: TId;\n dependsOn?: readonly string[];\n schema?: TSchema;\n routes?: readonly RouteDefinition[];\n hooks?: PluginHooks;\n /** Channels this plugin delivers. Makes them usable in a notification. */\n channels?: readonly Channel[];\n /** Called once by easyPing() before any hook or route can run. */\n init?: (ctx: PluginInitContext) => void;\n};\n\nexport type AnyPlugin = EasyPingPlugin<string, SchemaDeclaration>;\n\nexport function definePlugin<TId extends string, TSchema extends SchemaDeclaration>(\n plugin: EasyPingPlugin<TId, TSchema>,\n): EasyPingPlugin<TId, TSchema> {\n return plugin;\n}\n\nexport type ClientFetch = (path: string, init?: RequestInit) => Promise<unknown>;\n\nexport type ClientPlugin<\n TId extends string = string,\n TActions extends Record<string, unknown> = Record<string, unknown>,\n> = {\n id: TId;\n /** Phantom. Links client types to the server plugin so a mismatch is a type error. */\n $InferServerPlugin?: AnyPlugin;\n actions: (fetch: ClientFetch) => TActions;\n};\n\nexport type AnyClientPlugin = ClientPlugin<string, Record<string, unknown>>;\n\nexport function defineClientPlugin<TId extends string, TActions extends Record<string, unknown>>(\n plugin: ClientPlugin<TId, TActions>,\n): ClientPlugin<TId, TActions> {\n return plugin;\n}\n\nexport type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (\n k: infer I,\n) => void\n ? I\n : never;\n\n/** Naked type parameter, so the conditional distributes over the union. */\ntype ExtractActions<TPlugin> = TPlugin extends { actions: (fetch: never) => infer TActions }\n ? TActions\n : never;\n\n/** Indexes the tuple rather than recursing: a fold blows the instantiation depth limit. */\nexport type ActionsOf<TPlugins extends readonly AnyClientPlugin[]> = UnionToIntersection<\n ExtractActions<TPlugins[number]>\n>;\n","import { defineClientPlugin } from \"../../core/plugin\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { preferences } from \"./index\";\n\nexport type PreferenceView = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n};\n\nexport type UpdatePreferenceInput = {\n type: string;\n channel: Channel;\n enabled?: boolean;\n frequency?: Frequency;\n};\n\n/** Paired by phantom type: importing the server plugin's value would bundle the DB. */\nexport const preferencesClient = () =>\n defineClientPlugin({\n id: \"preferences\",\n $InferServerPlugin: {} as ReturnType<typeof preferences>,\n\n actions: (fetch) => ({\n listPreferences: async (): Promise<{\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n }> =>\n (await fetch(\"/preferences\")) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n },\n\n updatePreference: async (input: UpdatePreferenceInput): Promise<{ ok: boolean }> =>\n (await fetch(\"/preferences\", {\n method: \"POST\",\n body: JSON.stringify(input),\n })) as { ok: boolean },\n }),\n });\n\nexport type PreferencesClient = ReturnType<typeof preferencesClient>;\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { f as ClientPlugin } from '../plugin-
|
|
2
|
-
import { C as Channel, F as Frequency } from '../adapter-
|
|
1
|
+
import { f as ClientPlugin } from '../plugin-CHd2ZiGC.cjs';
|
|
2
|
+
import { C as Channel, F as Frequency } from '../adapter-D3lTy-4E.cjs';
|
|
3
3
|
|
|
4
4
|
type PreferenceView = {
|
|
5
5
|
userId: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { f as ClientPlugin } from '../plugin-
|
|
2
|
-
import { C as Channel, F as Frequency } from '../adapter-
|
|
1
|
+
import { f as ClientPlugin } from '../plugin-Cg9usuUi.js';
|
|
2
|
+
import { C as Channel, F as Frequency } from '../adapter-D3lTy-4E.js';
|
|
3
3
|
|
|
4
4
|
type PreferenceView = {
|
|
5
5
|
userId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/preferences/react.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n\nexport type UsePreferencesOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nexport type PreferenceKey = { type: string; channel: Channel };\n\nexport type UsePreferencesResult = {\n preferences: readonly PreferenceView[];\n defaultEnabled: boolean;\n isLoading: boolean;\n error: Error | null;\n /** Resolved value for a cell, falling back to the configured default. */\n isEnabled: (key: PreferenceKey) => boolean;\n setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;\n setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;\n refresh: () => Promise<void>;\n};\n\nconst matches = (row: PreferenceView, key: PreferenceKey) =>\n row.type === key.type && row.channel === key.channel;\n\n/** Headless: state and persistence only, no markup. */\nexport function usePreferences(options: UsePreferencesOptions = {}): UsePreferencesResult {\n const { baseUrl = \"/api/notifications\", fetch: providedFetch } = options;\n\n const doFetch = useMemo(\n () =>\n providedFetch ??\n ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args)),\n [providedFetch],\n );\n\n const [preferences, setPreferences] = useState<readonly PreferenceView[]>([]);\n const [defaultEnabled, setDefaultEnabled] = useState(true);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const refresh = useCallback(async () => {\n try {\n const response = await doFetch(`${baseUrl}/preferences`);\n if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);\n\n const body = (await response.json()) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n };\n\n setPreferences(body.preferences);\n setDefaultEnabled(body.defaultEnabled);\n setError(null);\n } catch (caught) {\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n } finally {\n setIsLoading(false);\n }\n }, [baseUrl, doFetch]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const persist = useCallback(\n async (input: UpdatePreferenceInput, optimistic: Partial<PreferenceView>) => {\n const previous = preferences;\n\n //
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/preferences/react.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n\nexport type UsePreferencesOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nexport type PreferenceKey = { type: string; channel: Channel };\n\nexport type UsePreferencesResult = {\n preferences: readonly PreferenceView[];\n defaultEnabled: boolean;\n isLoading: boolean;\n error: Error | null;\n /** Resolved value for a cell, falling back to the configured default. */\n isEnabled: (key: PreferenceKey) => boolean;\n setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;\n setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;\n refresh: () => Promise<void>;\n};\n\nconst matches = (row: PreferenceView, key: PreferenceKey) =>\n row.type === key.type && row.channel === key.channel;\n\n/** Headless: state and persistence only, no markup. */\nexport function usePreferences(options: UsePreferencesOptions = {}): UsePreferencesResult {\n const { baseUrl = \"/api/notifications\", fetch: providedFetch } = options;\n\n const doFetch = useMemo(\n () =>\n providedFetch ??\n ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args)),\n [providedFetch],\n );\n\n const [preferences, setPreferences] = useState<readonly PreferenceView[]>([]);\n const [defaultEnabled, setDefaultEnabled] = useState(true);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const refresh = useCallback(async () => {\n try {\n const response = await doFetch(`${baseUrl}/preferences`);\n if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);\n\n const body = (await response.json()) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n };\n\n setPreferences(body.preferences);\n setDefaultEnabled(body.defaultEnabled);\n setError(null);\n } catch (caught) {\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n } finally {\n setIsLoading(false);\n }\n }, [baseUrl, doFetch]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const persist = useCallback(\n async (input: UpdatePreferenceInput, optimistic: Partial<PreferenceView>) => {\n const previous = preferences;\n\n // Optimistic: apply now, revert on failure.\n setPreferences((rows) => {\n const existing = rows.find((row) => matches(row, input));\n if (existing) {\n return rows.map((row) => (matches(row, input) ? { ...row, ...optimistic } : row));\n }\n return [\n ...rows,\n {\n userId: \"\",\n type: input.type,\n channel: input.channel,\n enabled: true,\n frequency: \"instant\" as Frequency,\n ...optimistic,\n },\n ];\n });\n\n try {\n const response = await doFetch(`${baseUrl}/preferences`, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(input),\n });\n if (!response.ok) throw new Error(`POST /preferences failed: ${response.status}`);\n } catch (caught) {\n setPreferences(previous);\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n throw caught;\n }\n },\n [baseUrl, doFetch, preferences],\n );\n\n return {\n preferences,\n defaultEnabled,\n isLoading,\n error,\n\n isEnabled: useCallback(\n (key: PreferenceKey) => {\n const row = preferences.find((candidate) => matches(candidate, key));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n },\n [preferences, defaultEnabled],\n ),\n\n setEnabled: useCallback(\n (key: PreferenceKey, enabled: boolean) =>\n persist(\n { ...key, enabled, frequency: enabled ? \"instant\" : \"off\" },\n { enabled, frequency: enabled ? \"instant\" : \"off\" },\n ),\n [persist],\n ),\n\n setFrequency: useCallback(\n (key: PreferenceKey, frequency: Frequency) =>\n persist(\n { ...key, enabled: frequency !== \"off\", frequency },\n { enabled: frequency !== \"off\", frequency },\n ),\n [persist],\n ),\n\n refresh,\n };\n}\n\nexport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAEA,mBAA0D;AAuB1D,IAAM,UAAU,CAAC,KAAqB,QACpC,IAAI,SAAS,IAAI,QAAQ,IAAI,YAAY,IAAI;AAGxC,SAAS,eAAe,UAAiC,CAAC,GAAyB;AACxF,QAAM,EAAE,UAAU,sBAAsB,OAAO,cAAc,IAAI;AAEjE,QAAM,cAAU;AAAA,IACd,MACE,kBACC,IAAI,SAA8C,WAAW,MAAM,GAAG,IAAI;AAAA,IAC7E,CAAC,aAAa;AAAA,EAChB;AAEA,QAAM,CAAC,aAAa,cAAc,QAAI,uBAAoC,CAAC,CAAC;AAC5E,QAAM,CAAC,gBAAgB,iBAAiB,QAAI,uBAAS,IAAI;AACzD,QAAM,CAAC,WAAW,YAAY,QAAI,uBAAS,IAAI;AAC/C,QAAM,CAAC,OAAO,QAAQ,QAAI,uBAAuB,IAAI;AAErD,QAAM,cAAU,0BAAY,YAAY;AACtC,QAAI;AACF,YAAM,WAAW,MAAM,QAAQ,GAAG,OAAO,cAAc;AACvD,UAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,4BAA4B,SAAS,MAAM,EAAE;AAE/E,YAAM,OAAQ,MAAM,SAAS,KAAK;AAKlC,qBAAe,KAAK,WAAW;AAC/B,wBAAkB,KAAK,cAAc;AACrC,eAAS,IAAI;AAAA,IACf,SAAS,QAAQ;AACf,eAAS,kBAAkB,QAAQ,SAAS,IAAI,MAAM,OAAO,MAAM,CAAC,CAAC;AAAA,IACvE,UAAE;AACA,mBAAa,KAAK;AAAA,IACpB;AAAA,EACF,GAAG,CAAC,SAAS,OAAO,CAAC;AAErB,8BAAU,MAAM;AACd,SAAK,QAAQ;AAAA,EACf,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,cAAU;AAAA,IACd,OAAO,OAA8B,eAAwC;AAC3E,YAAM,WAAW;AAGjB,qBAAe,CAAC,SAAS;AACvB,cAAM,WAAW,KAAK,KAAK,CAAC,QAAQ,QAAQ,KAAK,KAAK,CAAC;AACvD,YAAI,UAAU;AACZ,iBAAO,KAAK,IAAI,CAAC,QAAS,QAAQ,KAAK,KAAK,IAAI,EAAE,GAAG,KAAK,GAAG,WAAW,IAAI,GAAI;AAAA,QAClF;AACA,eAAO;AAAA,UACL,GAAG;AAAA,UACH;AAAA,YACE,QAAQ;AAAA,YACR,MAAM,MAAM;AAAA,YACZ,SAAS,MAAM;AAAA,YACf,SAAS;AAAA,YACT,WAAW;AAAA,YACX,GAAG;AAAA,UACL;AAAA,QACF;AAAA,MACF,CAAC;AAED,UAAI;AACF,cAAM,WAAW,MAAM,QAAQ,GAAG,OAAO,gBAAgB;AAAA,UACvD,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,KAAK;AAAA,QAC5B,CAAC;AACD,YAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,6BAA6B,SAAS,MAAM,EAAE;AAAA,MAClF,SAAS,QAAQ;AACf,uBAAe,QAAQ;AACvB,iBAAS,kBAAkB,QAAQ,SAAS,IAAI,MAAM,OAAO,MAAM,CAAC,CAAC;AACrE,cAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,CAAC,SAAS,SAAS,WAAW;AAAA,EAChC;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IAEA,eAAW;AAAA,MACT,CAAC,QAAuB;AACtB,cAAM,MAAM,YAAY,KAAK,CAAC,cAAc,QAAQ,WAAW,GAAG,CAAC;AACnE,eAAO,MAAM,IAAI,WAAW,IAAI,cAAc,QAAQ;AAAA,MACxD;AAAA,MACA,CAAC,aAAa,cAAc;AAAA,IAC9B;AAAA,IAEA,gBAAY;AAAA,MACV,CAAC,KAAoB,YACnB;AAAA,QACE,EAAE,GAAG,KAAK,SAAS,WAAW,UAAU,YAAY,MAAM;AAAA,QAC1D,EAAE,SAAS,WAAW,UAAU,YAAY,MAAM;AAAA,MACpD;AAAA,MACF,CAAC,OAAO;AAAA,IACV;AAAA,IAEA,kBAAc;AAAA,MACZ,CAAC,KAAoB,cACnB;AAAA,QACE,EAAE,GAAG,KAAK,SAAS,cAAc,OAAO,UAAU;AAAA,QAClD,EAAE,SAAS,cAAc,OAAO,UAAU;AAAA,MAC5C;AAAA,MACF,CAAC,OAAO;AAAA,IACV;AAAA,IAEA;AAAA,EACF;AACF;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/preferences/react.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n\nexport type UsePreferencesOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nexport type PreferenceKey = { type: string; channel: Channel };\n\nexport type UsePreferencesResult = {\n preferences: readonly PreferenceView[];\n defaultEnabled: boolean;\n isLoading: boolean;\n error: Error | null;\n /** Resolved value for a cell, falling back to the configured default. */\n isEnabled: (key: PreferenceKey) => boolean;\n setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;\n setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;\n refresh: () => Promise<void>;\n};\n\nconst matches = (row: PreferenceView, key: PreferenceKey) =>\n row.type === key.type && row.channel === key.channel;\n\n/** Headless: state and persistence only, no markup. */\nexport function usePreferences(options: UsePreferencesOptions = {}): UsePreferencesResult {\n const { baseUrl = \"/api/notifications\", fetch: providedFetch } = options;\n\n const doFetch = useMemo(\n () =>\n providedFetch ??\n ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args)),\n [providedFetch],\n );\n\n const [preferences, setPreferences] = useState<readonly PreferenceView[]>([]);\n const [defaultEnabled, setDefaultEnabled] = useState(true);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const refresh = useCallback(async () => {\n try {\n const response = await doFetch(`${baseUrl}/preferences`);\n if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);\n\n const body = (await response.json()) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n };\n\n setPreferences(body.preferences);\n setDefaultEnabled(body.defaultEnabled);\n setError(null);\n } catch (caught) {\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n } finally {\n setIsLoading(false);\n }\n }, [baseUrl, doFetch]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const persist = useCallback(\n async (input: UpdatePreferenceInput, optimistic: Partial<PreferenceView>) => {\n const previous = preferences;\n\n //
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/preferences/react.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n\nexport type UsePreferencesOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nexport type PreferenceKey = { type: string; channel: Channel };\n\nexport type UsePreferencesResult = {\n preferences: readonly PreferenceView[];\n defaultEnabled: boolean;\n isLoading: boolean;\n error: Error | null;\n /** Resolved value for a cell, falling back to the configured default. */\n isEnabled: (key: PreferenceKey) => boolean;\n setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;\n setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;\n refresh: () => Promise<void>;\n};\n\nconst matches = (row: PreferenceView, key: PreferenceKey) =>\n row.type === key.type && row.channel === key.channel;\n\n/** Headless: state and persistence only, no markup. */\nexport function usePreferences(options: UsePreferencesOptions = {}): UsePreferencesResult {\n const { baseUrl = \"/api/notifications\", fetch: providedFetch } = options;\n\n const doFetch = useMemo(\n () =>\n providedFetch ??\n ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args)),\n [providedFetch],\n );\n\n const [preferences, setPreferences] = useState<readonly PreferenceView[]>([]);\n const [defaultEnabled, setDefaultEnabled] = useState(true);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const refresh = useCallback(async () => {\n try {\n const response = await doFetch(`${baseUrl}/preferences`);\n if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);\n\n const body = (await response.json()) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n };\n\n setPreferences(body.preferences);\n setDefaultEnabled(body.defaultEnabled);\n setError(null);\n } catch (caught) {\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n } finally {\n setIsLoading(false);\n }\n }, [baseUrl, doFetch]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const persist = useCallback(\n async (input: UpdatePreferenceInput, optimistic: Partial<PreferenceView>) => {\n const previous = preferences;\n\n // Optimistic: apply now, revert on failure.\n setPreferences((rows) => {\n const existing = rows.find((row) => matches(row, input));\n if (existing) {\n return rows.map((row) => (matches(row, input) ? { ...row, ...optimistic } : row));\n }\n return [\n ...rows,\n {\n userId: \"\",\n type: input.type,\n channel: input.channel,\n enabled: true,\n frequency: \"instant\" as Frequency,\n ...optimistic,\n },\n ];\n });\n\n try {\n const response = await doFetch(`${baseUrl}/preferences`, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(input),\n });\n if (!response.ok) throw new Error(`POST /preferences failed: ${response.status}`);\n } catch (caught) {\n setPreferences(previous);\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n throw caught;\n }\n },\n [baseUrl, doFetch, preferences],\n );\n\n return {\n preferences,\n defaultEnabled,\n isLoading,\n error,\n\n isEnabled: useCallback(\n (key: PreferenceKey) => {\n const row = preferences.find((candidate) => matches(candidate, key));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n },\n [preferences, defaultEnabled],\n ),\n\n setEnabled: useCallback(\n (key: PreferenceKey, enabled: boolean) =>\n persist(\n { ...key, enabled, frequency: enabled ? \"instant\" : \"off\" },\n { enabled, frequency: enabled ? \"instant\" : \"off\" },\n ),\n [persist],\n ),\n\n setFrequency: useCallback(\n (key: PreferenceKey, frequency: Frequency) =>\n persist(\n { ...key, enabled: frequency !== \"off\", frequency },\n { enabled: frequency !== \"off\", frequency },\n ),\n [persist],\n ),\n\n refresh,\n };\n}\n\nexport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n"],"mappings":"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","names":[]}
|