notifkit 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +179 -152
  2. package/dist/index.d.mts +193 -129
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
  7. package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
  8. package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
  9. package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
  10. package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
  11. package/dist/main-BNJtzY61.mjs.map +1 -0
  12. package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
  13. package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
  14. package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
  15. package/dist/main-CiigNpsP.mjs.map +1 -0
  16. package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
  17. package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
  18. package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
  19. package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
  20. package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
  21. package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
  22. package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
  23. package/dist/src-vG79L-8m.mjs.map +1 -0
  24. package/drizzle/0002_wide_colleen_wing.sql +2 -0
  25. package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
  26. package/drizzle/0004_pretty_bruce_banner.sql +1 -0
  27. package/drizzle/meta/0002_snapshot.json +1460 -0
  28. package/drizzle/meta/0003_snapshot.json +1460 -0
  29. package/drizzle/meta/0004_snapshot.json +1470 -0
  30. package/drizzle/meta/_journal.json +21 -0
  31. package/package.json +7 -1
  32. package/scripts/create-project.mjs +61 -0
  33. package/src/client.ts +412 -0
  34. package/src/config/index.ts +107 -0
  35. package/src/contracts/common.ts +28 -0
  36. package/src/contracts/envelope.ts +31 -0
  37. package/src/contracts/events/notification-ai-pending.ts +18 -0
  38. package/src/contracts/events/notification-canceled.ts +7 -0
  39. package/src/contracts/events/notification-created.ts +14 -0
  40. package/src/contracts/events/notification-delivered.ts +17 -0
  41. package/src/contracts/events/notification-dispatched.ts +45 -0
  42. package/src/contracts/events/notification-enriched.ts +46 -0
  43. package/src/contracts/events/notification-failed.ts +19 -0
  44. package/src/contracts/events/notification-requested.ts +36 -0
  45. package/src/contracts/events/notification-scheduled.ts +9 -0
  46. package/src/contracts/events/notification-skipped.ts +9 -0
  47. package/src/contracts/helpers.ts +21 -0
  48. package/src/contracts/index.ts +46 -0
  49. package/src/contracts/metadata.ts +10 -0
  50. package/src/contracts/registry.ts +88 -0
  51. package/src/contracts/sdk.ts +242 -0
  52. package/src/contracts/streams.ts +62 -0
  53. package/src/db/index.ts +69 -0
  54. package/src/db/schema.ts +412 -0
  55. package/src/idempotency/index.ts +50 -0
  56. package/src/index.ts +19 -0
  57. package/src/logger/index.ts +60 -0
  58. package/src/metrics/index.ts +53 -0
  59. package/src/queue/index.ts +501 -0
  60. package/src/rate-limiter/index.ts +210 -0
  61. package/src/redis/index.ts +89 -0
  62. package/src/repositories/index.ts +1246 -0
  63. package/src/server.ts +277 -0
  64. package/src/services/ai/main.ts +404 -0
  65. package/src/services/api/handlers.ts +1734 -0
  66. package/src/services/api/http.ts +64 -0
  67. package/src/services/api/main.ts +693 -0
  68. package/src/services/api/router.ts +82 -0
  69. package/src/services/delivery/main.ts +842 -0
  70. package/src/services/delivery/throttle.ts +71 -0
  71. package/src/services/engine/main.ts +827 -0
  72. package/src/services/enricher/main.ts +594 -0
  73. package/src/services/events/main.ts +365 -0
  74. package/src/services/scheduler/main.ts +319 -0
  75. package/src/services/workflow/main.ts +627 -0
  76. package/src/shared/batch-processor.ts +67 -0
  77. package/src/shared/cache.ts +47 -0
  78. package/src/shared/circuit-breaker.ts +74 -0
  79. package/src/shared/dataloader.ts +41 -0
  80. package/src/shared/events.ts +3 -0
  81. package/src/shared/index.ts +39 -0
  82. package/src/shared/semaphore.ts +33 -0
  83. package/src/shared/utils.ts +64 -0
  84. package/src/templates/cache.ts +32 -0
  85. package/src/templates/index.ts +69 -0
  86. package/src/templates/render.ts +128 -0
  87. package/src/transport/index.ts +96 -0
  88. package/src/unsubscribe/index.ts +127 -0
  89. package/src/workers/health.ts +31 -0
  90. package/src/workers/index.ts +266 -0
  91. package/src/workflows/index.ts +2 -0
  92. package/src/workflows/registry.ts +21 -0
  93. package/src/workflows/sdk.ts +106 -0
  94. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  95. package/dist/main-CCfc45ev.mjs.map +0 -1
  96. package/dist/src-C-PfEDMY.mjs.map +0 -1
