@checkstack/backend-api 0.0.2

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.
@@ -0,0 +1,310 @@
1
+ import { z } from "zod";
2
+
3
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4
+ // Storage Interfaces (simple data shapes for DB/API)
5
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+
7
+ /**
8
+ * Base interface for versioned data stored in database.
9
+ * Simple JSON-serializable shape.
10
+ */
11
+ export interface VersionedRecord<T = unknown> {
12
+ /** Schema version of this record */
13
+ version: number;
14
+ /** The actual data payload */
15
+ data: T;
16
+ /** When the last migration was applied (if any) */
17
+ migratedAt?: Date;
18
+ /** Original version before any migrations were applied */
19
+ originalVersion?: number;
20
+ }
21
+
22
+ /**
23
+ * Versioned record with plugin context.
24
+ * Used for plugin-wide configuration storage.
25
+ */
26
+ export interface VersionedPluginRecord<T = unknown> extends VersionedRecord<T> {
27
+ /** Plugin ID that owns this configuration */
28
+ pluginId: string;
29
+ }
30
+
31
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
32
+ // Migration Types
33
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
34
+
35
+ /**
36
+ * Type-safe migration from one data version to another.
37
+ * Used for backward-compatible schema evolution.
38
+ */
39
+ export interface Migration<TFrom = unknown, TTo = unknown> {
40
+ /** Version number migrating from */
41
+ fromVersion: number;
42
+ /** Version number migrating to (must be fromVersion + 1) */
43
+ toVersion: number;
44
+ /** Human-readable description of what this migration does */
45
+ description: string;
46
+ /** Migration function that transforms old data to new format */
47
+ migrate(data: TFrom): TTo | Promise<TTo>;
48
+ }
49
+
50
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51
+ // Migration Builder
52
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
53
+
54
+ /**
55
+ * Builder for creating type-safe migration chains.
56
+ * Provides better type inference for each migration step.
57
+ */
58
+ export class MigrationBuilder<TCurrent> {
59
+ private migrations: Migration<unknown, unknown>[] = [];
60
+
61
+ /**
62
+ * Add a migration to the chain.
63
+ * Returns a new builder with updated type for the next migration.
64
+ */
65
+ addMigration<TNext>(
66
+ migration: Migration<TCurrent, TNext>
67
+ ): MigrationBuilder<TNext> {
68
+ this.migrations.push(migration);
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ return this as any as MigrationBuilder<TNext>;
71
+ }
72
+
73
+ /**
74
+ * Build the final migration chain.
75
+ */
76
+ build(): Migration<unknown, unknown>[] {
77
+ return this.migrations;
78
+ }
79
+ }
80
+
81
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
82
+ // Parse Result Types
83
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
84
+
85
+ export type ParseSuccess<T> = { success: true; data: T };
86
+ export type ParseError = { success: false; error: Error };
87
+ export type ParseResult<T> = ParseSuccess<T> | ParseError;
88
+
89
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
90
+ // Versioned<T> Class
91
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
92
+
93
+ /**
94
+ * Options for creating a Versioned instance.
95
+ */
96
+ export interface VersionedOptions<T> {
97
+ /** Current schema version */
98
+ version: number;
99
+ /** Zod schema for validation */
100
+ schema: z.ZodType<T>;
101
+ /** Optional migrations for backward compatibility */
102
+ migrations?: Migration<unknown, unknown>[];
103
+ }
104
+
105
+ /**
106
+ * Unified versioned schema handler.
107
+ * Combines type definition, validation, and migration in one API.
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * const configType = new Versioned({
112
+ * version: 2,
113
+ * schema: configSchemaV2,
114
+ * migrations: [v1ToV2Migration],
115
+ * });
116
+ *
117
+ * // Parse stored data (auto-migrates and validates)
118
+ * const config = await configType.parse(storedRecord);
119
+ *
120
+ * // Create new versioned data
121
+ * const record = configType.create({ url: "...", method: "GET" });
122
+ * ```
123
+ */
124
+ export class Versioned<T> {
125
+ readonly version: number;
126
+ readonly schema: z.ZodType<T>;
127
+ private readonly _migrations: Migration<unknown, unknown>[];
128
+
129
+ constructor(options: VersionedOptions<T>) {
130
+ this.version = options.version;
131
+ this.schema = options.schema;
132
+ this._migrations = options.migrations ?? [];
133
+ }
134
+
135
+ /**
136
+ * Get the migrations chain for this versioned schema.
137
+ * Useful for ConfigService integration.
138
+ */
139
+ get migrations(): Migration<unknown, unknown>[] {
140
+ return this._migrations;
141
+ }
142
+
143
+ // ─────────────────────────────────────────────────────────────────────────
144
+ // Data Parsing (load from storage)
145
+ // ─────────────────────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Parse and migrate stored data to current version.
149
+ * Returns just the data payload.
150
+ * @throws ZodError on validation failure
151
+ * @throws Error on migration failure
152
+ */
153
+ async parse(input: VersionedRecord<unknown>): Promise<T> {
154
+ const migrated = await this.migrateToVersion(input);
155
+ return this.schema.parse(migrated.data);
156
+ }
157
+
158
+ /**
159
+ * Safe parse - returns result object instead of throwing.
160
+ */
161
+ async safeParse(input: VersionedRecord<unknown>): Promise<ParseResult<T>> {
162
+ try {
163
+ const data = await this.parse(input);
164
+ return { success: true, data };
165
+ } catch (error) {
166
+ return { success: false, error: error as Error };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Parse and return full VersionedRecord wrapper (preserves metadata).
172
+ */
173
+ async parseRecord(
174
+ input: VersionedRecord<unknown>
175
+ ): Promise<VersionedRecord<T>> {
176
+ const migrated = await this.migrateToVersion(input);
177
+ const validated = this.schema.parse(migrated.data);
178
+ return { ...migrated, data: validated };
179
+ }
180
+
181
+ // ─────────────────────────────────────────────────────────────────────────
182
+ // Data Creation (wrap new data)
183
+ // ─────────────────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Create a new VersionedRecord wrapper for fresh data.
187
+ * Validates data against schema.
188
+ */
189
+ create(data: T): VersionedRecord<T> {
190
+ return {
191
+ version: this.version,
192
+ data: this.schema.parse(data),
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Create with plugin context for plugin-wide configs.
198
+ */
199
+ createForPlugin(data: T, pluginId: string): VersionedPluginRecord<T> {
200
+ return {
201
+ version: this.version,
202
+ data: this.schema.parse(data),
203
+ pluginId,
204
+ };
205
+ }
206
+
207
+ // ─────────────────────────────────────────────────────────────────────────
208
+ // Utilities
209
+ // ─────────────────────────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Check if data needs migration to reach current version.
213
+ */
214
+ needsMigration(input: VersionedRecord<unknown>): boolean {
215
+ return input.version !== this.version;
216
+ }
217
+
218
+ /**
219
+ * Validate data without migration (schema.parse wrapper).
220
+ * For data already at current version.
221
+ */
222
+ validate(data: unknown): T {
223
+ return this.schema.parse(data);
224
+ }
225
+
226
+ /**
227
+ * Safe validate - returns result object instead of throwing.
228
+ */
229
+ safeValidate(data: unknown): ReturnType<z.ZodType<T>["safeParse"]> {
230
+ return this.schema.safeParse(data);
231
+ }
232
+
233
+ // ─────────────────────────────────────────────────────────────────────────
234
+ // Internal migration logic
235
+ // ─────────────────────────────────────────────────────────────────────────
236
+
237
+ private async migrateToVersion(
238
+ input: VersionedRecord<unknown>
239
+ ): Promise<VersionedRecord<unknown>> {
240
+ if (input.version === this.version) {
241
+ return input;
242
+ }
243
+
244
+ // Validate migration chain
245
+ this.validateMigrationChain(input.version);
246
+
247
+ // Sort and filter applicable migrations
248
+ const applicable = this.migrations
249
+ .filter(
250
+ (m) => m.fromVersion >= input.version && m.toVersion <= this.version
251
+ )
252
+ .toSorted((a, b) => a.fromVersion - b.fromVersion);
253
+
254
+ // Run migrations sequentially
255
+ let currentData = input.data;
256
+ let currentVersion = input.version;
257
+ const originalVersion = input.originalVersion ?? input.version;
258
+
259
+ for (const migration of applicable) {
260
+ try {
261
+ currentData = await migration.migrate(currentData);
262
+ currentVersion = migration.toVersion;
263
+ } catch (error) {
264
+ throw new Error(
265
+ `Migration from v${migration.fromVersion} to v${migration.toVersion} failed: ${error}`
266
+ );
267
+ }
268
+ }
269
+
270
+ return {
271
+ version: currentVersion,
272
+ data: currentData,
273
+ migratedAt: new Date(),
274
+ originalVersion,
275
+ };
276
+ }
277
+
278
+ private validateMigrationChain(fromVersion: number): void {
279
+ const sorted = this.migrations.toSorted(
280
+ (a, b) => a.fromVersion - b.fromVersion
281
+ );
282
+
283
+ let expectedVersion = fromVersion;
284
+ for (const migration of sorted) {
285
+ if (migration.fromVersion < fromVersion) continue;
286
+ if (migration.toVersion > this.version) break;
287
+
288
+ if (migration.fromVersion !== expectedVersion) {
289
+ throw new Error(
290
+ `Migration chain broken: expected migration from version ${expectedVersion}, ` +
291
+ `but found migration from version ${migration.fromVersion}`
292
+ );
293
+ }
294
+ if (migration.toVersion !== migration.fromVersion + 1) {
295
+ throw new Error(
296
+ `Migration must increment version by 1: migration from ${migration.fromVersion} ` +
297
+ `to ${migration.toVersion} is invalid`
298
+ );
299
+ }
300
+ expectedVersion = migration.toVersion;
301
+ }
302
+
303
+ if (expectedVersion !== this.version) {
304
+ throw new Error(
305
+ `Migration chain incomplete: reaches version ${expectedVersion}, ` +
306
+ `but target version is ${this.version}`
307
+ );
308
+ }
309
+ }
310
+ }
@@ -0,0 +1,8 @@
1
+ // Helper to ensure backend routers implement their RPC contracts
2
+ // This provides compile-time type checking to prevent contract drift
3
+
4
+ export function validateRouter<TContract>() {
5
+ return <TRouter extends TContract>(router: TRouter): TRouter => {
6
+ return router;
7
+ };
8
+ }
@@ -0,0 +1,45 @@
1
+ import { createServiceRef } from "./service-ref";
2
+ import type { RpcService } from "./rpc";
3
+ import type { HealthCheckRegistry } from "./health-check";
4
+ import type {
5
+ QueuePluginRegistry,
6
+ QueueManager,
7
+ } from "@checkstack/queue-api";
8
+ import type { ConfigService } from "./config-service";
9
+ import type { SignalService } from "@checkstack/signal-common";
10
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
11
+ import {
12
+ Logger,
13
+ Fetch,
14
+ AuthService,
15
+ PluginInstaller,
16
+ RpcClient,
17
+ } from "./types";
18
+ import type { EventBus } from "./event-bus-types";
19
+
20
+ export * from "./types";
21
+
22
+ export const authenticationStrategyServiceRef = createServiceRef<unknown>(
23
+ "internal.authenticationStrategy"
24
+ );
25
+
26
+ export const coreServices = {
27
+ database:
28
+ createServiceRef<NodePgDatabase<Record<string, unknown>>>("core.database"),
29
+ logger: createServiceRef<Logger>("core.logger"),
30
+ fetch: createServiceRef<Fetch>("core.fetch"),
31
+ auth: createServiceRef<AuthService>("core.auth"),
32
+ healthCheckRegistry: createServiceRef<HealthCheckRegistry>(
33
+ "core.healthCheckRegistry"
34
+ ),
35
+ pluginInstaller: createServiceRef<PluginInstaller>("core.pluginInstaller"),
36
+ rpc: createServiceRef<RpcService>("core.rpc"),
37
+ rpcClient: createServiceRef<RpcClient>("core.rpcClient"),
38
+ queuePluginRegistry: createServiceRef<QueuePluginRegistry>(
39
+ "core.queuePluginRegistry"
40
+ ),
41
+ queueManager: createServiceRef<QueueManager>("core.queueManager"),
42
+ config: createServiceRef<ConfigService>("core.config"),
43
+ eventBus: createServiceRef<EventBus>("core.eventBus"),
44
+ signalService: createServiceRef<SignalService>("core.signalService"),
45
+ };
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Email layout utilities for notification strategies.
3
+ *
4
+ * Provides a responsive HTML email template that strategies can use
5
+ * to wrap their content with consistent branding.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ /**
11
+ * Options for the email layout wrapper.
12
+ */
13
+ export interface EmailLayoutOptions {
14
+ /** Email subject/title */
15
+ title: string;
16
+ /** Already-converted HTML body content */
17
+ bodyHtml: string;
18
+ /** Importance level affects header/button colors */
19
+ importance: "info" | "warning" | "critical";
20
+ /** Optional call-to-action button */
21
+ action?: {
22
+ label: string;
23
+ url: string;
24
+ };
25
+
26
+ // Admin-customizable options (via layoutConfig)
27
+ /** Logo URL (max ~200px wide recommended) */
28
+ logoUrl?: string;
29
+ /** Primary brand color (hex, e.g., "#3b82f6") */
30
+ primaryColor?: string;
31
+ /** Accent color for secondary elements */
32
+ accentColor?: string;
33
+ /** Footer text */
34
+ footerText?: string;
35
+ /** Footer links (e.g., Privacy Policy, Unsubscribe) */
36
+ footerLinks?: Array<{ label: string; url: string }>;
37
+ }
38
+
39
+ // Default importance-based colors
40
+ const IMPORTANCE_COLORS = {
41
+ info: "#3b82f6", // blue
42
+ warning: "#f59e0b", // amber
43
+ critical: "#ef4444", // red
44
+ } as const;
45
+
46
+ /**
47
+ * Simple HTML escaping for security.
48
+ */
49
+ function escapeHtml(text: string): string {
50
+ return text
51
+ .replaceAll("&", "&amp;")
52
+ .replaceAll("<", "&lt;")
53
+ .replaceAll(">", "&gt;")
54
+ .replaceAll('"', "&quot;")
55
+ .replaceAll("'", "&#039;");
56
+ }
57
+
58
+ /**
59
+ * Wrap HTML content in a responsive email template.
60
+ *
61
+ * This template is designed to render consistently across major
62
+ * email clients (Gmail, Outlook, Apple Mail, etc.).
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * const html = wrapInEmailLayout({
67
+ * title: "Password Reset",
68
+ * bodyHtml: "<p>Click the button below to reset your password.</p>",
69
+ * importance: "warning",
70
+ * action: { label: "Reset Password", url: "https://..." },
71
+ * primaryColor: "#10b981",
72
+ * footerText: "Sent by Acme Corp",
73
+ * });
74
+ * ```
75
+ */
76
+ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
77
+ const {
78
+ title,
79
+ bodyHtml,
80
+ importance,
81
+ action,
82
+ logoUrl,
83
+ primaryColor,
84
+ footerText = "This is an automated notification.",
85
+ footerLinks = [],
86
+ } = options;
87
+
88
+ // Use custom color or importance-based default
89
+ const headerColor = primaryColor ?? IMPORTANCE_COLORS[importance];
90
+ const buttonColor = options.accentColor ?? headerColor;
91
+
92
+ // Build footer links HTML
93
+ const footerLinksHtml =
94
+ footerLinks.length > 0
95
+ ? footerLinks
96
+ .map(
97
+ (link) =>
98
+ `<a href="${escapeHtml(
99
+ link.url
100
+ )}" style="color: #6b7280; text-decoration: underline;">${escapeHtml(
101
+ link.label
102
+ )}</a>`
103
+ )
104
+ .join(" · ")
105
+ : "";
106
+
107
+ return `
108
+ <!DOCTYPE html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="utf-8">
112
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
113
+ <meta name="color-scheme" content="light">
114
+ <meta name="supported-color-schemes" content="light">
115
+ <title>${escapeHtml(title)}</title>
116
+ <!--[if mso]>
117
+ <noscript>
118
+ <xml>
119
+ <o:OfficeDocumentSettings>
120
+ <o:PixelsPerInch>96</o:PixelsPerInch>
121
+ </o:OfficeDocumentSettings>
122
+ </xml>
123
+ </noscript>
124
+ <![endif]-->
125
+ <style>
126
+ /* Reset styles */
127
+ body, table, td, p, a, li, blockquote {
128
+ -webkit-text-size-adjust: 100%;
129
+ -ms-text-size-adjust: 100%;
130
+ }
131
+ table, td {
132
+ mso-table-lspace: 0pt;
133
+ mso-table-rspace: 0pt;
134
+ }
135
+ img {
136
+ -ms-interpolation-mode: bicubic;
137
+ border: 0;
138
+ height: auto;
139
+ line-height: 100%;
140
+ outline: none;
141
+ text-decoration: none;
142
+ }
143
+ body {
144
+ margin: 0 !important;
145
+ padding: 0 !important;
146
+ width: 100% !important;
147
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
148
+ background-color: #f4f4f5;
149
+ }
150
+ /* Link styling */
151
+ a {
152
+ color: ${buttonColor};
153
+ }
154
+ /* Button styling */
155
+ .button {
156
+ display: inline-block;
157
+ padding: 12px 24px;
158
+ background-color: ${buttonColor};
159
+ color: #ffffff !important;
160
+ text-decoration: none;
161
+ border-radius: 6px;
162
+ font-weight: 600;
163
+ font-size: 14px;
164
+ line-height: 1.5;
165
+ }
166
+ </style>
167
+ </head>
168
+ <body style="margin: 0; padding: 0; background-color: #f4f4f5;">
169
+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f4f4f5;">
170
+ <tr>
171
+ <td align="center" style="padding: 24px 16px;">
172
+ <!-- Main container -->
173
+ <table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
174
+ ${
175
+ logoUrl
176
+ ? `
177
+ <!-- Logo -->
178
+ <tr>
179
+ <td align="center" style="padding: 24px 24px 0 24px;">
180
+ <img src="${escapeHtml(
181
+ logoUrl
182
+ )}" alt="Logo" style="max-width: 200px; height: auto;">
183
+ </td>
184
+ </tr>
185
+ `
186
+ : ""
187
+ }
188
+ <!-- Header -->
189
+ <tr>
190
+ <td style="background-color: ${headerColor}; padding: 20px 24px;">
191
+ <h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600; line-height: 1.4;">
192
+ ${escapeHtml(title)}
193
+ </h1>
194
+ </td>
195
+ </tr>
196
+ <!-- Body -->
197
+ <tr>
198
+ <td style="padding: 24px;">
199
+ <div style="color: #374151; font-size: 16px; line-height: 1.6;">
200
+ ${bodyHtml}
201
+ </div>
202
+ ${
203
+ action
204
+ ? `
205
+ <!-- CTA Button -->
206
+ <table role="presentation" cellpadding="0" cellspacing="0" style="margin-top: 24px;">
207
+ <tr>
208
+ <td>
209
+ <a href="${escapeHtml(
210
+ action.url
211
+ )}" class="button" style="display: inline-block; padding: 12px 24px; background-color: ${buttonColor}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">
212
+ ${escapeHtml(action.label)}
213
+ </a>
214
+ </td>
215
+ </tr>
216
+ </table>
217
+ `
218
+ : ""
219
+ }
220
+ </td>
221
+ </tr>
222
+ <!-- Footer -->
223
+ <tr>
224
+ <td style="background-color: #f9fafb; padding: 16px 24px; border-top: 1px solid #e5e7eb;">
225
+ <p style="margin: 0; color: #6b7280; font-size: 12px; line-height: 1.5; text-align: center;">
226
+ ${escapeHtml(footerText)}
227
+ </p>
228
+ ${
229
+ footerLinksHtml
230
+ ? `
231
+ <p style="margin: 8px 0 0 0; color: #6b7280; font-size: 12px; line-height: 1.5; text-align: center;">
232
+ ${footerLinksHtml}
233
+ </p>
234
+ `
235
+ : ""
236
+ }
237
+ </td>
238
+ </tr>
239
+ </table>
240
+ </td>
241
+ </tr>
242
+ </table>
243
+ </body>
244
+ </html>
245
+ `.trim();
246
+ }