@krizaka/notifications 0.2.2 → 0.2.3

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.
@@ -26,4 +26,4 @@ export {
26
26
  NOTIFICATION_CODE,
27
27
  NotificationError
28
28
  };
29
- //# sourceMappingURL=chunk-RTHXH2BK.js.map
29
+ //# sourceMappingURL=chunk-6RZLSKEN.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/port.ts"],"sourcesContent":["/**\n * The ports of « notifications »: the bell (an item per notification), the account's preferences (a matrix of categories by\n * channels), the phones registered for push, and one outbox message per enabled channel. Generic: no push or realtime import (the\n * workers that deliver a channel are the product's, or the Java service's for e-mail), no database client, no wall clock.\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/**\n * The channels an account switches. `app` is the bell and the live event of the open pages (one switch);\n * `push` goes to the account's registered phones; `email` to its address.\n */\nexport const CHANNEL = { app: \"app\", push: \"push\", email: \"email\" } as const;\nexport type Channel = (typeof CHANNEL)[keyof typeof CHANNEL];\nexport const CHANNELS: readonly Channel[] = [CHANNEL.app, CHANNEL.push, CHANNEL.email];\n\n/** What the outbox carries: the `channel` of Java's `evt.notification.requested.v1` (EMAIL) and two more for the web (PUSH, LIVE). */\nexport const DELIVERY = { email: \"EMAIL\", push: \"PUSH\", live: \"LIVE\" } as const;\nexport type Delivery = (typeof DELIVERY)[keyof typeof DELIVERY];\n\nexport const PLATFORM = { ios: \"ios\", android: \"android\", web: \"web\" } as const;\nexport type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];\n\n/** A category as the product declares it. */\nexport interface CategoryDefaults {\n /** Cannot be switched off (security, the account): its channels follow `defaults`, whatever the account chose. */\n locked: boolean;\n /** Which channels are on until the account chooses. */\n defaults: Record<Channel, boolean>;\n /** After `burstMax` notifications of this category to one account within `burstWindowSeconds`, the next ones are bell-only. */\n burstMax: number;\n burstWindowSeconds: number;\n}\n\n/** The categories (declared by the product, tuned by the admin) and the retention. Read from the settings at each call. */\nexport interface NotificationConfig {\n categories: Record<string, CategoryDefaults>;\n /** Days an item is kept before `purge`. */\n retentionDays: number;\n}\n\n/** One line of the bell. */\nexport interface Item {\n id: string;\n accountId: string;\n category: string;\n template: string;\n payload: Record<string, unknown>;\n dedupeKey: string | null;\n /** Shown in the bell. An item whose app channel was off is kept (the burst count and the history) but not shown. */\n inBell: boolean;\n createdAt: Date;\n readAt: Date | null;\n}\n\nexport interface Device {\n id: string;\n accountId: string;\n platform: Platform;\n token: string;\n createdAt: Date;\n lastSeenAt: Date;\n}\n\n/** Where the state lives. Every adapter runs `notificationStoreContract` (`@krizaka/notifications/testkit`). */\nexport interface NotificationStore {\n /**\n * Writes the item and says whether it exceeds the burst cap, ATOMICALLY per account and category (the database serialises\n * concurrent callers with a transaction-level advisory lock, so the cap holds under concurrency: pass the caller's `tx`).\n * `duplicate` when the account already has an item with this `dedupeKey`: nothing is written.\n */\n record(item: Item, burst: { max: number; since: Date }): Promise<{ duplicate: boolean; capped: boolean }>;\n /** The account's explicit choices. */\n preferences(accountId: string): Promise<{ category: string; channel: Channel; enabled: boolean }[]>;\n setPreference(accountId: string, category: string, channel: Channel, enabled: boolean, now: Date): Promise<void>;\n /** Items shown in the bell, newest first; `before` is the cursor (the last item of the previous page). */\n list(accountId: string, options: { unreadOnly: boolean; limit: number; before?: { createdAt: Date; id: string } }): Promise<Item[]>;\n unreadCount(accountId: string): Promise<number>;\n /** Marks read the account's own unread items among `ids` (all of them when `ids` is null). The first `read_at` stays. Returns how many. */\n markRead(accountId: string, ids: readonly string[] | null, now: Date): Promise<number>;\n /** A token belongs to one account: registering it again moves it, and brings back a revoked one. */\n upsertDevice(device: Device): Promise<void>;\n devices(accountId: string): Promise<Device[]>;\n /** The push service said the token is dead. */\n revokeDevice(token: string, now: Date): Promise<boolean>;\n purgeBefore(before: Date): Promise<number>;\n /** Removes everything held about the account. Returns how many rows. */\n erase(accountId: string): Promise<number>;\n}\n\nexport const NOTIFICATION_CODE = {\n badCategory: \"NOTIFICATIONS_BAD_CATEGORY\",\n badInput: \"NOTIFICATIONS_BAD_INPUT\",\n locked: \"NOTIFICATIONS_LOCKED\",\n badConfig: \"NOTIFICATIONS_BAD_CONFIG\",\n} as const;\nexport type NotificationCode = (typeof NOTIFICATION_CODE)[keyof typeof NOTIFICATION_CODE];\n\nexport class NotificationError extends Error {\n constructor(\n readonly code: NotificationCode,\n message: string,\n ) {\n super(message);\n this.name = \"NotificationError\";\n }\n}\n"],"mappings":";AAWO,IAAM,UAAU,EAAE,KAAK,OAAO,MAAM,QAAQ,OAAO,QAAQ;AAE3D,IAAM,WAA+B,CAAC,QAAQ,KAAK,QAAQ,MAAM,QAAQ,KAAK;AAG9E,IAAM,WAAW,EAAE,OAAO,SAAS,MAAM,QAAQ,MAAM,OAAO;AAG9D,IAAM,WAAW,EAAE,KAAK,OAAO,SAAS,WAAW,KAAK,MAAM;AAsE9D,IAAM,oBAAoB;AAAA,EAC/B,aAAa;AAAA,EACb,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AACb;AAGO,IAAM,oBAAN,cAAgC,MAAM;AAAA,EAC3C,YACW,MACT,SACA;AACA,UAAM,OAAO;AAHJ;AAIT,SAAK,OAAO;AAAA,EACd;AAAA,EALW;AAMb;","names":[]}
1
+ {"version":3,"sources":["../src/port.ts"],"sourcesContent":["/**\n * The ports of « notifications »: the bell (an item per notification), the account's preferences (a matrix of categories by\n * channels), the phones registered for push, and one outbox message per enabled channel. Generic: no push or realtime import (the\n * workers that deliver a channel are the product's, or the Java service's for e-mail), no database client, no wall clock.\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/**\n * The channels an account switches. `app` is the bell and the live event of the open pages (one switch);\n * `push` goes to the account's registered phones; `email` to its address.\n */\nexport const CHANNEL = { app: \"app\", push: \"push\", email: \"email\" } as const;\nexport type Channel = (typeof CHANNEL)[keyof typeof CHANNEL];\nexport const CHANNELS: readonly Channel[] = [CHANNEL.app, CHANNEL.push, CHANNEL.email];\n\n/** What the outbox carries: the `channel` of Java's `evt.notification.requested.v1` (EMAIL) and two more for the web (PUSH, LIVE). */\nexport const DELIVERY = { email: \"EMAIL\", push: \"PUSH\", live: \"LIVE\" } as const;\nexport type Delivery = (typeof DELIVERY)[keyof typeof DELIVERY];\n\nexport const PLATFORM = { ios: \"ios\", android: \"android\", web: \"web\" } as const;\nexport type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];\n\n/** A category as the product declares it. */\nexport interface CategoryDefaults {\n /** Cannot be switched off (security, the account): its channels follow `defaults`, whatever the account chose. */\n locked: boolean;\n /** Which channels are on until the account chooses. */\n defaults: Record<Channel, boolean>;\n /** After `burstMax` notifications of this category to one account within `burstWindowSeconds`, the next ones are bell-only. */\n burstMax: number;\n burstWindowSeconds: number;\n}\n\n/** The categories (declared by the product, tuned by the admin) and the retention. Read from the settings at each call. */\nexport interface NotificationConfig {\n categories: Record<string, CategoryDefaults>;\n /** Days an item is kept before `purge`. */\n retentionDays: number;\n}\n\n/** One line of the bell. */\nexport interface Item {\n id: string;\n accountId: string;\n category: string;\n template: string;\n payload: Record<string, unknown>;\n dedupeKey: string | null;\n /** Shown in the bell. An item whose app channel was off is kept (the burst count and the history) but not shown. */\n inBell: boolean;\n createdAt: Date;\n readAt: Date | null;\n}\n\nexport interface Device {\n id: string;\n accountId: string;\n platform: Platform;\n token: string;\n createdAt: Date;\n lastSeenAt: Date;\n}\n\n/** Where the state lives. Every adapter runs `notificationStoreContract` (`@krizaka/notifications/testkit`). */\nexport interface NotificationStore {\n /**\n * Writes the item and says whether it exceeds the burst cap, ATOMICALLY per account and category (the database serialises\n * concurrent callers with a transaction-level advisory lock, so the cap holds under concurrency: pass the caller's `tx`).\n * `duplicate` when the account already has an item with this `dedupeKey`: nothing is written.\n */\n record(item: Item, burst: { max: number; since: Date }): Promise<{ duplicate: boolean; capped: boolean }>;\n /** The account's explicit choices. */\n preferences(accountId: string): Promise<{ category: string; channel: Channel; enabled: boolean }[]>;\n setPreference(accountId: string, category: string, channel: Channel, enabled: boolean, now: Date): Promise<void>;\n /** Items shown in the bell, newest first; `before` is the cursor (the last item of the previous page). */\n list(\n accountId: string,\n options: { unreadOnly: boolean; limit: number; before?: { createdAt: Date; id: string } },\n ): Promise<Item[]>;\n unreadCount(accountId: string): Promise<number>;\n /** Marks read the account's own unread items among `ids` (all of them when `ids` is null). The first `read_at` stays. Returns how many. */\n markRead(accountId: string, ids: readonly string[] | null, now: Date): Promise<number>;\n /** A token belongs to one account: registering it again moves it, and brings back a revoked one. */\n upsertDevice(device: Device): Promise<void>;\n devices(accountId: string): Promise<Device[]>;\n /** The push service said the token is dead. */\n revokeDevice(token: string, now: Date): Promise<boolean>;\n purgeBefore(before: Date): Promise<number>;\n /** Removes everything held about the account. Returns how many rows. */\n erase(accountId: string): Promise<number>;\n}\n\nexport const NOTIFICATION_CODE = {\n badCategory: \"NOTIFICATIONS_BAD_CATEGORY\",\n badInput: \"NOTIFICATIONS_BAD_INPUT\",\n locked: \"NOTIFICATIONS_LOCKED\",\n badConfig: \"NOTIFICATIONS_BAD_CONFIG\",\n} as const;\nexport type NotificationCode = (typeof NOTIFICATION_CODE)[keyof typeof NOTIFICATION_CODE];\n\nexport class NotificationError extends Error {\n constructor(\n readonly code: NotificationCode,\n message: string,\n ) {\n super(message);\n this.name = \"NotificationError\";\n }\n}\n"],"mappings":";AAWO,IAAM,UAAU,EAAE,KAAK,OAAO,MAAM,QAAQ,OAAO,QAAQ;AAE3D,IAAM,WAA+B,CAAC,QAAQ,KAAK,QAAQ,MAAM,QAAQ,KAAK;AAG9E,IAAM,WAAW,EAAE,OAAO,SAAS,MAAM,QAAQ,MAAM,OAAO;AAG9D,IAAM,WAAW,EAAE,KAAK,OAAO,SAAS,WAAW,KAAK,MAAM;AAyE9D,IAAM,oBAAoB;AAAA,EAC/B,aAAa;AAAA,EACb,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AACb;AAGO,IAAM,oBAAN,cAAgC,MAAM;AAAA,EAC3C,YACW,MACT,SACA;AACA,UAAM,OAAO;AAHJ;AAIT,SAAK,OAAO;AAAA,EACd;AAAA,EALW;AAMb;","names":[]}
@@ -2,11 +2,15 @@ import {
2
2
  CHANNELS,
3
3
  NOTIFICATION_CODE,
4
4
  NotificationError
5
- } from "./chunk-RTHXH2BK.js";
5
+ } from "./chunk-6RZLSKEN.js";
6
6
 
7
7
  // src/rules.ts
8
8
  import { createHash } from "crypto";
9
- var NOTIFICATION_BOUNDS = { burstMax: [1, 1e3], burstWindowSeconds: [1, 604800], retentionDays: [1, 3650] };
9
+ var NOTIFICATION_BOUNDS = {
10
+ burstMax: [1, 1e3],
11
+ burstWindowSeconds: [1, 604800],
12
+ retentionDays: [1, 3650]
13
+ };
10
14
  var NOTIFICATION_DEFAULTS = { burstMax: 20, burstWindowSeconds: 3600, retentionDays: 180 };
11
15
  var CATEGORY_NAME = /^[a-z][a-zA-Z0-9]{0,39}$/;
12
16
  var TEMPLATE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
@@ -23,11 +27,15 @@ function uuidOf(seed) {
23
27
  var within = (n, [min, max]) => Number.isInteger(n) && n >= min && n <= max;
24
28
  function checkConfig(config) {
25
29
  for (const [name, c] of Object.entries(config.categories)) {
26
- if (!CATEGORY_NAME.test(name)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `category \xAB ${name} \xBB must be a camel-case word`);
27
- if (!within(c.burstMax, NOTIFICATION_BOUNDS.burstMax)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstMax is out of bounds`);
28
- if (!within(c.burstWindowSeconds, NOTIFICATION_BOUNDS.burstWindowSeconds)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstWindowSeconds is out of bounds`);
30
+ if (!CATEGORY_NAME.test(name))
31
+ throw new NotificationError(NOTIFICATION_CODE.badConfig, `category \xAB ${name} \xBB must be a camel-case word`);
32
+ if (!within(c.burstMax, NOTIFICATION_BOUNDS.burstMax))
33
+ throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstMax is out of bounds`);
34
+ if (!within(c.burstWindowSeconds, NOTIFICATION_BOUNDS.burstWindowSeconds))
35
+ throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstWindowSeconds is out of bounds`);
29
36
  }
30
- if (!within(config.retentionDays, NOTIFICATION_BOUNDS.retentionDays)) throw new NotificationError(NOTIFICATION_CODE.badConfig, "retentionDays is out of bounds");
37
+ if (!within(config.retentionDays, NOTIFICATION_BOUNDS.retentionDays))
38
+ throw new NotificationError(NOTIFICATION_CODE.badConfig, "retentionDays is out of bounds");
31
39
  return config;
32
40
  }