@@ -0,0 +1,242 @@
1
+ import { z } from "zod";
2
+ import { NotificationChannelSchema, NotificationPrioritySchema } from "./common.js";
3
+
4
+ // ─── Preferences ──────────────────────────────────────────────────────────────
5
+ //
6
+ // Preferences are stored as JSONB and are intentionally open-ended so new
7
+ // channels/topics can be added without a migration. `channels` and `topics`
8
+ // are boolean opt-in maps; `quietHours` is a list of UTC HH:MM windows.
9
+
10
+ export const QuietHoursSchema = z.object({
11
+ start: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:MM (24h, UTC)"),
12
+ end: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:MM (24h, UTC)"),
13
+ });
14
+ export type QuietHours = z.infer<typeof QuietHoursSchema>;
15
+
16
+ export const PreferencesSchema = z.object({
17
+ channels: z.record(z.string(), z.boolean()).optional(),
18
+ topics: z.record(z.string(), z.boolean()).optional(),
19
+ quietHours: z.array(QuietHoursSchema).optional(),
20
+ });
21
+ export type Preferences = z.infer<typeof PreferencesSchema>;
22
+
23
+ // ─── Contacts ─────────────────────────────────────────────────────────────────
24
+
25
+ /** Channels that carry an addressable target (email address, phone, push token, url, chat id). */
26
+ export const ContactChannelSchema = z.enum([
27
+ "email",
28
+ "sms",
29
+ "push",
30
+ "webhook",
31
+ "telegram",
32
+ "discord",
33
+ "whatsapp",
34
+ "slack",
35
+ ]);
36
+ export type ContactChannel = z.infer<typeof ContactChannelSchema>;
37
+
38
+ /** Accept a single value or an array; always normalise to a non-empty array. */
39
+ const stringOrArray = z
40
+ .union([z.string().min(1), z.array(z.string().min(1))])
41
+ .transform((v) => (Array.isArray(v) ? v : [v]));
42
+
43
+ // ─── Users ────────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * addUser({ id, email, phone, pushToken, segments, preferences })
47
+ * email / phone / pushToken accept a single string or an array.
48
+ */
49
+ export const AddUserSchema = z.object({
50
+ id: z.string().min(1),
51
+ language: z.string().optional(),
52
+ timezone: z.string().optional(),
53
+ email: stringOrArray.optional(),
54
+ phone: stringOrArray.optional(),
55
+ pushToken: stringOrArray.optional(),
56
+ segments: z.array(z.string().min(1)).optional(),
57
+ preferences: PreferencesSchema.optional(),
58
+ });
59
+ export type AddUserInput = z.input<typeof AddUserSchema>;
60
+
61
+ /** updateUser(id, patch) — every field optional; id comes from the path. */
62
+ export const UpdateUserSchema = z.object({
63
+ language: z.string().optional(),
64
+ timezone: z.string().optional(),
65
+ email: stringOrArray.optional(),
66
+ phone: stringOrArray.optional(),
67
+ pushToken: stringOrArray.optional(),
68
+ segments: z.array(z.string().min(1)).optional(),
69
+ preferences: PreferencesSchema.optional(),
70
+ });
71
+ export type UpdateUserInput = z.input<typeof UpdateUserSchema>;
72
+
73
+ /** addUserContact(userId, channel, { target, preferences }) — channel carried in body. */
74
+ export const AddContactSchema = z.object({
75
+ channel: ContactChannelSchema,
76
+ target: z.string().min(1),
77
+ preferences: PreferencesSchema.optional(),
78
+ });
79
+ export type AddContactInput = z.infer<typeof AddContactSchema>;
80
+
81
+ // ─── Templates ────────────────────────────────────────────────────────────────
82
+
83
+ export const TemplateSchema = z.object({
84
+ id: z.string().min(1),
85
+ channel: NotificationChannelSchema,
86
+ topic: z
87
+ .union([z.string().min(1), z.array(z.string().min(1))])
88
+ .transform((v) => (Array.isArray(v) ? v : [v]))
89
+ .optional(),
90
+ content: z.record(z.string(), z.unknown()),
91
+ aiPrompts: z.record(z.string(), z.string()).optional(),
92
+ });
93
+ export type TemplateInput = z.infer<typeof TemplateSchema>;
94
+
95
+ export const SyncTemplatesSchema = z.object({
96
+ templates: z.array(TemplateSchema).min(1),
97
+ });
98
+ export type SyncTemplatesInput = z.infer<typeof SyncTemplatesSchema>;
99
+
100
+ // ─── notify() ─────────────────────────────────────────────────────────────────
101
+
102
+ /** Inline user object accepted by notify({ user: {...} }). */
103
+ export const InlineUserSchema = z.object({
104
+ id: z.string().min(1),
105
+ language: z.string().optional(),
106
+ timezone: z.string().optional(),
107
+ email: stringOrArray.optional(),
108
+ phone: stringOrArray.optional(),
109
+ pushToken: stringOrArray.optional(),
110
+ segments: z.array(z.string().min(1)).optional(),
111
+ preferences: PreferencesSchema.optional(),
112
+ });
113
+ export type InlineUser = z.infer<typeof InlineUserSchema>;
114
+
115
+ /**
116
+ * notify(...) request body.
117
+ *
118
+ * Exactly one of `user` / `segment` / `topic` must be provided.
119
+ */
120
+ /**
121
+ * The field set shared by `notify()` and a workflow's `notify` step. The two
122
+ * differ only in whether naming a target is mandatory, so the fields live here
123
+ * once and each schema layers its own target rule on top.
124
+ */
125
+ const NotifyRequestFields = z.object({
126
+ user: z
127
+ .union([
128
+ z.string().min(1),
129
+ InlineUserSchema,
130
+ z.array(z.union([z.string().min(1), InlineUserSchema])).nonempty(),
131
+ ])
132
+ .optional(),
133
+ segment: z.string().min(1).optional(),
134
+ topic: z.string().min(1).optional(),
135
+ template: z.string().min(1),
136
+ data: z.record(z.string(), z.unknown()).optional(),
137
+ aiPrompts: z.record(z.string(), z.string()).optional(),
138
+ priority: NotificationPrioritySchema.optional(),
139
+ channels: z.array(NotificationChannelSchema).nonempty().optional(),
140
+ fallback: z.boolean().optional(),
141
+ sendAt: z.string().datetime().optional(),
142
+ /**
143
+ * A label grouping every message this call produces, so the send can be
144
+ * reported on later via `/v1/campaigns/:id/stats`. Free-form, but reusing one
145
+ * label across calls merges them into a single campaign — which is either
146
+ * what you want (a send split into batches) or a reporting bug.
147
+ */
148
+ campaign: z.string().min(1).max(128).optional(),
149
+ });
150
+
151
+ function countTargets(val: { user?: unknown; segment?: unknown; topic?: unknown }): number {
152
+ return [val.user, val.segment, val.topic].filter((t) => t !== undefined).length;
153
+ }
154
+
155
+ export const NotifyRequestSchema = NotifyRequestFields.superRefine((val, ctx) => {
156
+ const targets = countTargets(val);
157
+ if (targets === 0) {
158
+ ctx.addIssue({
159
+ code: z.ZodIssueCode.custom,
160
+ message: "one of `user`, `segment`, or `topic` is required",
161
+ path: ["user"],
162
+ });
163
+ } else if (targets > 1) {
164
+ ctx.addIssue({
165
+ code: z.ZodIssueCode.custom,
166
+ message: "provide exactly one of `user`, `segment`, or `topic`",
167
+ path: ["user"],
168
+ });
169
+ }
170
+ });
171
+ export type NotifyRequestInput = z.input<typeof NotifyRequestSchema>;
172
+
173
+ /**
174
+ * The payload of a workflow `notify` step — every field `notify()` takes.
175
+ *
176
+ * The one difference is that a target is optional here: a step naming none
177
+ * inherits the instance's own user, which is the ordinary case. Naming one
178
+ * overrides that, so a step can notify a different user, a segment, or a topic.
179
+ */
180
+ export const WorkflowNotifyPayloadSchema = NotifyRequestFields.superRefine((val, ctx) => {
181
+ if (countTargets(val) > 1) {
182
+ ctx.addIssue({
183
+ code: z.ZodIssueCode.custom,
184
+ message: "provide at most one of `user`, `segment`, or `topic`",
185
+ path: ["user"],
186
+ });
187
+ }
188
+ });
189
+ export type WorkflowNotifyInput = z.input<typeof WorkflowNotifyPayloadSchema>;
190
+
191
+ // ─── Workflows ────────────────────────────────────────────────────────────────
192
+
193
+ export const TriggerWorkflowSchema = z.object({
194
+ name: z.string().min(1),
195
+ input: z.record(z.string(), z.unknown()).optional(),
196
+ user: z.union([z.string().min(1), InlineUserSchema]).optional(),
197
+ });
198
+ export type TriggerWorkflowInput = z.infer<typeof TriggerWorkflowSchema>;
199
+
200
+ // ─── Events ───────────────────────────────────────────────────────────────────
201
+
202
+ export const IngestEventSchema = z.object({
203
+ name: z.string().min(1),
204
+ properties: z.record(z.string(), z.unknown()),
205
+ });
206
+ export type IngestEventInput = z.infer<typeof IngestEventSchema>;
207
+
208
+ export const WorkflowStepSchema = z.discriminatedUnion("action", [
209
+ z.object({
210
+ action: z.literal("notify"),
211
+ payload: WorkflowNotifyPayloadSchema,
212
+ }),
213
+ z.object({
214
+ action: z.literal("wait"),
215
+ duration: z.string(), // e.g. "1h", "5m"
216
+ }),
217
+ z.object({
218
+ action: z.literal("waitForEvent"),
219
+ event: z.string(),
220
+ options: z
221
+ .object({
222
+ timeout: z.string().optional(), // e.g. "24h"
223
+ })
224
+ .optional(),
225
+ }),
226
+ ]);
227
+ export type WorkflowStepDef = z.infer<typeof WorkflowStepSchema>;
228
+
229
+ export const CreateWorkflowSchema = z.object({
230
+ name: z.string().min(1),
231
+ steps: z.array(WorkflowStepSchema).min(1),
232
+ });
233
+ export type CreateWorkflowInput = z.infer<typeof CreateWorkflowSchema>;
234
+
235
+ // ─── Projects ─────────────────────────────────────────────────────────────────
236
+
237
+ export const UpdateProjectSchema = z.object({
238
+ rateLimitRpm: z.number().nullable().optional(),
239
+ throttleLimit: z.number().nullable().optional(),
240
+ throttleWindowHours: z.number().nullable().optional(),
241
+ });
242
+ export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
@@ -0,0 +1,62 @@
1
+ export const STREAMS = {
2
+ INBOUND_CRITICAL: "notifkit:stream:inbound:critical",
3
+ INBOUND_NORMAL: "notifkit:stream:inbound:normal",
4
+ INBOUND_LOW: "notifkit:stream:inbound:low",
5
+ ENRICHED_CRITICAL: "notifkit:stream:enriched:critical",
6
+ ENRICHED_NORMAL: "notifkit:stream:enriched:normal",
7
+ ENRICHED_LOW: "notifkit:stream:enriched:low",
8
+ AI_PENDING: "notifkit:stream:ai:pending",
9
+ SCHEDULED: "notifkit:stream:scheduled",
10
+ OUTBOUND_CRITICAL: "notifkit:stream:outbound:critical",
11
+ OUTBOUND_NORMAL: "notifkit:stream:outbound:normal",
12
+ OUTBOUND_LOW: "notifkit:stream:outbound:low",
13
+ DEAD_LETTER: "notifkit:stream:dlq",
14
+ WORKFLOW_INBOUND: "notifkit:stream:workflow:inbound",
15
+ EVENTS_INBOUND: "notifkit:stream:events:inbound",
16
+ } as const;
17
+
18
+ export const INBOUND_STREAMS = [
19
+ STREAMS.INBOUND_CRITICAL,
20
+ STREAMS.INBOUND_NORMAL,
21
+ STREAMS.INBOUND_LOW,
22
+ ] as const;
23
+
24
+ export const ENRICHED_STREAMS = [
25
+ STREAMS.ENRICHED_CRITICAL,
26
+ STREAMS.ENRICHED_NORMAL,
27
+ STREAMS.ENRICHED_LOW,
28
+ ] as const;
29
+
30
+ export const OUTBOUND_STREAMS = [
31
+ STREAMS.OUTBOUND_CRITICAL,
32
+ STREAMS.OUTBOUND_NORMAL,
33
+ STREAMS.OUTBOUND_LOW,
34
+ ] as const;
35
+ export type StreamName = (typeof STREAMS)[keyof typeof STREAMS];
36
+
37
+ /**
38
+ * Redis pub/sub channels used to drop cached state across every process.
39
+ *
40
+ * Each cache also carries a TTL, so these only shorten the window in which a
41
+ * worker can act on stale data — they are not the sole correctness mechanism.
42
+ */
43
+ export const PUBSUB_CHANNELS = {
44
+ /** Payload: `{projectId}:{templateId}`. */
45
+ TEMPLATE_INVALIDATED: "template.invalidated",
46
+ /** Payload: `{projectId}`. Published when project settings change. */
47
+ PROJECT_INVALIDATED: "project.invalidated",
48
+ /** Payload: a token hash, or `*` for the whole cache. */
49
+ API_KEY_INVALIDATED: "apikey.invalidated",
50
+ } as const;
51
+ export type PubSubChannel = (typeof PUBSUB_CHANNELS)[keyof typeof PUBSUB_CHANNELS];
52
+
53
+ export const CONSUMER_GROUPS = {
54
+ ENRICHER: "notifkit:group:enricher",
55
+ ENGINE: "notifkit:group:engine",
56
+ DELIVERY: "notifkit:group:delivery",
57
+ SCHEDULER: "notifkit:group:scheduler",
58
+ AI: "notifkit:group:ai",
59
+ WORKFLOW: "notifkit:group:workflow",
60
+ EVENTS: "notifkit:group:events",
61
+ } as const;
62
+ export type ConsumerGroup = (typeof CONSUMER_GROUPS)[keyof typeof CONSUMER_GROUPS];
@@ -0,0 +1,69 @@
1
+ import postgres from "postgres";
2
+ import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import type { Logger } from "@/index.js";
4
+ import * as schema from "./schema.js";
5
+ import { readBaseConfig } from "@/index.js";
6
+
7
+ export type Sql = postgres.Sql;
8
+ export type Db = PostgresJsDatabase<typeof schema>;
9
+
10
+ export interface DatabaseOptions {
11
+ url: string;
12
+ applicationName?: string;
13
+ maxConnections?: number;
14
+ idleTimeoutSeconds?: number;
15
+ logger?: Logger;
16
+ }
17
+
18
+ export interface DatabaseClients {
19
+ sql: Sql;
20
+ db: Db;
21
+ }
22
+
23
+ /**
24
+ * Create a postgres.js connection pool and initialize Drizzle ORM.
25
+ * Call once at application startup; pass db into repositories.
26
+ * Call sql.end() during graceful shutdown.
27
+ */
28
+ export function createDatabase({
29
+ url,
30
+ applicationName = "notifkit",
31
+ maxConnections,
32
+ idleTimeoutSeconds = 30,
33
+ logger,
34
+ }: DatabaseOptions): DatabaseClients {
35
+ const finalMaxConnections = maxConnections ?? readBaseConfig().DB_MAX_CONNECTIONS;
36
+
37
+ const sql = postgres(url, {
38
+ max: finalMaxConnections,
39
+ idle_timeout: idleTimeoutSeconds,
40
+ connection: {
41
+ application_name: applicationName,
42
+ statement_timeout: 10000 as any, // prevent TS issues with postgres.js types
43
+ },
44
+ onnotice: (notice) => {
45
+ logger?.debug({ notice }, "postgres notice");
46
+ },
47
+ });
48
+
49
+ const db = drizzle(sql, { schema });
50
+
51
+ return { sql, db };
52
+ }
53
+
54
+ import { migrate } from "drizzle-orm/postgres-js/migrator";
55
+ import { fileURLToPath } from "url";
56
+ import path from "path";
57
+ import fs from "fs";
58
+
59
+ export async function runMigrations(db: Db) {
60
+ const __filename = fileURLToPath(import.meta.url);
61
+ const __dirname = path.dirname(__filename);
62
+
63
+ let migrationsFolder = path.resolve(__dirname, "../../drizzle");
64
+ if (!fs.existsSync(migrationsFolder)) {
65
+ migrationsFolder = path.resolve(__dirname, "../drizzle");
66
+ }
67
+
68
+ await migrate(db, { migrationsFolder });
69
+ }