notifkit 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -122
- package/dist/index.d.mts +196 -132
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
- package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
- package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
- package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
- package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
- package/dist/main-BNJtzY61.mjs.map +1 -0
- package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
- package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
- package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
- package/dist/main-CiigNpsP.mjs.map +1 -0
- package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
- package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
- package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
- package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
- package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
- package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
- package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
- package/dist/src-vG79L-8m.mjs.map +1 -0
- package/drizzle/0002_wide_colleen_wing.sql +2 -0
- package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
- package/drizzle/0004_pretty_bruce_banner.sql +1 -0
- package/drizzle/meta/0002_snapshot.json +1460 -0
- package/drizzle/meta/0003_snapshot.json +1460 -0
- package/drizzle/meta/0004_snapshot.json +1470 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +3 -2
- package/src/client.ts +412 -0
- package/src/config/index.ts +107 -0
- package/src/contracts/common.ts +28 -0
- package/src/contracts/envelope.ts +31 -0
- package/src/contracts/events/notification-ai-pending.ts +18 -0
- package/src/contracts/events/notification-canceled.ts +7 -0
- package/src/contracts/events/notification-created.ts +14 -0
- package/src/contracts/events/notification-delivered.ts +17 -0
- package/src/contracts/events/notification-dispatched.ts +45 -0
- package/src/contracts/events/notification-enriched.ts +46 -0
- package/src/contracts/events/notification-failed.ts +19 -0
- package/src/contracts/events/notification-requested.ts +36 -0
- package/src/contracts/events/notification-scheduled.ts +9 -0
- package/src/contracts/events/notification-skipped.ts +9 -0
- package/src/contracts/helpers.ts +21 -0
- package/src/contracts/index.ts +46 -0
- package/src/contracts/metadata.ts +10 -0
- package/src/contracts/registry.ts +88 -0
- package/src/contracts/sdk.ts +242 -0
- package/src/contracts/streams.ts +62 -0
- package/src/db/index.ts +69 -0
- package/src/db/schema.ts +412 -0
- package/src/idempotency/index.ts +50 -0
- package/src/index.ts +19 -0
- package/src/logger/index.ts +60 -0
- package/src/metrics/index.ts +53 -0
- package/src/queue/index.ts +501 -0
- package/src/rate-limiter/index.ts +210 -0
- package/src/redis/index.ts +89 -0
- package/src/repositories/index.ts +1246 -0
- package/src/server.ts +277 -0
- package/src/services/ai/main.ts +404 -0
- package/src/services/api/handlers.ts +1734 -0
- package/src/services/api/http.ts +64 -0
- package/src/services/api/main.ts +693 -0
- package/src/services/api/router.ts +82 -0
- package/src/services/delivery/main.ts +842 -0
- package/src/services/delivery/throttle.ts +71 -0
- package/src/services/engine/main.ts +827 -0
- package/src/services/enricher/main.ts +594 -0
- package/src/services/events/main.ts +365 -0
- package/src/services/scheduler/main.ts +319 -0
- package/src/services/workflow/main.ts +627 -0
- package/src/shared/batch-processor.ts +67 -0
- package/src/shared/cache.ts +47 -0
- package/src/shared/circuit-breaker.ts +74 -0
- package/src/shared/dataloader.ts +41 -0
- package/src/shared/events.ts +3 -0
- package/src/shared/index.ts +39 -0
- package/src/shared/semaphore.ts +33 -0
- package/src/shared/utils.ts +64 -0
- package/src/templates/cache.ts +32 -0
- package/src/templates/index.ts +69 -0
- package/src/templates/render.ts +128 -0
- package/src/transport/index.ts +96 -0
- package/src/unsubscribe/index.ts +127 -0
- package/src/workers/health.ts +31 -0
- package/src/workers/index.ts +266 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/registry.ts +21 -0
- package/src/workflows/sdk.ts +106 -0
- package/dist/main-CAH0_Q6d.mjs.map +0 -1
- package/dist/main-CCfc45ev.mjs.map +0 -1
- package/dist/src-C-PfEDMY.mjs.map +0 -1
package/src/db/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
varchar,
|
|
4
|
+
text,
|
|
5
|
+
jsonb,
|
|
6
|
+
boolean,
|
|
7
|
+
timestamp,
|
|
8
|
+
primaryKey,
|
|
9
|
+
unique,
|
|
10
|
+
uuid,
|
|
11
|
+
pgEnum,
|
|
12
|
+
time,
|
|
13
|
+
check,
|
|
14
|
+
index,
|
|
15
|
+
integer,
|
|
16
|
+
} from "drizzle-orm/pg-core";
|
|
17
|
+
import { sql } from "drizzle-orm";
|
|
18
|
+
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
|
19
|
+
|
|
20
|
+
export const channelEnum = pgEnum("channel", [
|
|
21
|
+
"email",
|
|
22
|
+
"sms",
|
|
23
|
+
"push",
|
|
24
|
+
"webhook",
|
|
25
|
+
"in-app",
|
|
26
|
+
"telegram",
|
|
27
|
+
"discord",
|
|
28
|
+
"whatsapp",
|
|
29
|
+
"slack",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export const projects = pgTable("projects", {
|
|
33
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
34
|
+
name: varchar("name").notNull(),
|
|
35
|
+
rateLimitRpm: integer("rate_limit_rpm"),
|
|
36
|
+
throttleLimit: integer("throttle_limit"),
|
|
37
|
+
throttleWindowHours: integer("throttle_window_hours"),
|
|
38
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const users = pgTable(
|
|
42
|
+
"users",
|
|
43
|
+
{
|
|
44
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
45
|
+
projectId: uuid("project_id").notNull(),
|
|
46
|
+
externalId: text("external_id").notNull(),
|
|
47
|
+
attributes: jsonb("attributes").notNull().default({}),
|
|
48
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
49
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
50
|
+
},
|
|
51
|
+
(table) => ({
|
|
52
|
+
unq: unique().on(table.projectId, table.externalId),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export const apiKeyRoleEnum = pgEnum("api_key_role", ["admin", "read_only"]);
|
|
57
|
+
|
|
58
|
+
export const projectApiKeys = pgTable("project_api_keys", {
|
|
59
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
60
|
+
projectId: uuid("project_id")
|
|
61
|
+
.notNull()
|
|
62
|
+
.references(() => projects.id, { onDelete: "cascade" }),
|
|
63
|
+
keyHash: varchar("key_hash").notNull().unique(),
|
|
64
|
+
role: apiKeyRoleEnum("role").notNull().default("admin"),
|
|
65
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const userSegments = pgTable(
|
|
69
|
+
"user_segments",
|
|
70
|
+
{
|
|
71
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
72
|
+
userId: uuid("user_id")
|
|
73
|
+
.notNull()
|
|
74
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
75
|
+
segment: varchar("segment").notNull(),
|
|
76
|
+
},
|
|
77
|
+
(table) => ({
|
|
78
|
+
unq: unique().on(table.userId, table.segment),
|
|
79
|
+
segmentIdx: index("segment_idx").on(table.segment),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
export const userContacts = pgTable(
|
|
84
|
+
"user_contacts",
|
|
85
|
+
{
|
|
86
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
87
|
+
userId: uuid("user_id")
|
|
88
|
+
.notNull()
|
|
89
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
90
|
+
channel: channelEnum("channel").notNull(),
|
|
91
|
+
target: text("target").notNull(),
|
|
92
|
+
label: text("label"),
|
|
93
|
+
isPrimary: boolean("is_primary").notNull().default(false),
|
|
94
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
95
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
96
|
+
},
|
|
97
|
+
(table) => ({
|
|
98
|
+
unq: unique().on(table.userId, table.channel, table.target),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
export const userChannelPreferences = pgTable(
|
|
103
|
+
"user_channel_preferences",
|
|
104
|
+
{
|
|
105
|
+
userId: uuid("user_id")
|
|
106
|
+
.notNull()
|
|
107
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
108
|
+
channel: channelEnum("channel").notNull(),
|
|
109
|
+
enabled: boolean("enabled").notNull(),
|
|
110
|
+
},
|
|
111
|
+
(table) => ({
|
|
112
|
+
pk: primaryKey({ columns: [table.userId, table.channel] }),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
export const userTopicPreferences = pgTable(
|
|
117
|
+
"user_topic_preferences",
|
|
118
|
+
{
|
|
119
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
120
|
+
userId: uuid("user_id")
|
|
121
|
+
.notNull()
|
|
122
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
123
|
+
topic: varchar("topic").notNull(),
|
|
124
|
+
enabled: boolean("enabled").notNull(),
|
|
125
|
+
},
|
|
126
|
+
(table) => ({
|
|
127
|
+
unq: unique().on(table.userId, table.topic),
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
export const contactTopicPreferences = pgTable(
|
|
132
|
+
"contact_topic_preferences",
|
|
133
|
+
{
|
|
134
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
135
|
+
contactId: uuid("contact_id")
|
|
136
|
+
.notNull()
|
|
137
|
+
.references(() => userContacts.id, { onDelete: "cascade" }),
|
|
138
|
+
topic: varchar("topic").notNull(),
|
|
139
|
+
enabled: boolean("enabled").notNull(),
|
|
140
|
+
},
|
|
141
|
+
(table) => ({
|
|
142
|
+
unq: unique().on(table.contactId, table.topic),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
export const quietHours = pgTable(
|
|
147
|
+
"quiet_hours",
|
|
148
|
+
{
|
|
149
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
150
|
+
userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
|
|
151
|
+
contactId: uuid("contact_id").references(() => userContacts.id, { onDelete: "cascade" }),
|
|
152
|
+
startTime: time("start_time").notNull(),
|
|
153
|
+
endTime: time("end_time").notNull(),
|
|
154
|
+
},
|
|
155
|
+
(table) => ({
|
|
156
|
+
checkOwner: check("check_owner", sql`num_nonnulls(user_id, contact_id) = 1`),
|
|
157
|
+
userIdIdx: index("quiet_hours_user_idx").on(table.userId),
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
export const templates = pgTable(
|
|
162
|
+
"templates",
|
|
163
|
+
{
|
|
164
|
+
projectId: uuid("project_id")
|
|
165
|
+
.notNull()
|
|
166
|
+
.references(() => projects.id, { onDelete: "cascade" }),
|
|
167
|
+
id: varchar("id").notNull(),
|
|
168
|
+
channel: channelEnum("channel").notNull(),
|
|
169
|
+
topics: text("topics").array().notNull(),
|
|
170
|
+
content: jsonb("content").notNull(),
|
|
171
|
+
aiPrompts: jsonb("ai_prompts"),
|
|
172
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
173
|
+
},
|
|
174
|
+
(table) => ({
|
|
175
|
+
pk: primaryKey({ columns: [table.projectId, table.id] }),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
export const messageLogs = pgTable(
|
|
180
|
+
"message_logs",
|
|
181
|
+
{
|
|
182
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
183
|
+
projectId: uuid("project_id").notNull(),
|
|
184
|
+
taskId: varchar("task_id").notNull(),
|
|
185
|
+
providerMessageId: varchar("provider_message_id"),
|
|
186
|
+
templateId: varchar("template_id"),
|
|
187
|
+
workflowInstanceId: uuid("workflow_instance_id"),
|
|
188
|
+
channel: channelEnum("channel").notNull(),
|
|
189
|
+
// Delivery attempts are numbered from 1; provider engagement events are not
|
|
190
|
+
// attempts and use 0.
|
|
191
|
+
attempt: integer("attempt").default(1).notNull(),
|
|
192
|
+
/**
|
|
193
|
+
* Discriminates a delivery attempt ("attempt") from a provider engagement
|
|
194
|
+
* event ("opened", "clicked", "bounced", …). Without it an engagement row
|
|
195
|
+
* collides with the delivery row for the same (task, channel, attempt).
|
|
196
|
+
*/
|
|
197
|
+
kind: varchar("kind").notNull().default("attempt"),
|
|
198
|
+
status: varchar("status").notNull(),
|
|
199
|
+
/**
|
|
200
|
+
* Groups every message produced by one `notify()` call. Null for sends that
|
|
201
|
+
* did not name a campaign, which is every send made before this column
|
|
202
|
+
* existed — treat null as "unattributed", not as a campaign of its own.
|
|
203
|
+
*/
|
|
204
|
+
campaignId: varchar("campaign_id"),
|
|
205
|
+
/**
|
|
206
|
+
* Provider-specific detail that would otherwise be discarded: the clicked
|
|
207
|
+
* URL on a click event, the bounce subtype on a bounce. Deliberately loose
|
|
208
|
+
* — every provider reports these differently.
|
|
209
|
+
*/
|
|
210
|
+
metadata: jsonb("metadata"),
|
|
211
|
+
timestamp: timestamp("timestamp", { withTimezone: true }).defaultNow().notNull(),
|
|
212
|
+
},
|
|
213
|
+
(table) => ({
|
|
214
|
+
projectIdx: index("message_logs_project_idx").on(table.projectId),
|
|
215
|
+
taskIdx: index("task_idx").on(table.taskId),
|
|
216
|
+
projectIdTaskIdIdx: index("message_logs_project_task_idx").on(table.projectId, table.taskId),
|
|
217
|
+
providerMsgIdx: index("provider_msg_idx").on(table.providerMessageId),
|
|
218
|
+
projectTimeIdx: index("msg_log_proj_time_idx").on(table.projectId, table.timestamp),
|
|
219
|
+
templateIdx: index("msg_log_template_idx").on(table.projectId, table.templateId),
|
|
220
|
+
workflowIdx: index("msg_log_workflow_idx").on(table.projectId, table.workflowInstanceId),
|
|
221
|
+
campaignIdx: index("msg_log_campaign_idx").on(table.projectId, table.campaignId),
|
|
222
|
+
taskChannelAttemptUidx: unique("task_channel_attempt_uidx").on(
|
|
223
|
+
table.taskId,
|
|
224
|
+
table.channel,
|
|
225
|
+
table.attempt,
|
|
226
|
+
table.kind,
|
|
227
|
+
),
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Addresses that must not be contacted again on a given channel.
|
|
233
|
+
*
|
|
234
|
+
* Rows are written from provider webhooks (an unsubscribe, a spam complaint, a
|
|
235
|
+
* hard bounce) and by hand through the API. The engine consults this table
|
|
236
|
+
* before dispatching, so a suppression is a hard stop rather than a preference
|
|
237
|
+
* — `priority: "critical"` does not override it. Removing a row is the only way
|
|
238
|
+
* back, and that is deliberately a manual act.
|
|
239
|
+
*/
|
|
240
|
+
export const suppressions = pgTable(
|
|
241
|
+
"suppressions",
|
|
242
|
+
{
|
|
243
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
244
|
+
projectId: uuid("project_id").notNull(),
|
|
245
|
+
channel: channelEnum("channel").notNull(),
|
|
246
|
+
/** The address itself, normalised: email is lower-cased, others stored verbatim. */
|
|
247
|
+
target: varchar("target").notNull(),
|
|
248
|
+
/** `unsubscribed` | `complained` | `bounced` | `manual`. */
|
|
249
|
+
reason: varchar("reason").notNull(),
|
|
250
|
+
/** Where it came from — a provider name, or `api` for a manual entry. */
|
|
251
|
+
source: varchar("source"),
|
|
252
|
+
/** The delivery that triggered it, when a provider webhook is the origin. */
|
|
253
|
+
taskId: varchar("task_id"),
|
|
254
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
255
|
+
},
|
|
256
|
+
(table) => ({
|
|
257
|
+
// One row per address per channel; a second complaint must not error.
|
|
258
|
+
projectChannelTargetUidx: unique("suppression_project_channel_target_uidx").on(
|
|
259
|
+
table.projectId,
|
|
260
|
+
table.channel,
|
|
261
|
+
table.target,
|
|
262
|
+
),
|
|
263
|
+
projectIdx: index("suppression_project_idx").on(table.projectId),
|
|
264
|
+
// The engine's hot path: "is this address suppressed on this channel?"
|
|
265
|
+
lookupIdx: index("suppression_lookup_idx").on(table.projectId, table.channel, table.target),
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
export const workflowStatusEnum = pgEnum("workflow_status", [
|
|
270
|
+
"pending",
|
|
271
|
+
"running",
|
|
272
|
+
"completed",
|
|
273
|
+
"failed",
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
export const workflowDefinitions = pgTable(
|
|
277
|
+
"workflow_definitions",
|
|
278
|
+
{
|
|
279
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
280
|
+
projectId: uuid("project_id").notNull(),
|
|
281
|
+
name: varchar("name").notNull(),
|
|
282
|
+
steps: jsonb("steps").notNull(),
|
|
283
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
284
|
+
},
|
|
285
|
+
(table) => ({
|
|
286
|
+
nameUnq: unique().on(table.projectId, table.name),
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
export const workflowInstances = pgTable(
|
|
291
|
+
"workflow_instances",
|
|
292
|
+
{
|
|
293
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
294
|
+
projectId: uuid("project_id").notNull(),
|
|
295
|
+
name: varchar("name").notNull(),
|
|
296
|
+
status: workflowStatusEnum("status").notNull().default("pending"),
|
|
297
|
+
input: jsonb("input").default({}),
|
|
298
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
299
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
300
|
+
},
|
|
301
|
+
(table) => ({
|
|
302
|
+
nameIdx: index("workflow_name_idx").on(table.name),
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
export const workflowSteps = pgTable(
|
|
307
|
+
"workflow_steps",
|
|
308
|
+
{
|
|
309
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
310
|
+
projectId: uuid("project_id").notNull(),
|
|
311
|
+
instanceId: uuid("instance_id")
|
|
312
|
+
.notNull()
|
|
313
|
+
.references(() => workflowInstances.id, { onDelete: "cascade" }),
|
|
314
|
+
stepIndex: varchar("step_index").notNull(), // We can use string format for index (e.g. "0", "1", "0.1") or a deterministic id
|
|
315
|
+
action: varchar("action").notNull(), // e.g. "notify", "wait", "run"
|
|
316
|
+
output: jsonb("output"),
|
|
317
|
+
error: text("error"),
|
|
318
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
319
|
+
},
|
|
320
|
+
(table) => ({
|
|
321
|
+
unq: unique().on(table.instanceId, table.stepIndex),
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
export const workflowWaiters = pgTable(
|
|
326
|
+
"workflow_waiters",
|
|
327
|
+
{
|
|
328
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
329
|
+
projectId: uuid("project_id").notNull(),
|
|
330
|
+
instanceId: uuid("instance_id")
|
|
331
|
+
.notNull()
|
|
332
|
+
.references(() => workflowInstances.id, { onDelete: "cascade" }),
|
|
333
|
+
eventName: varchar("event_name").notNull(),
|
|
334
|
+
matchCriteria: jsonb("match_criteria").notNull(), // { userId: "123" }
|
|
335
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
336
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
337
|
+
},
|
|
338
|
+
(table) => ({
|
|
339
|
+
eventIdx: index("waiter_event_idx").on(table.eventName),
|
|
340
|
+
instanceIdx: index("waiter_instance_idx").on(table.instanceId),
|
|
341
|
+
compositeWaitIdx: index("waiter_comp_idx").on(
|
|
342
|
+
table.eventName,
|
|
343
|
+
table.projectId,
|
|
344
|
+
table.expiresAt,
|
|
345
|
+
),
|
|
346
|
+
matchCriteriaIdx: index("waiter_match_idx").using("gin", table.matchCriteria),
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
export const deliveryOutbox = pgTable(
|
|
351
|
+
"delivery_outbox",
|
|
352
|
+
{
|
|
353
|
+
taskId: varchar("task_id").notNull(),
|
|
354
|
+
channel: channelEnum("channel").notNull(),
|
|
355
|
+
destination: text("destination").notNull(),
|
|
356
|
+
providerMessageId: varchar("provider_message_id"),
|
|
357
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
358
|
+
},
|
|
359
|
+
(table) => ({
|
|
360
|
+
pk: primaryKey({ columns: [table.taskId, table.channel, table.destination] }),
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
export const scheduledPayloads = pgTable("scheduled_payloads", {
|
|
365
|
+
taskId: varchar("task_id").primaryKey(),
|
|
366
|
+
payload: jsonb("payload").notNull(),
|
|
367
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ─── Generated Zod Schemas ──────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
export const insertProjectSchema = createInsertSchema(projects);
|
|
373
|
+
export const selectProjectSchema = createSelectSchema(projects);
|
|
374
|
+
|
|
375
|
+
export const insertProjectApiKeySchema = createInsertSchema(projectApiKeys);
|
|
376
|
+
export const selectProjectApiKeySchema = createSelectSchema(projectApiKeys);
|
|
377
|
+
|
|
378
|
+
export const insertUserSchema = createInsertSchema(users);
|
|
379
|
+
export const selectUserSchema = createSelectSchema(users);
|
|
380
|
+
|
|
381
|
+
export const insertUserSegmentSchema = createInsertSchema(userSegments);
|
|
382
|
+
export const selectUserSegmentSchema = createSelectSchema(userSegments);
|
|
383
|
+
|
|
384
|
+
export const insertUserContactSchema = createInsertSchema(userContacts);
|
|
385
|
+
export const selectUserContactSchema = createSelectSchema(userContacts);
|
|
386
|
+
|
|
387
|
+
export const insertUserChannelPreferenceSchema = createInsertSchema(userChannelPreferences);
|
|
388
|
+
export const selectUserChannelPreferenceSchema = createSelectSchema(userChannelPreferences);
|
|
389
|
+
|
|
390
|
+
export const insertUserTopicPreferenceSchema = createInsertSchema(userTopicPreferences);
|
|
391
|
+
export const selectUserTopicPreferenceSchema = createSelectSchema(userTopicPreferences);
|
|
392
|
+
|
|
393
|
+
export const insertMessageLogSchema = createInsertSchema(messageLogs);
|
|
394
|
+
export const selectMessageLogSchema = createSelectSchema(messageLogs);
|
|
395
|
+
|
|
396
|
+
export const insertSuppressionSchema = createInsertSchema(suppressions);
|
|
397
|
+
export const selectSuppressionSchema = createSelectSchema(suppressions);
|
|
398
|
+
|
|
399
|
+
export const insertWorkflowInstanceSchema = createInsertSchema(workflowInstances);
|
|
400
|
+
export const selectWorkflowInstanceSchema = createSelectSchema(workflowInstances);
|
|
401
|
+
|
|
402
|
+
export const insertWorkflowStepSchema = createInsertSchema(workflowSteps);
|
|
403
|
+
export const selectWorkflowStepSchema = createSelectSchema(workflowSteps);
|
|
404
|
+
|
|
405
|
+
export const insertWorkflowWaiterSchema = createInsertSchema(workflowWaiters);
|
|
406
|
+
export const selectWorkflowWaiterSchema = createSelectSchema(workflowWaiters);
|
|
407
|
+
|
|
408
|
+
export const insertDeliveryOutboxSchema = createInsertSchema(deliveryOutbox);
|
|
409
|
+
export const selectDeliveryOutboxSchema = createSelectSchema(deliveryOutbox);
|
|
410
|
+
|
|
411
|
+
export const insertScheduledPayloadSchema = createInsertSchema(scheduledPayloads);
|
|
412
|
+
export const selectScheduledPayloadSchema = createSelectSchema(scheduledPayloads);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Redis } from "@/index.js";
|
|
2
|
+
|
|
3
|
+
export interface IdempotencyOptions {
|
|
4
|
+
redis: Redis;
|
|
5
|
+
keyPrefix: string;
|
|
6
|
+
ttlSeconds?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SETNX-based idempotency guard.
|
|
11
|
+
* Returns true from checkAndMark() only the first time a given ID is seen
|
|
12
|
+
* within the TTL window; subsequent calls return false (duplicate / retry).
|
|
13
|
+
*/
|
|
14
|
+
export class IdempotencyGuard {
|
|
15
|
+
private readonly redis: Redis;
|
|
16
|
+
private readonly keyPrefix: string;
|
|
17
|
+
private readonly ttlSeconds: number;
|
|
18
|
+
|
|
19
|
+
constructor({ redis, keyPrefix, ttlSeconds = 86_400 }: IdempotencyOptions) {
|
|
20
|
+
this.redis = redis;
|
|
21
|
+
this.keyPrefix = keyPrefix;
|
|
22
|
+
this.ttlSeconds = ttlSeconds;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private key(id: string): string {
|
|
26
|
+
return `${this.keyPrefix}:${id}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Atomically mark id as processed. Returns true on first call; false if already seen. */
|
|
30
|
+
async checkAndMark(id: string, customTtlSeconds?: number): Promise<boolean> {
|
|
31
|
+
const ttl = customTtlSeconds ?? this.ttlSeconds;
|
|
32
|
+
const result = await this.redis.set(this.key(id), "1", "EX", ttl, "NX");
|
|
33
|
+
return result === "OK";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Unconditionally mark id as processed (e.g. to upgrade a short-lived lock). */
|
|
37
|
+
async markProcessed(id: string, customTtlSeconds?: number): Promise<void> {
|
|
38
|
+
const ttl = customTtlSeconds ?? this.ttlSeconds;
|
|
39
|
+
await this.redis.set(this.key(id), "1", "EX", ttl);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async isProcessed(id: string): Promise<boolean> {
|
|
43
|
+
return (await this.redis.get(this.key(id))) !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove the idempotency marker (useful in tests or manual rollbacks). */
|
|
47
|
+
async unmark(id: string): Promise<void> {
|
|
48
|
+
await this.redis.del(this.key(id));
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from "./config/index.js";
|
|
2
|
+
export * from "./contracts/index.js";
|
|
3
|
+
export * from "./db/index.js";
|
|
4
|
+
export * from "./idempotency/index.js";
|
|
5
|
+
export * from "./logger/index.js";
|
|
6
|
+
export * from "./metrics/index.js";
|
|
7
|
+
export * from "./queue/index.js";
|
|
8
|
+
export * from "./rate-limiter/index.js";
|
|
9
|
+
export * from "./redis/index.js";
|
|
10
|
+
export * from "./repositories/index.js";
|
|
11
|
+
|
|
12
|
+
export * from "./shared/index.js";
|
|
13
|
+
export * from "./templates/index.js";
|
|
14
|
+
export * from "./transport/index.js";
|
|
15
|
+
export * from "./unsubscribe/index.js";
|
|
16
|
+
export * from "./workers/index.js";
|
|
17
|
+
export * from "./workflows/index.js";
|
|
18
|
+
export * from "./client.js";
|
|
19
|
+
export * from "./server.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import pino, { type Logger as PinoLogger, type LoggerOptions as PinoOptions } from "pino";
|
|
2
|
+
|
|
3
|
+
export type LogLevel = "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent";
|
|
4
|
+
|
|
5
|
+
export type Logger = PinoLogger;
|
|
6
|
+
|
|
7
|
+
export interface LoggerOptions {
|
|
8
|
+
name: string;
|
|
9
|
+
level?: LogLevel;
|
|
10
|
+
pretty?: boolean;
|
|
11
|
+
context?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createLogger({ name, level = "info", pretty, context }: LoggerOptions): Logger {
|
|
15
|
+
const usePretty = pretty ?? process.env["NODE_ENV"] !== "production";
|
|
16
|
+
|
|
17
|
+
const options: PinoOptions = {
|
|
18
|
+
name,
|
|
19
|
+
level,
|
|
20
|
+
formatters: {
|
|
21
|
+
level(label) {
|
|
22
|
+
return { level: label };
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
26
|
+
serializers: {
|
|
27
|
+
err: pino.stdSerializers.err,
|
|
28
|
+
error: pino.stdSerializers.err,
|
|
29
|
+
req: pino.stdSerializers.req,
|
|
30
|
+
res: pino.stdSerializers.res,
|
|
31
|
+
},
|
|
32
|
+
base: { service: name, ...context },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (usePretty) {
|
|
36
|
+
options.transport = {
|
|
37
|
+
target: "pino-pretty",
|
|
38
|
+
options: {
|
|
39
|
+
colorize: true,
|
|
40
|
+
translateTime: "SYS:standard",
|
|
41
|
+
ignore: "pid,hostname",
|
|
42
|
+
messageFormat: "{service} | {msg}",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return pino(options);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function withRequestId(logger: Logger, requestId: string): Logger {
|
|
51
|
+
return logger.child({ requestId });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function withContext(logger: Logger, context: Record<string, unknown>): Logger {
|
|
55
|
+
return logger.child(context);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function childLogger(logger: Logger, bindings: Record<string, unknown>): Logger {
|
|
59
|
+
return logger.child(bindings);
|
|
60
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import promClient from "prom-client";
|
|
2
|
+
|
|
3
|
+
const register = new promClient.Registry();
|
|
4
|
+
promClient.collectDefaultMetrics({ register });
|
|
5
|
+
|
|
6
|
+
export const metrics = {
|
|
7
|
+
messagesPublished: new promClient.Counter({
|
|
8
|
+
name: "notifkit_messages_published_total",
|
|
9
|
+
help: "Total messages published to inbound streams",
|
|
10
|
+
labelNames: ["channel", "priority"],
|
|
11
|
+
registers: [register],
|
|
12
|
+
}),
|
|
13
|
+
messagesProcessed: new promClient.Counter({
|
|
14
|
+
name: "notifkit_messages_processed_total",
|
|
15
|
+
help: "Total messages processed by workers",
|
|
16
|
+
labelNames: ["worker", "status"],
|
|
17
|
+
registers: [register],
|
|
18
|
+
}),
|
|
19
|
+
deliverySuccess: new promClient.Counter({
|
|
20
|
+
name: "notifkit_delivery_success_total",
|
|
21
|
+
help: "Total successful deliveries",
|
|
22
|
+
labelNames: ["channel"],
|
|
23
|
+
registers: [register],
|
|
24
|
+
}),
|
|
25
|
+
deliveryFailed: new promClient.Counter({
|
|
26
|
+
name: "notifkit_delivery_failed_total",
|
|
27
|
+
help: "Total failed deliveries",
|
|
28
|
+
labelNames: ["channel", "reason"],
|
|
29
|
+
registers: [register],
|
|
30
|
+
}),
|
|
31
|
+
workerActiveTasks: new promClient.Gauge({
|
|
32
|
+
name: "notifkit_worker_active_tasks",
|
|
33
|
+
help: "Number of currently active tasks per worker",
|
|
34
|
+
labelNames: ["worker"],
|
|
35
|
+
registers: [register],
|
|
36
|
+
}),
|
|
37
|
+
queueSize: new promClient.Gauge({
|
|
38
|
+
name: "notifkit_queue_size",
|
|
39
|
+
help: "Current size of streams",
|
|
40
|
+
labelNames: ["stream"],
|
|
41
|
+
registers: [register],
|
|
42
|
+
}),
|
|
43
|
+
pendingAcks: new promClient.Gauge({
|
|
44
|
+
name: "notifkit_pending_acks",
|
|
45
|
+
help: "Number of pending un-acked messages per group",
|
|
46
|
+
labelNames: ["group"],
|
|
47
|
+
registers: [register],
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function getMetricsRegistry() {
|
|
52
|
+
return register;
|
|
53
|
+
}
|