33
41
  function categoryOf(config, name) {
@@ -50,7 +58,10 @@ function viewOf(config, rows) {
50
58
  const channels = Object.fromEntries(
51
59
  CHANNELS.map((channel) => {
52
60
  const source = def.locked ? "locked" : chosen.has(channel) ? "account" : "default";
53
- return [channel, { enabled: def.locked ? def.defaults[channel] : chosen.get(channel) ?? def.defaults[channel], source }];
61
+ return [
62
+ channel,
63
+ { enabled: def.locked ? def.defaults[channel] : chosen.get(channel) ?? def.defaults[channel], source }
64
+ ];
54
65
  })
55
66
  );
56
67
  return { category, locked: def.locked, channels };
@@ -68,10 +79,13 @@ function checkInput(input) {
68
79
  if (typeof input.accountId !== "string" || input.accountId === "") throw bad("accountId is required");
69
80
  if (!TEMPLATE.test(input.template)) throw bad("template is lower-case words joined by hyphens");
70
81
  if (input.locale !== void 0 && !LOCALE.test(input.locale)) throw bad("locale is a language tag like fr or en-CA");
71
- if (input.dedupeKey !== void 0 && (input.dedupeKey === "" || input.dedupeKey.length > MAX_KEY)) throw bad("dedupeKey is 1 to 200 characters");
82
+ if (input.dedupeKey !== void 0 && (input.dedupeKey === "" || input.dedupeKey.length > MAX_KEY))
83
+ throw bad("dedupeKey is 1 to 200 characters");
72
84
  if (input.email !== void 0 && !isAddress(input.email)) throw bad("email is not an address");
73
- for (const [key, value] of Object.entries(input.variables ?? {})) if (typeof value !== "string") throw bad(`variable \xAB ${key} \xBB must be a string`);
74
- if (Buffer.byteLength(JSON.stringify({ v: input.variables ?? {}, p: input.payload ?? {} })) > MAX_PAYLOAD_BYTES) throw bad("variables and payload are too big for a notification");
85
+ for (const [key, value] of Object.entries(input.variables ?? {}))
86
+ if (typeof value !== "string") throw bad(`variable \xAB ${key} \xBB must be a string`);
87
+ if (Buffer.byteLength(JSON.stringify({ v: input.variables ?? {}, p: input.payload ?? {} })) > MAX_PAYLOAD_BYTES)
88
+ throw bad("variables and payload are too big for a notification");
75
89
  }
76
90
 
77
91
  export {
@@ -85,4 +99,4 @@ export {
85
99
  viewOf,
86
100
  checkInput
87
101
  };
88
- //# sourceMappingURL=chunk-AQI4ZDWW.js.map
102
+ //# sourceMappingURL=chunk-WSVZKGIC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/rules.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\n\nimport {\n type CategoryDefaults,\n type Channel,\n CHANNELS,\n NOTIFICATION_CODE,\n type NotificationConfig,\n NotificationError,\n} from \"./port\";\n\nexport const NOTIFICATION_BOUNDS = {\n burstMax: [1, 1000],\n burstWindowSeconds: [1, 604_800],\n retentionDays: [1, 3650],\n} as const;\nexport const NOTIFICATION_DEFAULTS = { burstMax: 20, burstWindowSeconds: 3600, retentionDays: 180 } as const;\n\n/** A category name: a camel-case word (it becomes part of a setting key). */\nexport const CATEGORY_NAME = /^[a-z][a-zA-Z0-9]{0,39}$/;\nconst TEMPLATE = /^[a-z0-9]+(-[a-z0-9]+)*$/;\nconst LOCALE = /^[a-z]{2}(-[A-Za-z]{2,4})?$/;\n// @fixed: a notification is a short event, not a document; a bigger payload belongs in the product's own tables\nconst MAX_PAYLOAD_BYTES = 8192;\nconst MAX_KEY = 200;\n\n/** A stable UUID (version 5 shaped) from a seed: the same fact appended twice is one outbox message. */\nexport function uuidOf(seed: string): string {\n const h = createHash(\"sha256\").update(`krizaka.notifications:${seed}`).digest();\n h[6] = (h[6]! & 0x0f) | 0x50;\n h[8] = (h[8]! & 0x3f) | 0x80;\n const x = h.subarray(0, 16).toString(\"hex\");\n return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;\n}\n\nconst within = (n: number, [min, max]: readonly [number, number]) => Number.isInteger(n) && n >= min && n <= max;\n\n/** @throws NotificationError when a category name, a burst cap or the retention is out of bounds. */\nexport function checkConfig(config: NotificationConfig): NotificationConfig {\n for (const [name, c] of Object.entries(config.categories)) {\n if (!CATEGORY_NAME.test(name))\n throw new NotificationError(NOTIFICATION_CODE.badConfig, `category « ${name} » must be a camel-case word`);\n if (!within(c.burstMax, NOTIFICATION_BOUNDS.burstMax))\n throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstMax is out of bounds`);\n if (!within(c.burstWindowSeconds, NOTIFICATION_BOUNDS.burstWindowSeconds))\n throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstWindowSeconds is out of bounds`);\n }\n if (!within(config.retentionDays, NOTIFICATION_BOUNDS.retentionDays))\n throw new NotificationError(NOTIFICATION_CODE.badConfig, \"retentionDays is out of bounds\");\n return config;\n}\n\nexport function categoryOf(config: NotificationConfig, name: string): CategoryDefaults {\n const category = Object.hasOwn(config.categories, name) ? config.categories[name] : undefined;\n if (!category) throw new NotificationError(NOTIFICATION_CODE.badCategory, `unknown category « ${name} »`);\n return category;\n}\n\n/** The channels on for an account: the category's defaults, overridden by the account's choices, unless the category is locked. */\nexport function resolveChannels(\n category: CategoryDefaults,\n chosen: ReadonlyMap<Channel, boolean>,\n): Record<Channel, boolean> {\n const out = { ...category.defaults };\n if (category.locked) return out;\n for (const channel of CHANNELS) {\n const choice = chosen.get(channel);\n if (choice !== undefined) out[channel] = choice;\n }\n return out;\n}\n\n/** What an account sees in its settings: every category, every channel, and why. */\nexport interface PreferenceView {\n category: string;\n locked: boolean;\n channels: Record<Channel, { enabled: boolean; source: \"default\" | \"account\" | \"locked\" }>;\n}\n\nexport function viewOf(\n config: NotificationConfig,\n rows: readonly { category: string; channel: Channel; enabled: boolean }[],\n): PreferenceView[] {\n return Object.entries(config.categories).map(([category, def]) => {\n const chosen = new Map(rows.filter((r) => r.category === category).map((r) => [r.channel, r.enabled] as const));\n const channels = Object.fromEntries(\n CHANNELS.map((channel) => {\n const source = def.locked ? \"locked\" : chosen.has(channel) ? \"account\" : \"default\";\n return [\n channel,\n { enabled: def.locked ? def.defaults[channel] : (chosen.get(channel) ?? def.defaults[channel]), source },\n ];\n }),\n ) as PreferenceView[\"channels\"];\n return { category, locked: def.locked, channels };\n });\n}\n\nexport interface NotifyInput {\n accountId: string;\n category: string;\n /** The template the e-mail and the push are rendered from (`new-follower`): lower-case words joined by hyphens. */\n template: string;\n /** The words' variables: strings only, the shape of Java's `NotificationRequest`. Never a secret. */\n variables?: Record<string, string>;\n /** The recipient's language; only the language is used. */\n locale?: string;\n /** Where e-mail goes. Without it the e-mail channel is skipped (the product resolves the address, this module holds none). */\n email?: string;\n /** What the bell shows besides the template and variables (a link, an actor id): JSON, small. */\n payload?: Record<string, unknown>;\n /** One event notified twice is one notification: the same key for the same fact. */\n dedupeKey?: string;\n}\n\nconst NOT_IN_ADDRESS = /[\\s<>]/;\n\n/** One `@`, a local part, and a domain of dot-separated non-empty parts: no backtracking pattern over library input. */\nfunction isAddress(value: string): boolean {\n const at = value.indexOf(\"@\");\n if (at < 1 || at !== value.lastIndexOf(\"@\") || NOT_IN_ADDRESS.test(value)) return false;\n const labels = value.slice(at + 1).split(\".\");\n return labels.length >= 2 && labels.every((label) => label !== \"\");\n}\n\n/** @throws NotificationError on the first thing wrong with an input. */\nexport function checkInput(input: NotifyInput): void {\n const bad = (why: string) => new NotificationError(NOTIFICATION_CODE.badInput, why);\n if (typeof input.accountId !== \"string\" || input.accountId === \"\") throw bad(\"accountId is required\");\n if (!TEMPLATE.test(input.template)) throw bad(\"template is lower-case words joined by hyphens\");\n if (input.locale !== undefined && !LOCALE.test(input.locale)) throw bad(\"locale is a language tag like fr or en-CA\");\n if (input.dedupeKey !== undefined && (input.dedupeKey === \"\" || input.dedupeKey.length > MAX_KEY))\n throw bad(\"dedupeKey is 1 to 200 characters\");\n if (input.email !== undefined && !isAddress(input.email)) throw bad(\"email is not an address\");\n for (const [key, value] of Object.entries(input.variables ?? {}))\n if (typeof value !== \"string\") throw bad(`variable « ${key} » must be a string`);\n if (Buffer.byteLength(JSON.stringify({ v: input.variables ?? {}, p: input.payload ?? {} })) > MAX_PAYLOAD_BYTES)\n throw bad(\"variables and payload are too big for a notification\");\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  resolveChannels,
8
8
  uuidOf,
9
9
  viewOf
10
- } from "./chunk-AQI4ZDWW.js";
10
+ } from "./chunk-WSVZKGIC.js";
11
11
  import {
12
12
  CHANNEL,
13
13
  CHANNELS,
@@ -15,7 +15,7 @@ import {
15
15
  NOTIFICATION_CODE,
16
16
  NotificationError,
17
17
  PLATFORM
18
- } from "./chunk-RTHXH2BK.js";
18
+ } from "./chunk-6RZLSKEN.js";
19
19
 
20
20
  // src/memory.ts
21
21
  var copy = (v) => ({ ...v });
@@ -27,7 +27,8 @@ function memoryNotificationStore() {
27
27
  return {
28
28
  async record(item, burst) {
29
29
  const mine = [...items.values()].filter((i) => i.accountId === item.accountId);
30
- if (item.dedupeKey !== null && mine.some((i) => i.dedupeKey === item.dedupeKey)) return { duplicate: true, capped: false };
30
+ if (item.dedupeKey !== null && mine.some((i) => i.dedupeKey === item.dedupeKey))
31
+ return { duplicate: true, capped: false };
31
32
  const recent = mine.filter((i) => i.category === item.category && i.createdAt >= burst.since).length;
32
33
  items.set(item.id, copy(item));
33
34
  return { duplicate: false, capped: recent >= burst.max };
@@ -39,7 +40,9 @@ function memoryNotificationStore() {
39
40
  prefs.set(`${accountId}|${category}|${channel}`, { category, channel, enabled });
40
41
  },
41
42
  async list(accountId, { unreadOnly, limit, before }) {
42
- return [...items.values()].filter((i) => i.accountId === accountId && i.inBell && (!unreadOnly || i.readAt === null)).sort(newestFirst).filter((i) => !before || i.createdAt < before.createdAt || i.createdAt.valueOf() === before.createdAt.valueOf() && i.id < before.id).slice(0, limit).map(copy);
43
+ return [...items.values()].filter((i) => i.accountId === accountId && i.inBell && (!unreadOnly || i.readAt === null)).sort(newestFirst).filter(
44
+ (i) => !before || i.createdAt < before.createdAt || i.createdAt.valueOf() === before.createdAt.valueOf() && i.id < before.id
45
+ ).slice(0, limit).map(copy);
43
46
  },
44
47
  async unreadCount(accountId) {
45
48
  return [...items.values()].filter((i) => i.accountId === accountId && i.inBell && i.readAt === null).length;
@@ -47,7 +50,8 @@ function memoryNotificationStore() {
47
50
  async markRead(accountId, ids, now) {
48
51
  let n = 0;
49
52
  for (const item of items.values()) {
50
- if (item.accountId !== accountId || !item.inBell || item.readAt !== null || ids !== null && !ids.includes(item.id)) continue;
53
+ if (item.accountId !== accountId || !item.inBell || item.readAt !== null || ids !== null && !ids.includes(item.id))
54
+ continue;
51
55
  item.readAt = now;
52
56
  n += 1;
53
57
  }
@@ -55,7 +59,12 @@ function memoryNotificationStore() {
55
59
  },
56
60
  async upsertDevice(device) {
57
61
  const known = devices.get(device.token);
58
- devices.set(device.token, { ...device, id: known?.id ?? device.id, createdAt: known?.createdAt ?? device.createdAt, revokedAt: null });
62
+ devices.set(device.token, {
63
+ ...device,
64
+ id: known?.id ?? device.id,
65
+ createdAt: known?.createdAt ?? device.createdAt,
66
+ revokedAt: null
67
+ });
59
68
  },
60
69
  async devices(accountId) {
61
70
  return [...devices.values()].filter((d) => d.accountId === accountId && d.revokedAt === null).map(({ revokedAt: _revoked, ...d }) => d);
@@ -91,26 +100,61 @@ import { subHours, subSeconds } from "@krizaka/time";
91
100
 
92
101
  // src/persistence.ts
93
102
  import { ident } from "@krizaka/db";
94
- var toItem = (r) => ({ id: r.id, accountId: r.account_id, category: r.category, template: r.template, payload: r.payload, dedupeKey: r.dedupe_key, inBell: r.in_bell, createdAt: r.created_at, readAt: r.read_at });
95
- var toDevice = (r) => ({ id: r.id, accountId: r.account_id, platform: r.platform, token: r.token, createdAt: r.created_at, lastSeenAt: r.last_seen_at });
103
+ var toItem = (r) => ({
104
+ id: r.id,
105
+ accountId: r.account_id,
106
+ category: r.category,
107
+ template: r.template,
108
+ payload: r.payload,
109
+ dedupeKey: r.dedupe_key,
110
+ inBell: r.in_bell,
111
+ createdAt: r.created_at,
112
+ readAt: r.read_at
113
+ });
114
+ var toDevice = (r) => ({
115
+ id: r.id,
116
+ accountId: r.account_id,
117
+ platform: r.platform,
118
+ token: r.token,
119
+ createdAt: r.created_at,
120
+ lastSeenAt: r.last_seen_at
121
+ });
96
122
  function dbNotificationStore(query, schema = "notifications") {
97
123
  const s = ident(schema);
98
124
  return {
99
125
  async record(item, burst) {
100
126
  await query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [`${s}:${item.accountId}:${item.category}`]);
101
- const [seen] = await query(`SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND category = $2 AND created_at >= $3`, [item.accountId, item.category, burst.since]);
127
+ const [seen] = await query(
128
+ `SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND category = $2 AND created_at >= $3`,
129
+ [item.accountId, item.category, burst.since]
130
+ );
102
131
  const inserted = await query(
103
132
  `INSERT INTO ${s}.item (id, account_id, category, template, payload, dedupe_key, in_bell, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
104
133
  ON CONFLICT (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING RETURNING 1`,
105
- [item.id, item.accountId, item.category, item.template, JSON.stringify(item.payload), item.dedupeKey, item.inBell, item.createdAt]
134
+ [
135
+ item.id,
136
+ item.accountId,
137
+ item.category,
138
+ item.template,
139
+ JSON.stringify(item.payload),
140
+ item.dedupeKey,
141
+ item.inBell,
142
+ item.createdAt
143
+ ]
106
144
  );
107
145
  return inserted.length === 0 ? { duplicate: true, capped: false } : { duplicate: false, capped: (seen?.n ?? 0) >= burst.max };
108
146
  },
109
147
  async preferences(accountId) {
110
- return query(`SELECT category, channel, enabled FROM ${s}.preference WHERE account_id = $1`, [accountId]);
148
+ return query(
149
+ `SELECT category, channel, enabled FROM ${s}.preference WHERE account_id = $1`,
150
+ [accountId]
151
+ );
111
152
  },
112
153
  async setPreference(accountId, category, channel, enabled, now) {
113
- await query(`INSERT INTO ${s}.preference (account_id, category, channel, enabled, updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (account_id, category, channel) DO UPDATE SET enabled = $4, updated_at = $5`, [accountId, category, channel, enabled, now]);
154
+ await query(
155
+ `INSERT INTO ${s}.preference (account_id, category, channel, enabled, updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (account_id, category, channel) DO UPDATE SET enabled = $4, updated_at = $5`,
156
+ [accountId, category, channel, enabled, now]
157
+ );
114
158
  },
115
159
  async list(accountId, { unreadOnly, limit, before }) {
116
160
  const rows = await query(
@@ -122,11 +166,17 @@ function dbNotificationStore(query, schema = "notifications") {
122
166
  return rows.map(toItem);
123
167
  },
124
168
  async unreadCount(accountId) {
125
- const [row] = await query(`SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND in_bell AND read_at IS NULL`, [accountId]);
169
+ const [row] = await query(
170
+ `SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND in_bell AND read_at IS NULL`,
171
+ [accountId]
172
+ );
126
173
  return row?.n ?? 0;
127
174
  },
128
175
  async markRead(accountId, ids, now) {
129
- const rows = await query(`UPDATE ${s}.item SET read_at = $3 WHERE account_id = $1 AND in_bell AND read_at IS NULL AND ($2::uuid[] IS NULL OR id = ANY($2::uuid[])) RETURNING 1`, [accountId, ids ? [...ids] : null, now]);
176
+ const rows = await query(
177
+ `UPDATE ${s}.item SET read_at = $3 WHERE account_id = $1 AND in_bell AND read_at IS NULL AND ($2::uuid[] IS NULL OR id = ANY($2::uuid[])) RETURNING 1`,
178
+ [accountId, ids ? [...ids] : null, now]
179
+ );
130
180
  return rows.length;
131
181
  },
132
182
  async upsertDevice(d) {
@@ -137,10 +187,16 @@ function dbNotificationStore(query, schema = "notifications") {
137
187
  );
138
188
  },
139
189
  async devices(accountId) {
140
- return (await query(`SELECT id, account_id, platform, token, created_at, last_seen_at FROM ${s}.device WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at, id`, [accountId])).map(toDevice);
190
+ return (await query(
191
+ `SELECT id, account_id, platform, token, created_at, last_seen_at FROM ${s}.device WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at, id`,
192
+ [accountId]
193
+ )).map(toDevice);
141
194
  },
142
195
  async revokeDevice(token, now) {
143
- return (await query(`UPDATE ${s}.device SET revoked_at = $2 WHERE token = $1 AND revoked_at IS NULL RETURNING 1`, [token, now])).length > 0;
196
+ return (await query(`UPDATE ${s}.device SET revoked_at = $2 WHERE token = $1 AND revoked_at IS NULL RETURNING 1`, [
197
+ token,
198
+ now
199
+ ])).length > 0;
144
200
  },
145
201
  async purgeBefore(before) {
146
202
  return (await query(`DELETE FROM ${s}.item WHERE created_at < $1 RETURNING 1`, [before])).length;
@@ -156,7 +212,11 @@ function dbNotificationStore(query, schema = "notifications") {
156
212
 
157
213
  // src/notifications.ts
158
214
  var NOTIFICATION_EVENT = { requested: "evt.notification.requested.v1" };
159
- var NOTIFICATION_TOPIC = { [DELIVERY.email]: "notifications.email", [DELIVERY.push]: "notifications.push", [DELIVERY.live]: "notifications.live" };
215
+ var NOTIFICATION_TOPIC = {
216
+ [DELIVERY.email]: "notifications.email",
217
+ [DELIVERY.push]: "notifications.push",
218
+ [DELIVERY.live]: "notifications.live"
219
+ };
160
220
  var platforms = new Set(Object.values(PLATFORM));
161
221
  function createNotifications(deps) {
162
222
  const storeFor = deps.storeFor ?? ((query) => dbNotificationStore(query, deps.schema ?? "notifications"));
@@ -170,23 +230,54 @@ function createNotifications(deps) {
170
230
  const now = deps.clock.now();
171
231
  const store = storeFor(tx);
172
232
  const channels = resolveChannels(category, asMap(await store.preferences(input.accountId), input.category));
173
- const item = { id: randomUUID(), accountId: input.accountId, category: input.category, template: input.template, payload: { variables: input.variables ?? {}, ...input.payload ?? {} }, dedupeKey: input.dedupeKey ?? null, inBell: channels.app, createdAt: now, readAt: null };
174
- const { duplicate, capped } = await store.record(item, { max: category.burstMax, since: subSeconds(now, category.burstWindowSeconds) });
233
+ const item = {
234
+ id: randomUUID(),
235
+ accountId: input.accountId,
236
+ category: input.category,
237
+ template: input.template,
238
+ payload: { variables: input.variables ?? {}, ...input.payload ?? {} },
239
+ dedupeKey: input.dedupeKey ?? null,
240
+ inBell: channels.app,
241
+ createdAt: now,
242
+ readAt: null
243
+ };
244
+ const { duplicate, capped } = await store.record(item, {
245
+ max: category.burstMax,
246
+ since: subSeconds(now, category.burstWindowSeconds)
247
+ });
175
248
  if (duplicate) return { notificationId: null, duplicate: true, capped: false, channels: [] };
176
249
  const used = [];
177
250
  if (!capped) {
178
- const base = { template: input.template, locale: input.locale ?? null, variables: input.variables ?? {}, notificationId: item.id, accountId: input.accountId, category: input.category };
251
+ const base = {
252
+ template: input.template,
253
+ locale: input.locale ?? null,
254
+ variables: input.variables ?? {},
255
+ notificationId: item.id,
256
+ accountId: input.accountId,
257
+ category: input.category
258
+ };
179
259
  const send = async (request) => {
180
- await deps.outbox.append(tx, { topic: NOTIFICATION_TOPIC[request.channel], type: NOTIFICATION_EVENT.requested, payload: request, id: uuidOf(`${item.id}:${request.channel}:${request.recipient}`) });
260
+ await deps.outbox.append(tx, {
261
+ topic: NOTIFICATION_TOPIC[request.channel],
262
+ type: NOTIFICATION_EVENT.requested,
263
+ payload: request,
264
+ id: uuidOf(`${item.id}:${request.channel}:${request.recipient}`)
265
+ });
181
266
  if (!used.includes(request.channel)) used.push(request.channel);
182
267
  };
183
268
  if (channels.app) await send({ ...base, channel: DELIVERY.live, recipient: input.accountId });
184
269
  if (channels.email && input.email) await send({ ...base, channel: DELIVERY.email, recipient: input.email });
185
- if (channels.push) for (const device of await store.devices(input.accountId)) await send({ ...base, channel: DELIVERY.push, recipient: device.token, platform: device.platform });
270
+ if (channels.push)
271
+ for (const device of await store.devices(input.accountId))
272
+ await send({ ...base, channel: DELIVERY.push, recipient: device.token, platform: device.platform });
186
273
  }
187
274
  return { notificationId: item.id, duplicate: false, capped, channels: used };
188
275
  },
189
- list: (query, accountId, options = {}) => storeFor(query).list(accountId, { unreadOnly: options.unreadOnly ?? false, limit: options.limit ?? 30, ...options.before ? { before: options.before } : {} }),
276
+ list: (query, accountId, options = {}) => storeFor(query).list(accountId, {
277
+ unreadOnly: options.unreadOnly ?? false,
278
+ limit: options.limit ?? 30,
279
+ ...options.before ? { before: options.before } : {}
280
+ }),
190
281
  unreadCount: (query, accountId) => storeFor(query).unreadCount(accountId),
191
282
  markRead: (query, accountId, ids) => storeFor(query).markRead(accountId, ids ?? null, deps.clock.now()),
192
283
  async preferences(query, accountId) {
@@ -194,8 +285,10 @@ function createNotifications(deps) {
194
285
  },
195
286
  async setPreference(query, accountId, category, channel, enabled) {
196
287
  const def = categoryOf(await config(), category);
197
- if (def.locked) throw new NotificationError(NOTIFICATION_CODE.locked, `category \xAB ${category} \xBB cannot be switched off`);
198
- if (!Object.values(CHANNEL).includes(channel)) throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel \xAB ${channel} \xBB`);
288
+ if (def.locked)
289
+ throw new NotificationError(NOTIFICATION_CODE.locked, `category \xAB ${category} \xBB cannot be switched off`);
290
+ if (!Object.values(CHANNEL).includes(channel))
291
+ throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel \xAB ${channel} \xBB`);
199
292
  await storeFor(query).setPreference(accountId, category, channel, enabled, deps.clock.now());
200
293
  },
201
294
  async setMatrix(query, accountId, matrix) {
@@ -205,15 +298,28 @@ function createNotifications(deps) {
205
298
  for (const [category, row] of Object.entries(matrix)) {
206
299
  if (cfg.categories[category].locked) continue;
207
300
  for (const [channel, enabled] of Object.entries(row)) {
208
- if (!Object.values(CHANNEL).includes(channel)) throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel \xAB ${channel} \xBB`);
209
- if (typeof enabled === "boolean") await store.setPreference(accountId, category, channel, enabled, deps.clock.now());
301
+ if (!Object.values(CHANNEL).includes(channel))
302
+ throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel \xAB ${channel} \xBB`);
303
+ if (typeof enabled === "boolean")
304
+ await store.setPreference(accountId, category, channel, enabled, deps.clock.now());
210
305
  }
211
306
  }
212
307
  },
213
308
  async registerDevice(query, accountId, device) {
214
- if (!platforms.has(device.platform) || device.token.trim() === "" || device.token.length > 4096) throw new NotificationError(NOTIFICATION_CODE.badInput, "a device is a platform (ios, android, web) and a token");
309
+ if (!platforms.has(device.platform) || device.token.trim() === "" || device.token.length > 4096)
310
+ throw new NotificationError(
311
+ NOTIFICATION_CODE.badInput,
312
+ "a device is a platform (ios, android, web) and a token"
313
+ );
215
314
  const now = deps.clock.now();
216
- await storeFor(query).upsertDevice({ id: randomUUID(), accountId, platform: device.platform, token: device.token, createdAt: now, lastSeenAt: now });
315
+ await storeFor(query).upsertDevice({
316
+ id: randomUUID(),
317
+ accountId,
318
+ platform: device.platform,
319
+ token: device.token,
320
+ createdAt: now,
321
+ lastSeenAt: now
322
+ });
217
323
  },
218
324
  revokeDevice: (query, token) => storeFor(query).revokeDevice(token, deps.clock.now()),
219
325
  devices: (query, accountId) => storeFor(query).devices(accountId),
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/memory.ts","../src/notifications.ts","../src/persistence.ts","../src/schema.ts"],"sourcesContent":["import type { Channel, Device, Item, NotificationStore } from \"./port\";\n\nconst copy = <T extends object>(v: T): T => ({ ...v });\nconst newestFirst = (a: Item, b: Item) => b.createdAt.valueOf() - a.createdAt.valueOf() || b.id.localeCompare(a.id);\n\n/** An in-memory store, for unit tests and demos: the behaviour of the database's (the contract kit runs on both). `record` is one synchronous step, so it is atomic. */\nexport function memoryNotificationStore(): NotificationStore {\n const items = new Map<string, Item>();\n const prefs = new Map<string, { category: string; channel: Channel; enabled: boolean }>();\n const devices = new Map<string, Device & { revokedAt: Date | null }>();\n return {\n async record(item, burst) {\n const mine = [...items.values()].filter((i) => i.accountId === item.accountId);\n if (item.dedupeKey !== null && mine.some((i) => i.dedupeKey === item.dedupeKey)) return { duplicate: true, capped: false };\n const recent = mine.filter((i) => i.category === item.category && i.createdAt >= burst.since).length;\n items.set(item.id, copy(item));\n return { duplicate: false, capped: recent >= burst.max };\n },\n async preferences(accountId) {\n return [...prefs.entries()].filter(([key]) => key.startsWith(`${accountId}|`)).map(([, row]) => copy(row));\n },\n async setPreference(accountId, category, channel, enabled) {\n prefs.set(`${accountId}|${category}|${channel}`, { category, channel, enabled });\n },\n async list(accountId, { unreadOnly, limit, before }) {\n return [...items.values()]\n .filter((i) => i.accountId === accountId && i.inBell && (!unreadOnly || i.readAt === null))\n .sort(newestFirst)\n .filter((i) => !before || i.createdAt < before.createdAt || (i.createdAt.valueOf() === before.createdAt.valueOf() && i.id < before.id))\n .slice(0, limit)\n .map(copy);\n },\n async unreadCount(accountId) {\n return [...items.values()].filter((i) => i.accountId === accountId && i.inBell && i.readAt === null).length;\n },\n async markRead(accountId, ids, now) {\n let n = 0;\n for (const item of items.values()) {\n if (item.accountId !== accountId || !item.inBell || item.readAt !== null || (ids !== null && !ids.includes(item.id))) continue;\n item.readAt = now;\n n += 1;\n }\n return n;\n },\n async upsertDevice(device) {\n const known = devices.get(device.token);\n devices.set(device.token, { ...device, id: known?.id ?? device.id, createdAt: known?.createdAt ?? device.createdAt, revokedAt: null });\n },\n async devices(accountId) {\n return [...devices.values()].filter((d) => d.accountId === accountId && d.revokedAt === null).map(({ revokedAt: _revoked, ...d }) => d);\n },\n async revokeDevice(token, now) {\n const d = devices.get(token);\n if (!d || d.revokedAt !== null) return false;\n d.revokedAt = now;\n return true;\n },\n async purgeBefore(before) {\n let n = 0;\n for (const [id, item] of items) {\n if (item.createdAt >= before) continue;\n items.delete(id);\n n += 1;\n }\n return n;\n },\n async erase(accountId) {\n let n = 0;\n for (const [id, item] of items) if (item.accountId === accountId && items.delete(id)) n += 1;\n for (const key of [...prefs.keys()]) if (key.startsWith(`${accountId}|`) && prefs.delete(key)) n += 1;\n for (const [token, d] of devices) if (d.accountId === accountId && devices.delete(token)) n += 1;\n return n;\n },\n };\n}\n","import { randomUUID } from \"node:crypto\";\n\nimport { subHours, subSeconds } from \"@krizaka/time\";\n\nimport { dbNotificationStore } from \"./persistence\";\nimport { CHANNEL, type Channel, type Clock, DELIVERY, type Delivery, type Device, type Item, NOTIFICATION_CODE, type NotificationConfig, NotificationError, type NotificationStore, PLATFORM, type Platform, type Query } from \"./port\";\nimport { categoryOf, checkConfig, checkInput, type NotifyInput, type PreferenceView, resolveChannels, uuidOf, viewOf } from \"./rules\";\n\n/** The event the outbox carries, the name Java's `krizaka-notifications-api` gives its request. */\nexport const NOTIFICATION_EVENT = { requested: \"evt.notification.requested.v1\" } as const;\n\n/** One topic per delivery, so a worker claims only what it delivers (`notifications.email`, `notifications.push`, `notifications.live`). */\nexport const NOTIFICATION_TOPIC: Record<Delivery, string> = { [DELIVERY.email]: \"notifications.email\", [DELIVERY.push]: \"notifications.push\", [DELIVERY.live]: \"notifications.live\" };\n\n/** What the module needs of `@krizaka/outbox` (its `Outbox` satisfies it): append in the caller's transaction. */\nexport interface NotificationOutbox {\n append(tx: Query, message: { topic: string; type: string; payload: unknown; id: string }): Promise<{ id: string; appended: boolean }>;\n}\n\nexport interface NotificationDeps {\n clock: Clock;\n outbox: NotificationOutbox;\n /** The categories and the retention, read at each call so the admin's change applies at once. */\n config: NotificationConfig | (() => NotificationConfig | Promise<NotificationConfig>);\n /** The store over a query (a transaction or a pool). Default: the PostgreSQL store on schema `notifications`. */\n storeFor?: (query: Query) => NotificationStore;\n /** The Postgres schema, when the product renamed the module's (used by the default store). */\n schema?: string;\n}\n\n/** The request one delivery worker receives: Java's `NotificationRequest` (channel, recipient, template, locale, variables) plus the ids. */\nexport interface NotificationRequest {\n channel: Delivery;\n /** An e-mail address (EMAIL), a device token (PUSH) or an account id (LIVE). */\n recipient: string;\n template: string;\n locale: string | null;\n variables: Record<string, string>;\n notificationId: string;\n accountId: string;\n category: string;\n /** PUSH only. */\n platform?: Platform;\n}\n\nexport interface Notified {\n /** `null` for a duplicate (same `dedupeKey`): nothing was written or appended. */\n notificationId: string | null;\n duplicate: boolean;\n /** Past the category's burst cap: the item is kept, the channels were not used. */\n capped: boolean;\n /** The channels that were used. */\n channels: Delivery[];\n}\n\nexport interface Notifications {\n /**\n * Writes the bell item and appends one outbox message per enabled channel, in `tx` (use the caller's transaction: it is what makes\n * the burst cap hold under concurrency, and the messages leave only if the work commits).\n */\n notify(tx: Query, input: NotifyInput): Promise<Notified>;\n /** The bell, newest first. */\n list(query: Query, accountId: string, options?: { unreadOnly?: boolean; limit?: number; before?: { createdAt: Date; id: string } }): Promise<Item[]>;\n unreadCount(query: Query, accountId: string): Promise<number>;\n /** Marks the account's own unread items read (those in `ids`, or all). Returns how many. */\n markRead(query: Query, accountId: string, ids?: readonly string[]): Promise<number>;\n /** The account's matrix: every category, every channel, and whether it is the default, the account's choice or locked. */\n preferences(query: Query, accountId: string): Promise<PreferenceView[]>;\n /** @throws NotificationError `NOTIFICATIONS_LOCKED` for a locked category, `NOTIFICATIONS_BAD_CATEGORY` for an unknown one. */\n setPreference(query: Query, accountId: string, category: string, channel: Channel, enabled: boolean): Promise<void>;\n /** The whole matrix at once (a preset applied): the locked categories are left as they are. */\n setMatrix(query: Query, accountId: string, matrix: Record<string, Partial<Record<Channel, boolean>>>): Promise<void>;\n registerDevice(query: Query, accountId: string, device: { platform: Platform; token: string }): Promise<void>;\n /** The push service refused the token: it is never used again until registered anew. */\n revokeDevice(query: Query, token: string): Promise<boolean>;\n devices(query: Query, accountId: string): Promise<Device[]>;\n /** Removes the items older than the retention. */\n purge(query: Query): Promise<number>;\n /** Erasure (Loi 25): everything held about the account. */\n erase(query: Query, accountId: string): Promise<number>;\n}\n\nconst platforms = new Set<string>(Object.values(PLATFORM));\n\n/**\n * The notifications of a product.\n * @param deps the clock, the outbox, the configuration, and optionally the store and schema\n * @returns the notification functions\n */\nexport function createNotifications(deps: NotificationDeps): Notifications {\n const storeFor = deps.storeFor ?? ((query: Query) => dbNotificationStore(query, deps.schema ?? \"notifications\"));\n const config = async () => checkConfig(typeof deps.config === \"function\" ? await deps.config() : deps.config);\n const asMap = (rows: { category: string; channel: Channel; enabled: boolean }[], category: string) => new Map(rows.filter((r) => r.category === category).map((r) => [r.channel, r.enabled] as const));\n return {\n async notify(tx, input) {\n checkInput(input);\n const cfg = await config();\n const category = categoryOf(cfg, input.category);\n const now = deps.clock.now();\n const store = storeFor(tx);\n const channels = resolveChannels(category, asMap(await store.preferences(input.accountId), input.category));\n const item: Item = { id: randomUUID(), accountId: input.accountId, category: input.category, template: input.template, payload: { variables: input.variables ?? {}, ...(input.payload ?? {}) }, dedupeKey: input.dedupeKey ?? null, inBell: channels.app, createdAt: now, readAt: null };\n const { duplicate, capped } = await store.record(item, { max: category.burstMax, since: subSeconds(now, category.burstWindowSeconds) });\n if (duplicate) return { notificationId: null, duplicate: true, capped: false, channels: [] };\n const used: Delivery[] = [];\n if (!capped) {\n const base = { template: input.template, locale: input.locale ?? null, variables: input.variables ?? {}, notificationId: item.id, accountId: input.accountId, category: input.category };\n const send = async (request: NotificationRequest) => {\n await deps.outbox.append(tx, { topic: NOTIFICATION_TOPIC[request.channel], type: NOTIFICATION_EVENT.requested, payload: request, id: uuidOf(`${item.id}:${request.channel}:${request.recipient}`) });\n if (!used.includes(request.channel)) used.push(request.channel);\n };\n if (channels.app) await send({ ...base, channel: DELIVERY.live, recipient: input.accountId });\n if (channels.email && input.email) await send({ ...base, channel: DELIVERY.email, recipient: input.email });\n if (channels.push) for (const device of await store.devices(input.accountId)) await send({ ...base, channel: DELIVERY.push, recipient: device.token, platform: device.platform });\n }\n return { notificationId: item.id, duplicate: false, capped, channels: used };\n },\n list: (query, accountId, options = {}) => storeFor(query).list(accountId, { unreadOnly: options.unreadOnly ?? false, limit: options.limit ?? 30, ...(options.before ? { before: options.before } : {}) }),\n unreadCount: (query, accountId) => storeFor(query).unreadCount(accountId),\n markRead: (query, accountId, ids) => storeFor(query).markRead(accountId, ids ?? null, deps.clock.now()),\n async preferences(query, accountId) {\n return viewOf(await config(), await storeFor(query).preferences(accountId));\n },\n async setPreference(query, accountId, category, channel, enabled) {\n const def = categoryOf(await config(), category);\n if (def.locked) throw new NotificationError(NOTIFICATION_CODE.locked, `category « ${category} » cannot be switched off`);\n if (!Object.values(CHANNEL).includes(channel)) throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel « ${channel} »`);\n await storeFor(query).setPreference(accountId, category, channel, enabled, deps.clock.now());\n },\n async setMatrix(query, accountId, matrix) {\n const cfg = await config();\n for (const category of Object.keys(matrix)) categoryOf(cfg, category);\n const store = storeFor(query);\n for (const [category, row] of Object.entries(matrix)) {\n if (cfg.categories[category]!.locked) continue;\n for (const [channel, enabled] of Object.entries(row)) {\n if (!Object.values(CHANNEL).includes(channel as Channel)) throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel « ${channel} »`);\n if (typeof enabled === \"boolean\") await store.setPreference(accountId, category, channel as Channel, enabled, deps.clock.now());\n }\n }\n },\n async registerDevice(query, accountId, device) {\n if (!platforms.has(device.platform) || device.token.trim() === \"\" || device.token.length > 4096) throw new NotificationError(NOTIFICATION_CODE.badInput, \"a device is a platform (ios, android, web) and a token\");\n const now = deps.clock.now();\n await storeFor(query).upsertDevice({ id: randomUUID(), accountId, platform: device.platform, token: device.token, createdAt: now, lastSeenAt: now });\n },\n revokeDevice: (query, token) => storeFor(query).revokeDevice(token, deps.clock.now()),\n devices: (query, accountId) => storeFor(query).devices(accountId),\n async purge(query) {\n const cfg = await config();\n return storeFor(query).purgeBefore(subHours(deps.clock.now(), cfg.retentionDays * 24));\n },\n erase: (query, accountId) => storeFor(query).erase(accountId),\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { Channel, Device, Item, NotificationStore, Platform } from \"./port\";\n\ninterface ItemRow {\n id: string;\n account_id: string;\n category: string;\n template: string;\n payload: Record<string, unknown>;\n dedupe_key: string | null;\n in_bell: boolean;\n created_at: Date;\n read_at: Date | null;\n}\ninterface DeviceRow {\n id: string;\n account_id: string;\n platform: Platform;\n token: string;\n created_at: Date;\n last_seen_at: Date;\n}\n\nconst toItem = (r: ItemRow): Item => ({ id: r.id, accountId: r.account_id, category: r.category, template: r.template, payload: r.payload, dedupeKey: r.dedupe_key, inBell: r.in_bell, createdAt: r.created_at, readAt: r.read_at });\nconst toDevice = (r: DeviceRow): Device => ({ id: r.id, accountId: r.account_id, platform: r.platform, token: r.token, createdAt: r.created_at, lastSeenAt: r.last_seen_at });\n\n/**\n * The PostgreSQL store of « notifications » over a `Query`, in schema `notifications` (or the one the product renamed it to).\n * `record` takes a transaction-level advisory lock per account and category, so the burst cap holds under concurrency ONLY inside the\n * caller's transaction: pass `tx`.\n */\nexport function dbNotificationStore(query: Query, schema = \"notifications\"): NotificationStore {\n const s = ident(schema);\n return {\n async record(item, burst) {\n await query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [`${s}:${item.accountId}:${item.category}`]);\n const [seen] = await query<{ n: number }>(`SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND category = $2 AND created_at >= $3`, [item.accountId, item.category, burst.since]);\n const inserted = await query(\n `INSERT INTO ${s}.item (id, account_id, category, template, payload, dedupe_key, in_bell, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)\n ON CONFLICT (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING RETURNING 1`,\n [item.id, item.accountId, item.category, item.template, JSON.stringify(item.payload), item.dedupeKey, item.inBell, item.createdAt],\n );\n return inserted.length === 0 ? { duplicate: true, capped: false } : { duplicate: false, capped: (seen?.n ?? 0) >= burst.max };\n },\n async preferences(accountId) {\n return query<{ category: string; channel: Channel; enabled: boolean }>(`SELECT category, channel, enabled FROM ${s}.preference WHERE account_id = $1`, [accountId]);\n },\n async setPreference(accountId, category, channel, enabled, now) {\n await query(`INSERT INTO ${s}.preference (account_id, category, channel, enabled, updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (account_id, category, channel) DO UPDATE SET enabled = $4, updated_at = $5`, [accountId, category, channel, enabled, now]);\n },\n async list(accountId, { unreadOnly, limit, before }) {\n const rows = await query<ItemRow>(\n `SELECT * FROM ${s}.item WHERE account_id = $1 AND in_bell AND ($2::boolean = false OR read_at IS NULL)\n AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid))\n ORDER BY created_at DESC, id DESC LIMIT $5`,\n [accountId, unreadOnly, before?.createdAt ?? null, before?.id ?? null, limit],\n );\n return rows.map(toItem);\n },\n async unreadCount(accountId) {\n const [row] = await query<{ n: number }>(`SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND in_bell AND read_at IS NULL`, [accountId]);\n return row?.n ?? 0;\n },\n async markRead(accountId, ids, now) {\n const rows = await query(`UPDATE ${s}.item SET read_at = $3 WHERE account_id = $1 AND in_bell AND read_at IS NULL AND ($2::uuid[] IS NULL OR id = ANY($2::uuid[])) RETURNING 1`, [accountId, ids ? [...ids] : null, now]);\n return rows.length;\n },\n async upsertDevice(d) {\n await query(\n `INSERT INTO ${s}.device (id, account_id, platform, token, created_at, last_seen_at) VALUES ($1,$2,$3,$4,$5,$6)\n ON CONFLICT (token) DO UPDATE SET account_id = $2, platform = $3, last_seen_at = $6, revoked_at = NULL`,\n [d.id, d.accountId, d.platform, d.token, d.createdAt, d.lastSeenAt],\n );\n },\n async devices(accountId) {\n return (await query<DeviceRow>(`SELECT id, account_id, platform, token, created_at, last_seen_at FROM ${s}.device WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at, id`, [accountId])).map(toDevice);\n },\n async revokeDevice(token, now) {\n return (await query(`UPDATE ${s}.device SET revoked_at = $2 WHERE token = $1 AND revoked_at IS NULL RETURNING 1`, [token, now])).length > 0;\n },\n async purgeBefore(before) {\n return (await query(`DELETE FROM ${s}.item WHERE created_at < $1 RETURNING 1`, [before])).length;\n },\n async erase(accountId) {\n const items = await query(`DELETE FROM ${s}.item WHERE account_id = $1 RETURNING 1`, [accountId]);\n const prefs = await query(`DELETE FROM ${s}.preference WHERE account_id = $1 RETURNING 1`, [accountId]);\n const devs = await query(`DELETE FROM ${s}.device WHERE account_id = $1 RETURNING 1`, [accountId]);\n return items.length + prefs.length + devs.length;\n },\n };\n}\n","import { defineModule } from \"@krizaka/db\";\n\n/**\n * The tables of « notifications », in their own Postgres schema: `item` (the bell, and the history the burst caps count), `preference`\n * (the account's explicit choices: account by category by channel) and `device` (the push tokens). The one product table referenced is\n * the owner (`owner`, the account table): a deleted account takes its rows with it. Version 1 is frozen once released: a change is version 2.\n */\nexport const notificationsSchema = defineModule({\n module: \"notifications\",\n tables: [\"owner\"],\n migrations: [\n {\n version: 1,\n name: \"items, preferences and devices\",\n sql: (c) => `\nCREATE TABLE ${c.schema}.item (\n id uuid PRIMARY KEY,\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n category text NOT NULL CHECK (category ~ '^[a-z][a-zA-Z0-9]{0,39}$'),\n template text NOT NULL CHECK (template ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),\n payload jsonb NOT NULL,\n dedupe_key text CHECK (dedupe_key IS NULL OR char_length(dedupe_key) BETWEEN 1 AND 200),\n in_bell boolean NOT NULL,\n created_at timestamptz NOT NULL,\n read_at timestamptz\n);\nCREATE UNIQUE INDEX item_dedupe ON ${c.schema}.item (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL;\nCREATE INDEX item_bell ON ${c.schema}.item (account_id, created_at DESC, id DESC) WHERE in_bell;\nCREATE INDEX item_burst ON ${c.schema}.item (account_id, category, created_at);\nCREATE INDEX item_created ON ${c.schema}.item (created_at);\nCREATE TABLE ${c.schema}.preference (\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n category text NOT NULL CHECK (category ~ '^[a-z][a-zA-Z0-9]{0,39}$'),\n channel text NOT NULL CHECK (channel IN ('app', 'push', 'email')),\n enabled boolean NOT NULL,\n updated_at timestamptz NOT NULL,\n PRIMARY KEY (account_id, category, channel)\n);\nCREATE TABLE ${c.schema}.device (\n id uuid PRIMARY KEY,\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n platform text NOT NULL CHECK (platform IN ('ios', 'android', 'web')),\n token text NOT NULL UNIQUE CHECK (char_length(token) BETWEEN 1 AND 4096),\n created_at timestamptz NOT NULL,\n last_seen_at timestamptz NOT NULL,\n revoked_at timestamptz\n);\nCREATE INDEX device_account ON ${c.schema}.device (account_id) WHERE revoked_at IS NULL;`,\n },\n ],\n});\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/memory.ts","../src/notifications.ts","../src/persistence.ts","../src/schema.ts"],"sourcesContent":["import type { Channel, Device, Item, NotificationStore } from \"./port\";\n\nconst copy = <T extends object>(v: T): T => ({ ...v });\nconst newestFirst = (a: Item, b: Item) => b.createdAt.valueOf() - a.createdAt.valueOf() || b.id.localeCompare(a.id);\n\n/** An in-memory store, for unit tests and demos: the behaviour of the database's (the contract kit runs on both). `record` is one synchronous step, so it is atomic. */\nexport function memoryNotificationStore(): NotificationStore {\n const items = new Map<string, Item>();\n const prefs = new Map<string, { category: string; channel: Channel; enabled: boolean }>();\n const devices = new Map<string, Device & { revokedAt: Date | null }>();\n return {\n async record(item, burst) {\n const mine = [...items.values()].filter((i) => i.accountId === item.accountId);\n if (item.dedupeKey !== null && mine.some((i) => i.dedupeKey === item.dedupeKey))\n return { duplicate: true, capped: false };\n const recent = mine.filter((i) => i.category === item.category && i.createdAt >= burst.since).length;\n items.set(item.id, copy(item));\n return { duplicate: false, capped: recent >= burst.max };\n },\n async preferences(accountId) {\n return [...prefs.entries()].filter(([key]) => key.startsWith(`${accountId}|`)).map(([, row]) => copy(row));\n },\n async setPreference(accountId, category, channel, enabled) {\n prefs.set(`${accountId}|${category}|${channel}`, { category, channel, enabled });\n },\n async list(accountId, { unreadOnly, limit, before }) {\n return [...items.values()]\n .filter((i) => i.accountId === accountId && i.inBell && (!unreadOnly || i.readAt === null))\n .sort(newestFirst)\n .filter(\n (i) =>\n !before ||\n i.createdAt < before.createdAt ||\n (i.createdAt.valueOf() === before.createdAt.valueOf() && i.id < before.id),\n )\n .slice(0, limit)\n .map(copy);\n },\n async unreadCount(accountId) {\n return [...items.values()].filter((i) => i.accountId === accountId && i.inBell && i.readAt === null).length;\n },\n async markRead(accountId, ids, now) {\n let n = 0;\n for (const item of items.values()) {\n if (\n item.accountId !== accountId ||\n !item.inBell ||\n item.readAt !== null ||\n (ids !== null && !ids.includes(item.id))\n )\n continue;\n item.readAt = now;\n n += 1;\n }\n return n;\n },\n async upsertDevice(device) {\n const known = devices.get(device.token);\n devices.set(device.token, {\n ...device,\n id: known?.id ?? device.id,\n createdAt: known?.createdAt ?? device.createdAt,\n revokedAt: null,\n });\n },\n async devices(accountId) {\n return [...devices.values()]\n .filter((d) => d.accountId === accountId && d.revokedAt === null)\n .map(({ revokedAt: _revoked, ...d }) => d);\n },\n async revokeDevice(token, now) {\n const d = devices.get(token);\n if (!d || d.revokedAt !== null) return false;\n d.revokedAt = now;\n return true;\n },\n async purgeBefore(before) {\n let n = 0;\n for (const [id, item] of items) {\n if (item.createdAt >= before) continue;\n items.delete(id);\n n += 1;\n }\n return n;\n },\n async erase(accountId) {\n let n = 0;\n for (const [id, item] of items) if (item.accountId === accountId && items.delete(id)) n += 1;\n for (const key of [...prefs.keys()]) if (key.startsWith(`${accountId}|`) && prefs.delete(key)) n += 1;\n for (const [token, d] of devices) if (d.accountId === accountId && devices.delete(token)) n += 1;\n return n;\n },\n };\n}\n","import { randomUUID } from \"node:crypto\";\n\nimport { subHours, subSeconds } from \"@krizaka/time\";\n\nimport { dbNotificationStore } from \"./persistence\";\nimport {\n CHANNEL,\n type Channel,\n type Clock,\n DELIVERY,\n type Delivery,\n type Device,\n type Item,\n NOTIFICATION_CODE,\n type NotificationConfig,\n NotificationError,\n type NotificationStore,\n PLATFORM,\n type Platform,\n type Query,\n} from \"./port\";\nimport {\n categoryOf,\n checkConfig,\n checkInput,\n type NotifyInput,\n type PreferenceView,\n resolveChannels,\n uuidOf,\n viewOf,\n} from \"./rules\";\n\n/** The event the outbox carries, the name Java's `krizaka-notifications-api` gives its request. */\nexport const NOTIFICATION_EVENT = { requested: \"evt.notification.requested.v1\" } as const;\n\n/** One topic per delivery, so a worker claims only what it delivers (`notifications.email`, `notifications.push`, `notifications.live`). */\nexport const NOTIFICATION_TOPIC: Record<Delivery, string> = {\n [DELIVERY.email]: \"notifications.email\",\n [DELIVERY.push]: \"notifications.push\",\n [DELIVERY.live]: \"notifications.live\",\n};\n\n/** What the module needs of `@krizaka/outbox` (its `Outbox` satisfies it): append in the caller's transaction. */\nexport interface NotificationOutbox {\n append(\n tx: Query,\n message: { topic: string; type: string; payload: unknown; id: string },\n ): Promise<{ id: string; appended: boolean }>;\n}\n\nexport interface NotificationDeps {\n clock: Clock;\n outbox: NotificationOutbox;\n /** The categories and the retention, read at each call so the admin's change applies at once. */\n config: NotificationConfig | (() => NotificationConfig | Promise<NotificationConfig>);\n /** The store over a query (a transaction or a pool). Default: the PostgreSQL store on schema `notifications`. */\n storeFor?: (query: Query) => NotificationStore;\n /** The Postgres schema, when the product renamed the module's (used by the default store). */\n schema?: string;\n}\n\n/** The request one delivery worker receives: Java's `NotificationRequest` (channel, recipient, template, locale, variables) plus the ids. */\nexport interface NotificationRequest {\n channel: Delivery;\n /** An e-mail address (EMAIL), a device token (PUSH) or an account id (LIVE). */\n recipient: string;\n template: string;\n locale: string | null;\n variables: Record<string, string>;\n notificationId: string;\n accountId: string;\n category: string;\n /** PUSH only. */\n platform?: Platform;\n}\n\nexport interface Notified {\n /** `null` for a duplicate (same `dedupeKey`): nothing was written or appended. */\n notificationId: string | null;\n duplicate: boolean;\n /** Past the category's burst cap: the item is kept, the channels were not used. */\n capped: boolean;\n /** The channels that were used. */\n channels: Delivery[];\n}\n\nexport interface Notifications {\n /**\n * Writes the bell item and appends one outbox message per enabled channel, in `tx` (use the caller's transaction: it is what makes\n * the burst cap hold under concurrency, and the messages leave only if the work commits).\n */\n notify(tx: Query, input: NotifyInput): Promise<Notified>;\n /** The bell, newest first. */\n list(\n query: Query,\n accountId: string,\n options?: { unreadOnly?: boolean; limit?: number; before?: { createdAt: Date; id: string } },\n ): Promise<Item[]>;\n unreadCount(query: Query, accountId: string): Promise<number>;\n /** Marks the account's own unread items read (those in `ids`, or all). Returns how many. */\n markRead(query: Query, accountId: string, ids?: readonly string[]): Promise<number>;\n /** The account's matrix: every category, every channel, and whether it is the default, the account's choice or locked. */\n preferences(query: Query, accountId: string): Promise<PreferenceView[]>;\n /** @throws NotificationError `NOTIFICATIONS_LOCKED` for a locked category, `NOTIFICATIONS_BAD_CATEGORY` for an unknown one. */\n setPreference(query: Query, accountId: string, category: string, channel: Channel, enabled: boolean): Promise<void>;\n /** The whole matrix at once (a preset applied): the locked categories are left as they are. */\n setMatrix(query: Query, accountId: string, matrix: Record<string, Partial<Record<Channel, boolean>>>): Promise<void>;\n registerDevice(query: Query, accountId: string, device: { platform: Platform; token: string }): Promise<void>;\n /** The push service refused the token: it is never used again until registered anew. */\n revokeDevice(query: Query, token: string): Promise<boolean>;\n devices(query: Query, accountId: string): Promise<Device[]>;\n /** Removes the items older than the retention. */\n purge(query: Query): Promise<number>;\n /** Erasure (Loi 25): everything held about the account. */\n erase(query: Query, accountId: string): Promise<number>;\n}\n\nconst platforms = new Set<string>(Object.values(PLATFORM));\n\n/**\n * The notifications of a product.\n * @param deps the clock, the outbox, the configuration, and optionally the store and schema\n * @returns the notification functions\n */\nexport function createNotifications(deps: NotificationDeps): Notifications {\n const storeFor = deps.storeFor ?? ((query: Query) => dbNotificationStore(query, deps.schema ?? \"notifications\"));\n const config = async () => checkConfig(typeof deps.config === \"function\" ? await deps.config() : deps.config);\n const asMap = (rows: { category: string; channel: Channel; enabled: boolean }[], category: string) =>\n new Map(rows.filter((r) => r.category === category).map((r) => [r.channel, r.enabled] as const));\n return {\n async notify(tx, input) {\n checkInput(input);\n const cfg = await config();\n const category = categoryOf(cfg, input.category);\n const now = deps.clock.now();\n const store = storeFor(tx);\n const channels = resolveChannels(category, asMap(await store.preferences(input.accountId), input.category));\n const item: Item = {\n id: randomUUID(),\n accountId: input.accountId,\n category: input.category,\n template: input.template,\n payload: { variables: input.variables ?? {}, ...(input.payload ?? {}) },\n dedupeKey: input.dedupeKey ?? null,\n inBell: channels.app,\n createdAt: now,\n readAt: null,\n };\n const { duplicate, capped } = await store.record(item, {\n max: category.burstMax,\n since: subSeconds(now, category.burstWindowSeconds),\n });\n if (duplicate) return { notificationId: null, duplicate: true, capped: false, channels: [] };\n const used: Delivery[] = [];\n if (!capped) {\n const base = {\n template: input.template,\n locale: input.locale ?? null,\n variables: input.variables ?? {},\n notificationId: item.id,\n accountId: input.accountId,\n category: input.category,\n };\n const send = async (request: NotificationRequest) => {\n await deps.outbox.append(tx, {\n topic: NOTIFICATION_TOPIC[request.channel],\n type: NOTIFICATION_EVENT.requested,\n payload: request,\n id: uuidOf(`${item.id}:${request.channel}:${request.recipient}`),\n });\n if (!used.includes(request.channel)) used.push(request.channel);\n };\n if (channels.app) await send({ ...base, channel: DELIVERY.live, recipient: input.accountId });\n if (channels.email && input.email) await send({ ...base, channel: DELIVERY.email, recipient: input.email });\n if (channels.push)\n for (const device of await store.devices(input.accountId))\n await send({ ...base, channel: DELIVERY.push, recipient: device.token, platform: device.platform });\n }\n return { notificationId: item.id, duplicate: false, capped, channels: used };\n },\n list: (query, accountId, options = {}) =>\n storeFor(query).list(accountId, {\n unreadOnly: options.unreadOnly ?? false,\n limit: options.limit ?? 30,\n ...(options.before ? { before: options.before } : {}),\n }),\n unreadCount: (query, accountId) => storeFor(query).unreadCount(accountId),\n markRead: (query, accountId, ids) => storeFor(query).markRead(accountId, ids ?? null, deps.clock.now()),\n async preferences(query, accountId) {\n return viewOf(await config(), await storeFor(query).preferences(accountId));\n },\n async setPreference(query, accountId, category, channel, enabled) {\n const def = categoryOf(await config(), category);\n if (def.locked)\n throw new NotificationError(NOTIFICATION_CODE.locked, `category « ${category} » cannot be switched off`);\n if (!Object.values(CHANNEL).includes(channel))\n throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel « ${channel} »`);\n await storeFor(query).setPreference(accountId, category, channel, enabled, deps.clock.now());\n },\n async setMatrix(query, accountId, matrix) {\n const cfg = await config();\n for (const category of Object.keys(matrix)) categoryOf(cfg, category);\n const store = storeFor(query);\n for (const [category, row] of Object.entries(matrix)) {\n if (cfg.categories[category]!.locked) continue;\n for (const [channel, enabled] of Object.entries(row)) {\n if (!Object.values(CHANNEL).includes(channel as Channel))\n throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel « ${channel} »`);\n if (typeof enabled === \"boolean\")\n await store.setPreference(accountId, category, channel as Channel, enabled, deps.clock.now());\n }\n }\n },\n async registerDevice(query, accountId, device) {\n if (!platforms.has(device.platform) || device.token.trim() === \"\" || device.token.length > 4096)\n throw new NotificationError(\n NOTIFICATION_CODE.badInput,\n \"a device is a platform (ios, android, web) and a token\",\n );\n const now = deps.clock.now();\n await storeFor(query).upsertDevice({\n id: randomUUID(),\n accountId,\n platform: device.platform,\n token: device.token,\n createdAt: now,\n lastSeenAt: now,\n });\n },\n revokeDevice: (query, token) => storeFor(query).revokeDevice(token, deps.clock.now()),\n devices: (query, accountId) => storeFor(query).devices(accountId),\n async purge(query) {\n const cfg = await config();\n return storeFor(query).purgeBefore(subHours(deps.clock.now(), cfg.retentionDays * 24));\n },\n erase: (query, accountId) => storeFor(query).erase(accountId),\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { Channel, Device, Item, NotificationStore, Platform } from \"./port\";\n\ninterface ItemRow {\n id: string;\n account_id: string;\n category: string;\n template: string;\n payload: Record<string, unknown>;\n dedupe_key: string | null;\n in_bell: boolean;\n created_at: Date;\n read_at: Date | null;\n}\ninterface DeviceRow {\n id: string;\n account_id: string;\n platform: Platform;\n token: string;\n created_at: Date;\n last_seen_at: Date;\n}\n\nconst toItem = (r: ItemRow): Item => ({\n id: r.id,\n accountId: r.account_id,\n category: r.category,\n template: r.template,\n payload: r.payload,\n dedupeKey: r.dedupe_key,\n inBell: r.in_bell,\n createdAt: r.created_at,\n readAt: r.read_at,\n});\nconst toDevice = (r: DeviceRow): Device => ({\n id: r.id,\n accountId: r.account_id,\n platform: r.platform,\n token: r.token,\n createdAt: r.created_at,\n lastSeenAt: r.last_seen_at,\n});\n\n/**\n * The PostgreSQL store of « notifications » over a `Query`, in schema `notifications` (or the one the product renamed it to).\n * `record` takes a transaction-level advisory lock per account and category, so the burst cap holds under concurrency ONLY inside the\n * caller's transaction: pass `tx`.\n */\nexport function dbNotificationStore(query: Query, schema = \"notifications\"): NotificationStore {\n const s = ident(schema);\n return {\n async record(item, burst) {\n await query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [`${s}:${item.accountId}:${item.category}`]);\n const [seen] = await query<{ n: number }>(\n `SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND category = $2 AND created_at >= $3`,\n [item.accountId, item.category, burst.since],\n );\n const inserted = await query(\n `INSERT INTO ${s}.item (id, account_id, category, template, payload, dedupe_key, in_bell, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)\n ON CONFLICT (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING RETURNING 1`,\n [\n item.id,\n item.accountId,\n item.category,\n item.template,\n JSON.stringify(item.payload),\n item.dedupeKey,\n item.inBell,\n item.createdAt,\n ],\n );\n return inserted.length === 0\n ? { duplicate: true, capped: false }\n : { duplicate: false, capped: (seen?.n ?? 0) >= burst.max };\n },\n async preferences(accountId) {\n return query<{ category: string; channel: Channel; enabled: boolean }>(\n `SELECT category, channel, enabled FROM ${s}.preference WHERE account_id = $1`,\n [accountId],\n );\n },\n async setPreference(accountId, category, channel, enabled, now) {\n await query(\n `INSERT INTO ${s}.preference (account_id, category, channel, enabled, updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (account_id, category, channel) DO UPDATE SET enabled = $4, updated_at = $5`,\n [accountId, category, channel, enabled, now],\n );\n },\n async list(accountId, { unreadOnly, limit, before }) {\n const rows = await query<ItemRow>(\n `SELECT * FROM ${s}.item WHERE account_id = $1 AND in_bell AND ($2::boolean = false OR read_at IS NULL)\n AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid))\n ORDER BY created_at DESC, id DESC LIMIT $5`,\n [accountId, unreadOnly, before?.createdAt ?? null, before?.id ?? null, limit],\n );\n return rows.map(toItem);\n },\n async unreadCount(accountId) {\n const [row] = await query<{ n: number }>(\n `SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND in_bell AND read_at IS NULL`,\n [accountId],\n );\n return row?.n ?? 0;\n },\n async markRead(accountId, ids, now) {\n const rows = await query(\n `UPDATE ${s}.item SET read_at = $3 WHERE account_id = $1 AND in_bell AND read_at IS NULL AND ($2::uuid[] IS NULL OR id = ANY($2::uuid[])) RETURNING 1`,\n [accountId, ids ? [...ids] : null, now],\n );\n return rows.length;\n },\n async upsertDevice(d) {\n await query(\n `INSERT INTO ${s}.device (id, account_id, platform, token, created_at, last_seen_at) VALUES ($1,$2,$3,$4,$5,$6)\n ON CONFLICT (token) DO UPDATE SET account_id = $2, platform = $3, last_seen_at = $6, revoked_at = NULL`,\n [d.id, d.accountId, d.platform, d.token, d.createdAt, d.lastSeenAt],\n );\n },\n async devices(accountId) {\n return (\n await query<DeviceRow>(\n `SELECT id, account_id, platform, token, created_at, last_seen_at FROM ${s}.device WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at, id`,\n [accountId],\n )\n ).map(toDevice);\n },\n async revokeDevice(token, now) {\n return (\n (\n await query(`UPDATE ${s}.device SET revoked_at = $2 WHERE token = $1 AND revoked_at IS NULL RETURNING 1`, [\n token,\n now,\n ])\n ).length > 0\n );\n },\n async purgeBefore(before) {\n return (await query(`DELETE FROM ${s}.item WHERE created_at < $1 RETURNING 1`, [before])).length;\n },\n async erase(accountId) {\n const items = await query(`DELETE FROM ${s}.item WHERE account_id = $1 RETURNING 1`, [accountId]);\n const prefs = await query(`DELETE FROM ${s}.preference WHERE account_id = $1 RETURNING 1`, [accountId]);\n const devs = await query(`DELETE FROM ${s}.device WHERE account_id = $1 RETURNING 1`, [accountId]);\n return items.length + prefs.length + devs.length;\n },\n };\n}\n","import { defineModule } from \"@krizaka/db\";\n\n/**\n * The tables of « notifications », in their own Postgres schema: `item` (the bell, and the history the burst caps count), `preference`\n * (the account's explicit choices: account by category by channel) and `device` (the push tokens). The one product table referenced is\n * the owner (`owner`, the account table): a deleted account takes its rows with it. Version 1 is frozen once released: a change is version 2.\n */\nexport const notificationsSchema = defineModule({\n module: \"notifications\",\n tables: [\"owner\"],\n migrations: [\n {\n version: 1,\n name: \"items, preferences and devices\",\n sql: (c) => `\nCREATE TABLE ${c.schema}.item (\n id uuid PRIMARY KEY,\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n category text NOT NULL CHECK (category ~ '^[a-z][a-zA-Z0-9]{0,39}$'),\n template text NOT NULL CHECK (template ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),\n payload jsonb NOT NULL,\n dedupe_key text CHECK (dedupe_key IS NULL OR char_length(dedupe_key) BETWEEN 1 AND 200),\n in_bell boolean NOT NULL,\n created_at timestamptz NOT NULL,\n read_at timestamptz\n);\nCREATE UNIQUE INDEX item_dedupe ON ${c.schema}.item (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL;\nCREATE INDEX item_bell ON ${c.schema}.item (account_id, created_at DESC, id DESC) WHERE in_bell;\nCREATE INDEX item_burst ON ${c.schema}.item (account_id, category, created_at);\nCREATE INDEX item_created ON ${c.schema}.item (created_at);\nCREATE TABLE ${c.schema}.preference (\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n category text NOT NULL CHECK (category ~ '^[a-z][a-zA-Z0-9]{0,39}$'),\n channel text NOT NULL CHECK (channel IN ('app', 'push', 'email')),\n enabled boolean NOT NULL,\n updated_at timestamptz NOT NULL,\n PRIMARY KEY (account_id, category, channel)\n);\nCREATE TABLE ${c.schema}.device (\n id uuid PRIMARY KEY,\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n platform text NOT NULL CHECK (platform IN ('ios', 'android', 'web')),\n token text NOT NULL UNIQUE CHECK (char_length(token) BETWEEN 1 AND 4096),\n created_at timestamptz NOT NULL,\n last_seen_at timestamptz NOT NULL,\n revoked_at timestamptz\n);\nCREATE INDEX device_account ON ${c.schema}.device (account_id) WHERE revoked_at IS NULL;`,\n },\n ],\n});\n"],"mappings":"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","names":[]}
package/dist/settings.js CHANGED
@@ -2,27 +2,41 @@ import {
2
2
  CATEGORY_NAME,
3
3
  NOTIFICATION_BOUNDS,
4
4
  NOTIFICATION_DEFAULTS
5
- } from "./chunk-AQI4ZDWW.js";
5
+ } from "./chunk-WSVZKGIC.js";
6
6
  import {
7
7
  CHANNELS,
8
8
  NOTIFICATION_CODE,
9
9
  NotificationError
10
- } from "./chunk-RTHXH2BK.js";
10
+ } from "./chunk-6RZLSKEN.js";
11
11
 
12
12
  // src/settings.ts
13
13
  import { flag, integer } from "@krizaka/settings";
14
14
  var burstOf = (d) => d.burst ?? { max: NOTIFICATION_DEFAULTS.burstMax, windowSeconds: NOTIFICATION_DEFAULTS.burstWindowSeconds };
15
15
  function notificationSettings(declared) {
16
16
  const registry = {
17
- "notifications.retentionDays": integer("notifications", NOTIFICATION_DEFAULTS.retentionDays, { min: NOTIFICATION_BOUNDS.retentionDays[0], max: NOTIFICATION_BOUNDS.retentionDays[1], unit: "days" })
17
+ "notifications.retentionDays": integer("notifications", NOTIFICATION_DEFAULTS.retentionDays, {
18
+ min: NOTIFICATION_BOUNDS.retentionDays[0],
19
+ max: NOTIFICATION_BOUNDS.retentionDays[1],
20
+ unit: "days"
21
+ })
18
22
  };
19
23
  for (const [name, d] of Object.entries(declared)) {
20
- if (!CATEGORY_NAME.test(name)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `category \xAB ${name} \xBB must be a camel-case word`);
24
+ if (!CATEGORY_NAME.test(name))
25
+ throw new NotificationError(NOTIFICATION_CODE.badConfig, `category \xAB ${name} \xBB must be a camel-case word`);
21
26
  const group = `notifications.${name}`;
22
- if (!d.locked) for (const channel of CHANNELS) registry[`notifications.${name}.${channel}`] = flag(group, d.defaults[channel]);
27
+ if (!d.locked)
28
+ for (const channel of CHANNELS) registry[`notifications.${name}.${channel}`] = flag(group, d.defaults[channel]);
23
29
  const burst = burstOf(d);
24
- registry[`notifications.${name}.burstMax`] = integer(group, burst.max, { min: NOTIFICATION_BOUNDS.burstMax[0], max: NOTIFICATION_BOUNDS.burstMax[1], unit: "notifications" });
25
- registry[`notifications.${name}.burstWindowSeconds`] = integer(group, burst.windowSeconds, { min: NOTIFICATION_BOUNDS.burstWindowSeconds[0], max: NOTIFICATION_BOUNDS.burstWindowSeconds[1], unit: "seconds" });
30
+ registry[`notifications.${name}.burstMax`] = integer(group, burst.max, {
31
+ min: NOTIFICATION_BOUNDS.burstMax[0],
32
+ max: NOTIFICATION_BOUNDS.burstMax[1],
33
+ unit: "notifications"
34
+ });
35
+ registry[`notifications.${name}.burstWindowSeconds`] = integer(group, burst.windowSeconds, {
36
+ min: NOTIFICATION_BOUNDS.burstWindowSeconds[0],
37
+ max: NOTIFICATION_BOUNDS.burstWindowSeconds[1],
38
+ unit: "seconds"
39
+ });
26
40
  }
27
41
  return registry;
28
42
  }
@@ -41,9 +55,17 @@ async function readNotificationConfig(declared, read) {
41
55
  }
42
56
  }
43
57
  const burst = burstOf(d);
44
- categories[name] = { locked: d.locked ?? false, defaults, burstMax: await number(`notifications.${name}.burstMax`, burst.max), burstWindowSeconds: await number(`notifications.${name}.burstWindowSeconds`, burst.windowSeconds) };
58
+ categories[name] = {
59
+ locked: d.locked ?? false,
60
+ defaults,
61
+ burstMax: await number(`notifications.${name}.burstMax`, burst.max),
62
+ burstWindowSeconds: await number(`notifications.${name}.burstWindowSeconds`, burst.windowSeconds)
63
+ };
45
64
  }
46
- return { categories, retentionDays: await number("notifications.retentionDays", NOTIFICATION_DEFAULTS.retentionDays) };
65
+ return {
66
+ categories,
67
+ retentionDays: await number("notifications.retentionDays", NOTIFICATION_DEFAULTS.retentionDays)
68
+ };
47
69
  }
48
70
  export {
49
71
  notificationSettings,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/settings.ts"],"sourcesContent":["import { flag, integer, type Registry } from \"@krizaka/settings\";\n\nimport { CHANNELS, type NotificationConfig } from \"./port\";\nimport { type Channel, NOTIFICATION_CODE, NotificationError } from \"./port\";\nimport { CATEGORY_NAME, NOTIFICATION_BOUNDS, NOTIFICATION_DEFAULTS } from \"./rules\";\n\n/** What a product declares for a category; the admin tunes the values (except `locked`, which is code). */\nexport interface CategoryDeclaration {\n /** Cannot be switched off by the account or the admin (security, the account). Its channels are exactly `defaults`. */\n locked?: boolean;\n defaults: Record<Channel, boolean>;\n burst?: { max: number; windowSeconds: number };\n}\n\nexport type Declared = Record<string, CategoryDeclaration>;\n\nconst burstOf = (d: CategoryDeclaration) => d.burst ?? { max: NOTIFICATION_DEFAULTS.burstMax, windowSeconds: NOTIFICATION_DEFAULTS.burstWindowSeconds };\n\n/**\n * The settings of « notifications » for the categories a product declares, a fragment it adds to its registry\n * (`@krizaka/notifications/settings`): for each category the channels on by default (`notifications.<category>.<channel>`, not for a\n * locked category) and its burst cap (`notifications.<category>.burstMax`, `burstWindowSeconds`), plus `notifications.retentionDays`.\n * The admin changes them without a redeploy.\n * @throws NotificationError when a category name is not a camel-case word\n */\nexport function notificationSettings(declared: Declared): Registry {\n const registry: Registry = {\n \"notifications.retentionDays\": integer(\"notifications\", NOTIFICATION_DEFAULTS.retentionDays, { min: NOTIFICATION_BOUNDS.retentionDays[0], max: NOTIFICATION_BOUNDS.retentionDays[1], unit: \"days\" }),\n };\n for (const [name, d] of Object.entries(declared)) {\n if (!CATEGORY_NAME.test(name)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `category « ${name} » must be a camel-case word`);\n const group = `notifications.${name}`;\n if (!d.locked) for (const channel of CHANNELS) registry[`notifications.${name}.${channel}`] = flag(group, d.defaults[channel]);\n const burst = burstOf(d);\n registry[`notifications.${name}.burstMax`] = integer(group, burst.max, { min: NOTIFICATION_BOUNDS.burstMax[0], max: NOTIFICATION_BOUNDS.burstMax[1], unit: \"notifications\" });\n registry[`notifications.${name}.burstWindowSeconds`] = integer(group, burst.windowSeconds, { min: NOTIFICATION_BOUNDS.burstWindowSeconds[0], max: NOTIFICATION_BOUNDS.burstWindowSeconds[1], unit: \"seconds\" });\n }\n return registry;\n}\n\ntype Reader = (key: string) => boolean | number | undefined | Promise<boolean | number | undefined>;\n\n/**\n * The module's configuration as `createNotifications` takes it, from the product's accessor (`(key) => service.get(key, ctx)`).\n * A key the accessor does not know falls back to what the product declared.\n */\nexport async function readNotificationConfig(declared: Declared, read: Reader): Promise<NotificationConfig> {\n const number = async (key: string, fallback: number) => {\n const value = await read(key);\n return typeof value === \"number\" ? value : fallback;\n };\n const categories: NotificationConfig[\"categories\"] = {};\n for (const [name, d] of Object.entries(declared)) {\n const defaults = { ...d.defaults };\n if (!d.locked) {\n for (const channel of CHANNELS) {\n const value = await read(`notifications.${name}.${channel}`);\n if (typeof value === \"boolean\") defaults[channel] = value;\n }\n }\n const burst = burstOf(d);\n categories[name] = { locked: d.locked ?? false, defaults, burstMax: await number(`notifications.${name}.burstMax`, burst.max), burstWindowSeconds: await number(`notifications.${name}.burstWindowSeconds`, burst.windowSeconds) };\n }\n return { categories, retentionDays: await number(\"notifications.retentionDays\", NOTIFICATION_DEFAULTS.retentionDays) };\n}\n"],"mappings":";;;;;;;;;;;;AAAA,SAAS,MAAM,eAA8B;AAgB7C,IAAM,UAAU,CAAC,MAA2B,EAAE,SAAS,EAAE,KAAK,sBAAsB,UAAU,eAAe,sBAAsB,mBAAmB;AAS/I,SAAS,qBAAqB,UAA8B;AACjE,QAAM,WAAqB;AAAA,IACzB,+BAA+B,QAAQ,iBAAiB,sBAAsB,eAAe,EAAE,KAAK,oBAAoB,cAAc,CAAC,GAAG,KAAK,oBAAoB,cAAc,CAAC,GAAG,MAAM,OAAO,CAAC;AAAA,EACrM;AACA,aAAW,CAAC,MAAM,CAAC,KAAK,OAAO,QAAQ,QAAQ,GAAG;AAChD,QAAI,CAAC,cAAc,KAAK,IAAI,EAAG,OAAM,IAAI,kBAAkB,kBAAkB,WAAW,iBAAc,IAAI,iCAA8B;AACxI,UAAM,QAAQ,iBAAiB,IAAI;AACnC,QAAI,CAAC,EAAE,OAAQ,YAAW,WAAW,SAAU,UAAS,iBAAiB,IAAI,IAAI,OAAO,EAAE,IAAI,KAAK,OAAO,EAAE,SAAS,OAAO,CAAC;AAC7H,UAAM,QAAQ,QAAQ,CAAC;AACvB,aAAS,iBAAiB,IAAI,WAAW,IAAI,QAAQ,OAAO,MAAM,KAAK,EAAE,KAAK,oBAAoB,SAAS,CAAC,GAAG,KAAK,oBAAoB,SAAS,CAAC,GAAG,MAAM,gBAAgB,CAAC;AAC5K,aAAS,iBAAiB,IAAI,qBAAqB,IAAI,QAAQ,OAAO,MAAM,eAAe,EAAE,KAAK,oBAAoB,mBAAmB,CAAC,GAAG,KAAK,oBAAoB,mBAAmB,CAAC,GAAG,MAAM,UAAU,CAAC;AAAA,EAChN;AACA,SAAO;AACT;AAQA,eAAsB,uBAAuB,UAAoB,MAA2C;AAC1G,QAAM,SAAS,OAAO,KAAa,aAAqB;AACtD,UAAM,QAAQ,MAAM,KAAK,GAAG;AAC5B,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AACA,QAAM,aAA+C,CAAC;AACtD,aAAW,CAAC,MAAM,CAAC,KAAK,OAAO,QAAQ,QAAQ,GAAG;AAChD,UAAM,WAAW,EAAE,GAAG,EAAE,SAAS;AACjC,QAAI,CAAC,EAAE,QAAQ;AACb,iBAAW,WAAW,UAAU;AAC9B,cAAM,QAAQ,MAAM,KAAK,iBAAiB,IAAI,IAAI,OAAO,EAAE;AAC3D,YAAI,OAAO,UAAU,UAAW,UAAS,OAAO,IAAI;AAAA,MACtD;AAAA,IACF;AACA,UAAM,QAAQ,QAAQ,CAAC;AACvB,eAAW,IAAI,IAAI,EAAE,QAAQ,EAAE,UAAU,OAAO,UAAU,UAAU,MAAM,OAAO,iBAAiB,IAAI,aAAa,MAAM,GAAG,GAAG,oBAAoB,MAAM,OAAO,iBAAiB,IAAI,uBAAuB,MAAM,aAAa,EAAE;AAAA,EACnO;AACA,SAAO,EAAE,YAAY,eAAe,MAAM,OAAO,+BAA+B,sBAAsB,aAAa,EAAE;AACvH;","names":[]}
1
+ {"version":3,"sources":["../src/settings.ts"],"sourcesContent":["import { flag, integer, type Registry } from \"@krizaka/settings\";\n\nimport { CHANNELS, type NotificationConfig } from \"./port\";\nimport { type Channel, NOTIFICATION_CODE, NotificationError } from \"./port\";\nimport { CATEGORY_NAME, NOTIFICATION_BOUNDS, NOTIFICATION_DEFAULTS } from \"./rules\";\n\n/** What a product declares for a category; the admin tunes the values (except `locked`, which is code). */\nexport interface CategoryDeclaration {\n /** Cannot be switched off by the account or the admin (security, the account). Its channels are exactly `defaults`. */\n locked?: boolean;\n defaults: Record<Channel, boolean>;\n burst?: { max: number; windowSeconds: number };\n}\n\nexport type Declared = Record<string, CategoryDeclaration>;\n\nconst burstOf = (d: CategoryDeclaration) =>\n d.burst ?? { max: NOTIFICATION_DEFAULTS.burstMax, windowSeconds: NOTIFICATION_DEFAULTS.burstWindowSeconds };\n\n/**\n * The settings of « notifications » for the categories a product declares, a fragment it adds to its registry\n * (`@krizaka/notifications/settings`): for each category the channels on by default (`notifications.<category>.<channel>`, not for a\n * locked category) and its burst cap (`notifications.<category>.burstMax`, `burstWindowSeconds`), plus `notifications.retentionDays`.\n * The admin changes them without a redeploy.\n * @throws NotificationError when a category name is not a camel-case word\n */\nexport function notificationSettings(declared: Declared): Registry {\n const registry: Registry = {\n \"notifications.retentionDays\": integer(\"notifications\", NOTIFICATION_DEFAULTS.retentionDays, {\n min: NOTIFICATION_BOUNDS.retentionDays[0],\n max: NOTIFICATION_BOUNDS.retentionDays[1],\n unit: \"days\",\n }),\n };\n for (const [name, d] of Object.entries(declared)) {\n if (!CATEGORY_NAME.test(name))\n throw new NotificationError(NOTIFICATION_CODE.badConfig, `category « ${name} » must be a camel-case word`);\n const group = `notifications.${name}`;\n if (!d.locked)\n for (const channel of CHANNELS) registry[`notifications.${name}.${channel}`] = flag(group, d.defaults[channel]);\n const burst = burstOf(d);\n registry[`notifications.${name}.burstMax`] = integer(group, burst.max, {\n min: NOTIFICATION_BOUNDS.burstMax[0],\n max: NOTIFICATION_BOUNDS.burstMax[1],\n unit: \"notifications\",\n });\n registry[`notifications.${name}.burstWindowSeconds`] = integer(group, burst.windowSeconds, {\n min: NOTIFICATION_BOUNDS.burstWindowSeconds[0],\n max: NOTIFICATION_BOUNDS.burstWindowSeconds[1],\n unit: \"seconds\",\n });\n }\n return registry;\n}\n\ntype Reader = (key: string) => boolean | number | undefined | Promise<boolean | number | undefined>;\n\n/**\n * The module's configuration as `createNotifications` takes it, from the product's accessor (`(key) => service.get(key, ctx)`).\n * A key the accessor does not know falls back to what the product declared.\n */\nexport async function readNotificationConfig(declared: Declared, read: Reader): Promise<NotificationConfig> {\n const number = async (key: string, fallback: number) => {\n const value = await read(key);\n return typeof value === \"number\" ? value : fallback;\n };\n const categories: NotificationConfig[\"categories\"] = {};\n for (const [name, d] of Object.entries(declared)) {\n const defaults = { ...d.defaults };\n if (!d.locked) {\n for (const channel of CHANNELS) {\n const value = await read(`notifications.${name}.${channel}`);\n if (typeof value === \"boolean\") defaults[channel] = value;\n }\n }\n const burst = burstOf(d);\n categories[name] = {\n locked: d.locked ?? false,\n defaults,\n burstMax: await number(`notifications.${name}.burstMax`, burst.max),\n burstWindowSeconds: await number(`notifications.${name}.burstWindowSeconds`, burst.windowSeconds),\n };\n }\n return {\n categories,\n retentionDays: await number(\"notifications.retentionDays\", NOTIFICATION_DEFAULTS.retentionDays),\n };\n}\n"],"mappings":";;;;;;;;;;;;AAAA,SAAS,MAAM,eAA8B;AAgB7C,IAAM,UAAU,CAAC,MACf,EAAE,SAAS,EAAE,KAAK,sBAAsB,UAAU,eAAe,sBAAsB,mBAAmB;AASrG,SAAS,qBAAqB,UAA8B;AACjE,QAAM,WAAqB;AAAA,IACzB,+BAA+B,QAAQ,iBAAiB,sBAAsB,eAAe;AAAA,MAC3F,KAAK,oBAAoB,cAAc,CAAC;AAAA,MACxC,KAAK,oBAAoB,cAAc,CAAC;AAAA,MACxC,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AACA,aAAW,CAAC,MAAM,CAAC,KAAK,OAAO,QAAQ,QAAQ,GAAG;AAChD,QAAI,CAAC,cAAc,KAAK,IAAI;AAC1B,YAAM,IAAI,kBAAkB,kBAAkB,WAAW,iBAAc,IAAI,iCAA8B;AAC3G,UAAM,QAAQ,iBAAiB,IAAI;AACnC,QAAI,CAAC,EAAE;AACL,iBAAW,WAAW,SAAU,UAAS,iBAAiB,IAAI,IAAI,OAAO,EAAE,IAAI,KAAK,OAAO,EAAE,SAAS,OAAO,CAAC;AAChH,UAAM,QAAQ,QAAQ,CAAC;AACvB,aAAS,iBAAiB,IAAI,WAAW,IAAI,QAAQ,OAAO,MAAM,KAAK;AAAA,MACrE,KAAK,oBAAoB,SAAS,CAAC;AAAA,MACnC,KAAK,oBAAoB,SAAS,CAAC;AAAA,MACnC,MAAM;AAAA,IACR,CAAC;AACD,aAAS,iBAAiB,IAAI,qBAAqB,IAAI,QAAQ,OAAO,MAAM,eAAe;AAAA,MACzF,KAAK,oBAAoB,mBAAmB,CAAC;AAAA,MAC7C,KAAK,oBAAoB,mBAAmB,CAAC;AAAA,MAC7C,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AACA,SAAO;AACT;AAQA,eAAsB,uBAAuB,UAAoB,MAA2C;AAC1G,QAAM,SAAS,OAAO,KAAa,aAAqB;AACtD,UAAM,QAAQ,MAAM,KAAK,GAAG;AAC5B,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AACA,QAAM,aAA+C,CAAC;AACtD,aAAW,CAAC,MAAM,CAAC,KAAK,OAAO,QAAQ,QAAQ,GAAG;AAChD,UAAM,WAAW,EAAE,GAAG,EAAE,SAAS;AACjC,QAAI,CAAC,EAAE,QAAQ;AACb,iBAAW,WAAW,UAAU;AAC9B,cAAM,QAAQ,MAAM,KAAK,iBAAiB,IAAI,IAAI,OAAO,EAAE;AAC3D,YAAI,OAAO,UAAU,UAAW,UAAS,OAAO,IAAI;AAAA,MACtD;AAAA,IACF;AACA,UAAM,QAAQ,QAAQ,CAAC;AACvB,eAAW,IAAI,IAAI;AAAA,MACjB,QAAQ,EAAE,UAAU;AAAA,MACpB;AAAA,MACA,UAAU,MAAM,OAAO,iBAAiB,IAAI,aAAa,MAAM,GAAG;AAAA,MAClE,oBAAoB,MAAM,OAAO,iBAAiB,IAAI,uBAAuB,MAAM,aAAa;AAAA,IAClG;AAAA,EACF;AACA,SAAO;AAAA,IACL;AAAA,IACA,eAAe,MAAM,OAAO,+BAA+B,sBAAsB,aAAa;AAAA,EAChG;AACF;","names":[]}
package/dist/testkit.js CHANGED
@@ -1,13 +1,24 @@
1
1
  import {
2
2
  CHANNEL
3
- } from "./chunk-RTHXH2BK.js";
3
+ } from "./chunk-6RZLSKEN.js";
4
4
 
5
5
  // src/testkit.ts
6
6
  import { randomUUID } from "crypto";
7
7
  import { expect, it } from "vitest";
8
8
  var T0 = /* @__PURE__ */ new Date("2026-10-26T12:00:00.000Z");
9
9
  var at = (seconds) => new Date(T0.valueOf() + seconds * 1e3);
10
- var itemOf = (accountId, over = {}) => ({ id: randomUUID(), accountId, category: "messages", template: "new-message", payload: { variables: {} }, dedupeKey: null, inBell: true, createdAt: T0, readAt: null, ...over });
10
+ var itemOf = (accountId, over = {}) => ({
11
+ id: randomUUID(),
12
+ accountId,
13
+ category: "messages",
14
+ template: "new-message",
15
+ payload: { variables: {} },
16
+ dedupeKey: null,
17
+ inBell: true,
18
+ createdAt: T0,
19
+ readAt: null,
20
+ ...over
21
+ });
11
22
  var BURST = { max: 3, since: at(-3600) };
12
23
  function notificationStoreContract(name, make, accountId) {
13
24
  it(`${name}: records an item, and says it is capped once the window already holds the cap`, async () => {
@@ -41,7 +52,11 @@ function notificationStoreContract(name, make, accountId) {
41
52
  await store.record(itemOf(a, { createdAt: at(9), inBell: false }), { max: 100, since: at(-3600) });
42
53
  const first = await store.list(a, { unreadOnly: false, limit: 2 });
43
54
  expect(first.map((i) => i.id)).toEqual([ids[4], ids[3]]);
44
- const next = await store.list(a, { unreadOnly: false, limit: 10, before: { createdAt: first[1].createdAt, id: first[1].id } });
55
+ const next = await store.list(a, {
56
+ unreadOnly: false,
57
+ limit: 10,
58
+ before: { createdAt: first[1].createdAt, id: first[1].id }
59
+ });
45
60
  expect(next.map((i) => i.id)).toEqual([ids[2], ids[1], ids[0]]);
46
61
  expect(await store.unreadCount(a)).toBe(5);
47
62
  });
@@ -55,7 +70,9 @@ function notificationStoreContract(name, make, accountId) {
55
70
  for (const i of [mine, theirs, other]) await store.record(i, BURST);
56
71
  expect(await store.markRead(a, [mine.id, theirs.id], at(10))).toBe(1);
57
72
  expect(await store.markRead(a, [mine.id], at(20))).toBe(0);
58
- expect((await store.list(a, { unreadOnly: false, limit: 10 })).find((i) => i.id === mine.id)?.readAt?.toISOString()).toBe(at(10).toISOString());
73
+ expect(
74
+ (await store.list(a, { unreadOnly: false, limit: 10 })).find((i) => i.id === mine.id)?.readAt?.toISOString()
75
+ ).toBe(at(10).toISOString());
59
76
  expect(await store.unreadCount(b)).toBe(1);
60
77
  expect((await store.list(a, { unreadOnly: true, limit: 10 })).map((i) => i.id)).toEqual([other.id]);
61
78
  expect(await store.markRead(a, null, at(30))).toBe(1);
@@ -78,7 +95,14 @@ function notificationStoreContract(name, make, accountId) {
78
95
  const store = await make();
79
96
  const a = await accountId();
80
97
  const b = await accountId();
81
- const device = (account, token, seconds) => ({ id: randomUUID(), accountId: account, platform: "ios", token, createdAt: at(seconds), lastSeenAt: at(seconds) });
98
+ const device = (account, token, seconds) => ({
99
+ id: randomUUID(),
100
+ accountId: account,
101
+ platform: "ios",
102
+ token,
103
+ createdAt: at(seconds),
104
+ lastSeenAt: at(seconds)
105
+ });
82
106
  await store.upsertDevice(device(a, "tok-1", 0));
83
107
  await store.upsertDevice(device(a, "tok-1", 5));
84
108
  await store.upsertDevice(device(a, "tok-2", 6));
@@ -100,7 +124,14 @@ function notificationStoreContract(name, make, accountId) {
100
124
  expect(await store.purgeBefore(at(-86400))).toBe(1);
101
125
  await store.record(itemOf(b), BURST);
102
126
  await store.setPreference(a, "messages", CHANNEL.app, false, T0);
103
- await store.upsertDevice({ id: randomUUID(), accountId: a, platform: "web", token: "tok-erase", createdAt: T0, lastSeenAt: T0 });
127
+ await store.upsertDevice({
128
+ id: randomUUID(),
129
+ accountId: a,
130
+ platform: "web",
131
+ token: "tok-erase",
132
+ createdAt: T0,
133
+ lastSeenAt: T0
134
+ });
104
135
  expect(await store.erase(a)).toBe(3);
105
136
  expect(await store.unreadCount(a)).toBe(0);
106
137
  expect(await store.preferences(a)).toEqual([]);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/testkit.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\n\nimport { expect, it } from \"vitest\";\n\nimport { CHANNEL, type Item, type NotificationStore } from \"./port\";\n\nexport const T0 = new Date(\"2026-10-26T12:00:00.000Z\");\nexport const at = (seconds: number) => new Date(T0.valueOf() + seconds * 1000);\n\n/** An item for tests; the account ids are UUIDs because the database's are. */\nexport const itemOf = (accountId: string, over: Partial<Item> = {}): Item => ({ id: randomUUID(), accountId, category: \"messages\", template: \"new-message\", payload: { variables: {} }, dedupeKey: null, inBell: true, createdAt: T0, readAt: null, ...over });\nconst BURST = { max: 3, since: at(-3600) };\n\n/** What every `NotificationStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store whose accounts exist. */\nexport function notificationStoreContract(name: string, make: () => NotificationStore | Promise<NotificationStore>, accountId: () => string | Promise<string>) {\n it(`${name}: records an item, and says it is capped once the window already holds the cap`, async () => {\n const store = await make();\n const a = await accountId();\n const results = [];\n for (let i = 0; i < 5; i += 1) results.push(await store.record(itemOf(a, { createdAt: at(i) }), BURST));\n expect(results.map((r) => r.capped)).toEqual([false, false, false, true, true]);\n expect(results.every((r) => !r.duplicate)).toBe(true);\n // another category and another window are counted apart\n expect((await store.record(itemOf(a, { category: \"follows\" }), BURST)).capped).toBe(false);\n expect((await store.record(itemOf(a), { max: 3, since: at(100) })).capped).toBe(false);\n });\n\n it(`${name}: writes nothing for a second item with the same dedupe key, and lets another account use it`, async () => {\n const store = await make();\n const a = await accountId();\n const b = await accountId();\n expect(await store.record(itemOf(a, { dedupeKey: \"bid:1\" }), BURST)).toEqual({ duplicate: false, capped: false });\n expect(await store.record(itemOf(a, { dedupeKey: \"bid:1\" }), BURST)).toEqual({ duplicate: true, capped: false });\n expect(await store.record(itemOf(b, { dedupeKey: \"bid:1\" }), BURST)).toEqual({ duplicate: false, capped: false });\n expect(await store.list(a, { unreadOnly: false, limit: 10 })).toHaveLength(1);\n });\n\n it(`${name}: lists the bell newest first with a cursor, hides what was not shown, and counts the unread`, async () => {\n const store = await make();\n const a = await accountId();\n const ids: string[] = [];\n for (let i = 0; i < 5; i += 1) {\n const item = itemOf(a, { createdAt: at(i) });\n ids.push(item.id);\n await store.record(item, { max: 100, since: at(-3600) });\n }\n await store.record(itemOf(a, { createdAt: at(9), inBell: false }), { max: 100, since: at(-3600) });\n const first = await store.list(a, { unreadOnly: false, limit: 2 });\n expect(first.map((i) => i.id)).toEqual([ids[4], ids[3]]);\n const next = await store.list(a, { unreadOnly: false, limit: 10, before: { createdAt: first[1]!.createdAt, id: first[1]!.id } });\n expect(next.map((i) => i.id)).toEqual([ids[2], ids[1], ids[0]]);\n expect(await store.unreadCount(a)).toBe(5);\n });\n\n it(`${name}: marks read only the account's own unread items, keeps the first read_at`, async () => {\n const store = await make();\n const a = await accountId();\n const b = await accountId();\n const mine = itemOf(a);\n const theirs = itemOf(b);\n const other = itemOf(a, { createdAt: at(1) });\n for (const i of [mine, theirs, other]) await store.record(i, BURST);\n expect(await store.markRead(a, [mine.id, theirs.id], at(10))).toBe(1);\n expect(await store.markRead(a, [mine.id], at(20))).toBe(0);\n expect((await store.list(a, { unreadOnly: false, limit: 10 })).find((i) => i.id === mine.id)?.readAt?.toISOString()).toBe(at(10).toISOString());\n expect(await store.unreadCount(b)).toBe(1);\n expect((await store.list(a, { unreadOnly: true, limit: 10 })).map((i) => i.id)).toEqual([other.id]);\n expect(await store.markRead(a, null, at(30))).toBe(1);\n expect(await store.unreadCount(a)).toBe(0);\n });\n\n it(`${name}: keeps an account's choices, one per category and channel, the last one winning`, async () => {\n const store = await make();\n const a = await accountId();\n await store.setPreference(a, \"messages\", CHANNEL.email, false, T0);\n await store.setPreference(a, \"messages\", CHANNEL.push, true, T0);\n await store.setPreference(a, \"messages\", CHANNEL.email, true, at(5));\n const rows = (await store.preferences(a)).sort((x, y) => x.channel.localeCompare(y.channel));\n expect(rows).toEqual([\n { category: \"messages\", channel: \"email\", enabled: true },\n { category: \"messages\", channel: \"push\", enabled: true },\n ]);\n expect(await store.preferences(await accountId())).toEqual([]);\n });\n\n it(`${name}: registers a device once per token, moves it to a new account, revokes it and brings it back`, async () => {\n const store = await make();\n const a = await accountId();\n const b = await accountId();\n const device = (account: string, token: string, seconds: number) => ({ id: randomUUID(), accountId: account, platform: \"ios\" as const, token, createdAt: at(seconds), lastSeenAt: at(seconds) });\n await store.upsertDevice(device(a, \"tok-1\", 0));\n await store.upsertDevice(device(a, \"tok-1\", 5));\n await store.upsertDevice(device(a, \"tok-2\", 6));\n expect((await store.devices(a)).map((d) => d.token)).toEqual([\"tok-1\", \"tok-2\"]);\n expect((await store.devices(a))[0]!.createdAt.toISOString()).toBe(at(0).toISOString());\n expect(await store.revokeDevice(\"tok-1\", at(10))).toBe(true);\n expect(await store.revokeDevice(\"tok-1\", at(11))).toBe(false);\n expect((await store.devices(a)).map((d) => d.token)).toEqual([\"tok-2\"]);\n await store.upsertDevice(device(b, \"tok-1\", 20));\n expect((await store.devices(b)).map((d) => d.token)).toEqual([\"tok-1\"]);\n expect((await store.devices(a)).map((d) => d.token)).toEqual([\"tok-2\"]);\n });\n\n it(`${name}: purges old items and erases everything about an account`, async () => {\n const store = await make();\n const a = await accountId();\n const b = await accountId();\n await store.record(itemOf(a, { createdAt: at(-86_400 * 10) }), { max: 100, since: at(-86_400 * 20) });\n await store.record(itemOf(a), { max: 100, since: at(-3600) });\n expect(await store.purgeBefore(at(-86_400))).toBe(1);\n await store.record(itemOf(b), BURST);\n await store.setPreference(a, \"messages\", CHANNEL.app, false, T0);\n await store.upsertDevice({ id: randomUUID(), accountId: a, platform: \"web\", token: \"tok-erase\", createdAt: T0, lastSeenAt: T0 });\n expect(await store.erase(a)).toBe(3);\n expect(await store.unreadCount(a)).toBe(0);\n expect(await store.preferences(a)).toEqual([]);\n expect(await store.devices(a)).toEqual([]);\n expect(await store.unreadCount(b)).toBe(1);\n });\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/testkit.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\n\nimport { expect, it } from \"vitest\";\n\nimport { CHANNEL, type Item, type NotificationStore } from \"./port\";\n\nexport const T0 = new Date(\"2026-10-26T12:00:00.000Z\");\nexport const at = (seconds: number) => new Date(T0.valueOf() + seconds * 1000);\n\n/** An item for tests; the account ids are UUIDs because the database's are. */\nexport const itemOf = (accountId: string, over: Partial<Item> = {}): Item => ({\n id: randomUUID(),\n accountId,\n category: \"messages\",\n template: \"new-message\",\n payload: { variables: {} },\n dedupeKey: null,\n inBell: true,\n createdAt: T0,\n readAt: null,\n ...over,\n});\nconst BURST = { max: 3, since: at(-3600) };\n\n/** What every `NotificationStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store whose accounts exist. */\nexport function notificationStoreContract(\n name: string,\n make: () => NotificationStore | Promise<NotificationStore>,\n accountId: () => string | Promise<string>,\n) {\n it(`${name}: records an item, and says it is capped once the window already holds the cap`, async () => {\n const store = await make();\n const a = await accountId();\n const results = [];\n for (let i = 0; i < 5; i += 1) results.push(await store.record(itemOf(a, { createdAt: at(i) }), BURST));\n expect(results.map((r) => r.capped)).toEqual([false, false, false, true, true]);\n expect(results.every((r) => !r.duplicate)).toBe(true);\n // another category and another window are counted apart\n expect((await store.record(itemOf(a, { category: \"follows\" }), BURST)).capped).toBe(false);\n expect((await store.record(itemOf(a), { max: 3, since: at(100) })).capped).toBe(false);\n });\n\n it(`${name}: writes nothing for a second item with the same dedupe key, and lets another account use it`, async () => {\n const store = await make();\n const a = await accountId();\n const b = await accountId();\n expect(await store.record(itemOf(a, { dedupeKey: \"bid:1\" }), BURST)).toEqual({ duplicate: false, capped: false });\n expect(await store.record(itemOf(a, { dedupeKey: \"bid:1\" }), BURST)).toEqual({ duplicate: true, capped: false });\n expect(await store.record(itemOf(b, { dedupeKey: \"bid:1\" }), BURST)).toEqual({ duplicate: false, capped: false });\n expect(await store.list(a, { unreadOnly: false, limit: 10 })).toHaveLength(1);\n });\n\n it(`${name}: lists the bell newest first with a cursor, hides what was not shown, and counts the unread`, async () => {\n const store = await make();\n const a = await accountId();\n const ids: string[] = [];\n for (let i = 0; i < 5; i += 1) {\n const item = itemOf(a, { createdAt: at(i) });\n ids.push(item.id);\n await store.record(item, { max: 100, since: at(-3600) });\n }\n await store.record(itemOf(a, { createdAt: at(9), inBell: false }), { max: 100, since: at(-3600) });\n const first = await store.list(a, { unreadOnly: false, limit: 2 });\n expect(first.map((i) => i.id)).toEqual([ids[4], ids[3]]);\n const next = await store.list(a, {\n unreadOnly: false,\n limit: 10,\n before: { createdAt: first[1]!.createdAt, id: first[1]!.id },\n });\n expect(next.map((i) => i.id)).toEqual([ids[2], ids[1], ids[0]]);\n expect(await store.unreadCount(a)).toBe(5);\n });\n\n it(`${name}: marks read only the account's own unread items, keeps the first read_at`, async () => {\n const store = await make();\n const a = await accountId();\n const b = await accountId();\n const mine = itemOf(a);\n const theirs = itemOf(b);\n const other = itemOf(a, { createdAt: at(1) });\n for (const i of [mine, theirs, other]) await store.record(i, BURST);\n expect(await store.markRead(a, [mine.id, theirs.id], at(10))).toBe(1);\n expect(await store.markRead(a, [mine.id], at(20))).toBe(0);\n expect(\n (await store.list(a, { unreadOnly: false, limit: 10 })).find((i) => i.id === mine.id)?.readAt?.toISOString(),\n ).toBe(at(10).toISOString());\n expect(await store.unreadCount(b)).toBe(1);\n expect((await store.list(a, { unreadOnly: true, limit: 10 })).map((i) => i.id)).toEqual([other.id]);\n expect(await store.markRead(a, null, at(30))).toBe(1);\n expect(await store.unreadCount(a)).toBe(0);\n });\n\n it(`${name}: keeps an account's choices, one per category and channel, the last one winning`, async () => {\n const store = await make();\n const a = await accountId();\n await store.setPreference(a, \"messages\", CHANNEL.email, false, T0);\n await store.setPreference(a, \"messages\", CHANNEL.push, true, T0);\n await store.setPreference(a, \"messages\", CHANNEL.email, true, at(5));\n const rows = (await store.preferences(a)).sort((x, y) => x.channel.localeCompare(y.channel));\n expect(rows).toEqual([\n { category: \"messages\", channel: \"email\", enabled: true },\n { category: \"messages\", channel: \"push\", enabled: true },\n ]);\n expect(await store.preferences(await accountId())).toEqual([]);\n });\n\n it(`${name}: registers a device once per token, moves it to a new account, revokes it and brings it back`, async () => {\n const store = await make();\n const a = await accountId();\n const b = await accountId();\n const device = (account: string, token: string, seconds: number) => ({\n id: randomUUID(),\n accountId: account,\n platform: \"ios\" as const,\n token,\n createdAt: at(seconds),\n lastSeenAt: at(seconds),\n });\n await store.upsertDevice(device(a, \"tok-1\", 0));\n await store.upsertDevice(device(a, \"tok-1\", 5));\n await store.upsertDevice(device(a, \"tok-2\", 6));\n expect((await store.devices(a)).map((d) => d.token)).toEqual([\"tok-1\", \"tok-2\"]);\n expect((await store.devices(a))[0]!.createdAt.toISOString()).toBe(at(0).toISOString());\n expect(await store.revokeDevice(\"tok-1\", at(10))).toBe(true);\n expect(await store.revokeDevice(\"tok-1\", at(11))).toBe(false);\n expect((await store.devices(a)).map((d) => d.token)).toEqual([\"tok-2\"]);\n await store.upsertDevice(device(b, \"tok-1\", 20));\n expect((await store.devices(b)).map((d) => d.token)).toEqual([\"tok-1\"]);\n expect((await store.devices(a)).map((d) => d.token)).toEqual([\"tok-2\"]);\n });\n\n it(`${name}: purges old items and erases everything about an account`, async () => {\n const store = await make();\n const a = await accountId();\n const b = await accountId();\n await store.record(itemOf(a, { createdAt: at(-86_400 * 10) }), { max: 100, since: at(-86_400 * 20) });\n await store.record(itemOf(a), { max: 100, since: at(-3600) });\n expect(await store.purgeBefore(at(-86_400))).toBe(1);\n await store.record(itemOf(b), BURST);\n await store.setPreference(a, \"messages\", CHANNEL.app, false, T0);\n await store.upsertDevice({\n id: randomUUID(),\n accountId: a,\n platform: \"web\",\n token: \"tok-erase\",\n createdAt: T0,\n lastSeenAt: T0,\n });\n expect(await store.erase(a)).toBe(3);\n expect(await store.unreadCount(a)).toBe(0);\n expect(await store.preferences(a)).toEqual([]);\n expect(await store.devices(a)).toEqual([]);\n expect(await store.unreadCount(b)).toBe(1);\n });\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/notifications",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "The bell, channels and preferences for every Krizaka product: one notify() writes the in-app item and appends one outbox message per enabled channel (e-mail, push per device, live), respecting the account's matrix of categories by channels and per-category burst caps that hold under concurrency. The product declares its categories; the admin tunes defaults and caps. No push or realtime import, no wall clock.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -57,11 +57,11 @@
57
57
  "typescript": "^5.9.3",
58
58
  "vitest": "^4.1.11",
59
59
  "zod": "^4.6.7",
60
- "@krizaka/config": "^0.17.0",
61
- "@krizaka/outbox": "^0.1.3"
60
+ "@krizaka/config": "^0.20.0",
61
+ "@krizaka/outbox": "^0.1.4"
62
62
  },
63
63
  "dependencies": {
64
- "@krizaka/settings": "^0.5.2",
64
+ "@krizaka/settings": "^0.6.0",
65
65
  "@krizaka/time": "^0.2.0",
66
66
  "@krizaka/db": "^0.3.1"
67
67
  },
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/rules.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\n\nimport { type CategoryDefaults, type Channel, CHANNELS, NOTIFICATION_CODE, type NotificationConfig, NotificationError } from \"./port\";\n\nexport const NOTIFICATION_BOUNDS = { burstMax: [1, 1000], burstWindowSeconds: [1, 604_800], retentionDays: [1, 3650] } as const;\nexport const NOTIFICATION_DEFAULTS = { burstMax: 20, burstWindowSeconds: 3600, retentionDays: 180 } as const;\n\n/** A category name: a camel-case word (it becomes part of a setting key). */\nexport const CATEGORY_NAME = /^[a-z][a-zA-Z0-9]{0,39}$/;\nconst TEMPLATE = /^[a-z0-9]+(-[a-z0-9]+)*$/;\nconst LOCALE = /^[a-z]{2}(-[A-Za-z]{2,4})?$/;\n// @fixed: a notification is a short event, not a document; a bigger payload belongs in the product's own tables\nconst MAX_PAYLOAD_BYTES = 8192;\nconst MAX_KEY = 200;\n\n/** A stable UUID (version 5 shaped) from a seed: the same fact appended twice is one outbox message. */\nexport function uuidOf(seed: string): string {\n const h = createHash(\"sha256\").update(`krizaka.notifications:${seed}`).digest();\n h[6] = (h[6]! & 0x0f) | 0x50;\n h[8] = (h[8]! & 0x3f) | 0x80;\n const x = h.subarray(0, 16).toString(\"hex\");\n return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;\n}\n\nconst within = (n: number, [min, max]: readonly [number, number]) => Number.isInteger(n) && n >= min && n <= max;\n\n/** @throws NotificationError when a category name, a burst cap or the retention is out of bounds. */\nexport function checkConfig(config: NotificationConfig): NotificationConfig {\n for (const [name, c] of Object.entries(config.categories)) {\n if (!CATEGORY_NAME.test(name)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `category « ${name} » must be a camel-case word`);\n if (!within(c.burstMax, NOTIFICATION_BOUNDS.burstMax)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstMax is out of bounds`);\n if (!within(c.burstWindowSeconds, NOTIFICATION_BOUNDS.burstWindowSeconds)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstWindowSeconds is out of bounds`);\n }\n if (!within(config.retentionDays, NOTIFICATION_BOUNDS.retentionDays)) throw new NotificationError(NOTIFICATION_CODE.badConfig, \"retentionDays is out of bounds\");\n return config;\n}\n\nexport function categoryOf(config: NotificationConfig, name: string): CategoryDefaults {\n const category = Object.hasOwn(config.categories, name) ? config.categories[name] : undefined;\n if (!category) throw new NotificationError(NOTIFICATION_CODE.badCategory, `unknown category « ${name} »`);\n return category;\n}\n\n/** The channels on for an account: the category's defaults, overridden by the account's choices, unless the category is locked. */\nexport function resolveChannels(category: CategoryDefaults, chosen: ReadonlyMap<Channel, boolean>): Record<Channel, boolean> {\n const out = { ...category.defaults };\n if (category.locked) return out;\n for (const channel of CHANNELS) {\n const choice = chosen.get(channel);\n if (choice !== undefined) out[channel] = choice;\n }\n return out;\n}\n\n/** What an account sees in its settings: every category, every channel, and why. */\nexport interface PreferenceView {\n category: string;\n locked: boolean;\n channels: Record<Channel, { enabled: boolean; source: \"default\" | \"account\" | \"locked\" }>;\n}\n\nexport function viewOf(config: NotificationConfig, rows: readonly { category: string; channel: Channel; enabled: boolean }[]): PreferenceView[] {\n return Object.entries(config.categories).map(([category, def]) => {\n const chosen = new Map(rows.filter((r) => r.category === category).map((r) => [r.channel, r.enabled] as const));\n const channels = Object.fromEntries(\n CHANNELS.map((channel) => {\n const source = def.locked ? \"locked\" : chosen.has(channel) ? \"account\" : \"default\";\n return [channel, { enabled: def.locked ? def.defaults[channel] : (chosen.get(channel) ?? def.defaults[channel]), source }];\n }),\n ) as PreferenceView[\"channels\"];\n return { category, locked: def.locked, channels };\n });\n}\n\nexport interface NotifyInput {\n accountId: string;\n category: string;\n /** The template the e-mail and the push are rendered from (`new-follower`): lower-case words joined by hyphens. */\n template: string;\n /** The words' variables: strings only, the shape of Java's `NotificationRequest`. Never a secret. */\n variables?: Record<string, string>;\n /** The recipient's language; only the language is used. */\n locale?: string;\n /** Where e-mail goes. Without it the e-mail channel is skipped (the product resolves the address, this module holds none). */\n email?: string;\n /** What the bell shows besides the template and variables (a link, an actor id): JSON, small. */\n payload?: Record<string, unknown>;\n /** One event notified twice is one notification: the same key for the same fact. */\n dedupeKey?: string;\n}\n\nconst NOT_IN_ADDRESS = /[\\s<>]/;\n\n/** One `@`, a local part, and a domain of dot-separated non-empty parts: no backtracking pattern over library input. */\nfunction isAddress(value: string): boolean {\n const at = value.indexOf(\"@\");\n if (at < 1 || at !== value.lastIndexOf(\"@\") || NOT_IN_ADDRESS.test(value)) return false;\n const labels = value.slice(at + 1).split(\".\");\n return labels.length >= 2 && labels.every((label) => label !== \"\");\n}\n\n/** @throws NotificationError on the first thing wrong with an input. */\nexport function checkInput(input: NotifyInput): void {\n const bad = (why: string) => new NotificationError(NOTIFICATION_CODE.badInput, why);\n if (typeof input.accountId !== \"string\" || input.accountId === \"\") throw bad(\"accountId is required\");\n if (!TEMPLATE.test(input.template)) throw bad(\"template is lower-case words joined by hyphens\");\n if (input.locale !== undefined && !LOCALE.test(input.locale)) throw bad(\"locale is a language tag like fr or en-CA\");\n if (input.dedupeKey !== undefined && (input.dedupeKey === \"\" || input.dedupeKey.length > MAX_KEY)) throw bad(\"dedupeKey is 1 to 200 characters\");\n if (input.email !== undefined && !isAddress(input.email)) throw bad(\"email is not an address\");\n for (const [key, value] of Object.entries(input.variables ?? {})) if (typeof value !== \"string\") throw bad(`variable « ${key} » must be a string`);\n if (Buffer.byteLength(JSON.stringify({ v: input.variables ?? {}, p: input.payload ?? {} })) > MAX_PAYLOAD_BYTES) throw bad(\"variables and payload are too big for a notification\");\n}\n"],"mappings":"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","names":[]}