@everystack/server 0.2.16 → 0.2.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/image.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  APIGatewayProxyStructuredResultV2,
16
16
  } from 'aws-lambda';
17
17
  import { log } from './log';
18
+ import { buildTimingHeaders } from './timing';
18
19
 
19
20
  export interface S3CacheConfig {
20
21
  /** Enable S3 origin cache for processed images. Default: false. */
@@ -232,19 +233,11 @@ export function createImageHandler(
232
233
  totalMs: number;
233
234
  }): Record<string, string> {
234
235
  if (!observability) return {};
235
- const headers: Record<string, string> = { 'x-image-ms': String(opts.totalMs) };
236
- if (opts.cache) headers['x-image-cache'] = opts.cache;
237
- if (opts.fetchMs !== undefined) headers['x-fetch-ms'] = String(opts.fetchMs);
238
- if (opts.renderMs !== undefined) headers['x-render-ms'] = String(opts.renderMs);
239
-
240
- // Server-Timing mirror — nice for browser devtools.
241
- const timings: string[] = [];
242
- if (opts.cache) timings.push(`cache;desc=${opts.cache}`);
243
- if (opts.fetchMs !== undefined) timings.push(`s3;dur=${opts.fetchMs}`);
244
- if (opts.renderMs !== undefined) timings.push(`render;dur=${opts.renderMs}`);
245
- timings.push(`total;dur=${opts.totalMs}`);
246
- headers['Server-Timing'] = timings.join(', ');
247
- return headers;
236
+ return buildTimingHeaders('image', {
237
+ cache: opts.cache,
238
+ totalMs: opts.totalMs,
239
+ segments: { fetch: opts.fetchMs, render: opts.renderMs },
240
+ });
248
241
  }
249
242
 
250
243
  return async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyStructuredResultV2> => {
package/src/index.ts CHANGED
@@ -16,6 +16,10 @@ import type {
16
16
  } from 'aws-lambda';
17
17
 
18
18
  import { log } from './log';
19
+ import { buildTimingHeaders } from './timing';
20
+
21
+ export { buildTimingHeaders } from './timing';
22
+ export type { TimingHeaderOptions } from './timing';
19
23
 
20
24
  // --- Types ---
21
25
 
@@ -80,6 +84,8 @@ export interface LambdaHandlerOptions {
80
84
  /** Optional log sink — writes structured request logs to S3 or other storage.
81
85
  * Non-blocking: if the sink fails, logs still go to CloudWatch via stdout. */
82
86
  logSink?: LogSink;
87
+ /** Emit Server-Timing headers on responses. Default true. */
88
+ observability?: boolean;
83
89
  }
84
90
 
85
91
  // --- Event Adapter ---
@@ -224,10 +230,14 @@ export function createLambdaHandler(
224
230
  }
225
231
  // HTTP event — Function URL or API Gateway
226
232
  const httpEvent = event as APIGatewayProxyEventV2;
233
+ // Prefer the CloudFront edge request id: it also lands in the CDN access
234
+ // logs (x-edge-request-id), so app logs join to platform_requests rows.
227
235
  const requestId =
236
+ httpEvent.headers?.['x-amz-cf-id'] ||
228
237
  httpEvent.headers?.['x-amzn-trace-id'] ||
229
238
  httpEvent.headers?.['x-request-id'] ||
230
239
  crypto.randomUUID();
240
+ const t0 = Date.now();
231
241
 
232
242
  try {
233
243
  const { router } = await ensureInitialized();
@@ -241,6 +251,11 @@ export function createLambdaHandler(
241
251
  // Propagate request ID
242
252
  result.headers = { ...result.headers, 'x-request-id': requestId };
243
253
 
254
+ // Timing observability — additive, mirrors the image handler's headers
255
+ if (options.observability !== false && !result.headers['Server-Timing']) {
256
+ Object.assign(result.headers, buildTimingHeaders('server', { totalMs: Date.now() - t0 }));
257
+ }
258
+
244
259
  // Set Cache-Control if not already set by the handler
245
260
  if (!result.headers['cache-control']) {
246
261
  if (method === 'GET' || method === 'HEAD') {
package/src/migrate.ts CHANGED
@@ -1,16 +1,92 @@
1
1
  /**
2
- * @everystack/server/migrate — Drizzle migration runner.
2
+ * @everystack/server/migrate — Drizzle migration runner that cannot lie.
3
+ *
4
+ * drizzle's migrate() only runs what meta/_journal.json lists, and reports
5
+ * nothing either way. A SQL file missing from the journal silently never
6
+ * applies — a failure mode that is miserable to debug. This wrapper:
7
+ *
8
+ * - warns about migration files with no journal entry (the silent skip)
9
+ * - fails fast, by name, on journal entries with no SQL file
10
+ * - reports exactly which migrations applied this run
3
11
  *
4
12
  * Called directly from onAction handler (CLI → IAM → Lambda invoke).
5
13
  * No HTTP, no auth — IAM is the authorization layer.
6
14
  */
7
15
 
16
+ import { readdirSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { sql } from 'drizzle-orm';
8
19
  import { migrate } from 'drizzle-orm/postgres-js/migrator';
9
20
 
21
+ export interface MigrationResult {
22
+ success: boolean;
23
+ message: string;
24
+ /** Journal tags applied in this run. */
25
+ applied: string[];
26
+ /** Drift warnings — surface these to the operator, always. */
27
+ warnings: string[];
28
+ }
29
+
30
+ interface JournalEntry {
31
+ tag: string;
32
+ when: number;
33
+ }
34
+
35
+ function extractRows(result: unknown): Array<Record<string, unknown>> {
36
+ if (Array.isArray(result)) return result as Array<Record<string, unknown>>;
37
+ return ((result as { rows?: unknown[] })?.rows ?? []) as Array<Record<string, unknown>>;
38
+ }
39
+
40
+ /** Highest created_at in drizzle's ledger, or -1 before the first run. */
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ async function lastAppliedWhen(db: any): Promise<number> {
43
+ try {
44
+ const result = await db.execute(
45
+ sql`SELECT max(created_at)::bigint AS max FROM drizzle.__drizzle_migrations`,
46
+ );
47
+ const max = extractRows(result)[0]?.max;
48
+ return max === null || max === undefined ? -1 : Number(max);
49
+ } catch {
50
+ return -1; // ledger table doesn't exist yet — nothing applied
51
+ }
52
+ }
53
+
10
54
  export async function runMigrations(
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
56
  db: any,
12
57
  migrationsFolder: string
13
- ): Promise<{ success: boolean; message: string }> {
58
+ ): Promise<MigrationResult> {
59
+ const journal = JSON.parse(
60
+ readFileSync(join(migrationsFolder, 'meta/_journal.json'), 'utf8'),
61
+ ) as { entries: JournalEntry[] };
62
+ const entries = journal.entries ?? [];
63
+ const files = readdirSync(migrationsFolder).filter((f) => /^\d{4}_.+\.sql$/.test(f));
64
+ const tags = new Set(entries.map((e) => e.tag));
65
+
66
+ // Journal entry without a file: drizzle would die with an opaque ENOENT.
67
+ const ghosts = entries.filter((e) => !files.includes(`${e.tag}.sql`)).map((e) => e.tag);
68
+ if (ghosts.length > 0) {
69
+ throw new Error(
70
+ `Journal entries with no SQL file (broken journal): ${ghosts.join(', ')}`,
71
+ );
72
+ }
73
+
74
+ // File without a journal entry: drizzle will silently NEVER apply it.
75
+ const warnings = files
76
+ .filter((f) => !tags.has(f.replace(/\.sql$/, '')))
77
+ .map((f) => `${f} has no entry in meta/_journal.json — drizzle will NEVER apply it`);
78
+
79
+ const before = await lastAppliedWhen(db);
14
80
  await migrate(db, { migrationsFolder });
15
- return { success: true, message: 'Migrations applied' };
81
+
82
+ const applied = entries
83
+ .filter((e) => e.when > before)
84
+ .map((e) => e.tag);
85
+
86
+ const message =
87
+ applied.length > 0
88
+ ? `Applied ${applied.length} migration(s): ${applied.join(', ')}`
89
+ : 'Already up to date — no new migrations';
90
+
91
+ return { success: true, message, applied, warnings };
16
92
  }
package/src/plugin.ts CHANGED
@@ -216,7 +216,10 @@ export function createPluginLambdaHandler(
216
216
 
217
217
  // HTTP event — Function URL or API Gateway
218
218
  const httpEvent = event as APIGatewayProxyEventV2;
219
+ // Prefer the CloudFront edge request id: it also lands in the CDN access
220
+ // logs (x-edge-request-id), so app logs join to platform_requests rows.
219
221
  const requestId =
222
+ httpEvent.headers?.['x-amz-cf-id'] ||
220
223
  httpEvent.headers?.['x-amzn-trace-id'] ||
221
224
  httpEvent.headers?.['x-request-id'] ||
222
225
  crypto.randomUUID();
package/src/timing.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cache-state + timing observability headers.
3
+ *
4
+ * One builder for every handler (image, SSR, API): prefixed x-<name>-ms
5
+ * headers for machines, a Server-Timing mirror for browser devtools.
6
+ * Additive only — callers gate on their own observability flag and spread
7
+ * the result into response headers.
8
+ */
9
+
10
+ export interface TimingHeaderOptions {
11
+ /** Cache state, e.g. 'hit' | 'render'. Emitted as x-<prefix>-cache. */
12
+ cache?: string;
13
+ /** Total handler wall time. Emitted as x-<prefix>-ms. */
14
+ totalMs: number;
15
+ /** Named sub-timings, each emitted as x-<name>-ms. Undefined values skip. */
16
+ segments?: Record<string, number | undefined>;
17
+ }
18
+
19
+ export function buildTimingHeaders(
20
+ prefix: string,
21
+ options: TimingHeaderOptions,
22
+ ): Record<string, string> {
23
+ const headers: Record<string, string> = {
24
+ [`x-${prefix}-ms`]: String(options.totalMs),
25
+ };
26
+ if (options.cache) headers[`x-${prefix}-cache`] = options.cache;
27
+
28
+ const timings: string[] = [];
29
+ if (options.cache) timings.push(`cache;desc=${options.cache}`);
30
+ for (const [name, ms] of Object.entries(options.segments ?? {})) {
31
+ if (ms === undefined) continue;
32
+ headers[`x-${name}-ms`] = String(ms);
33
+ timings.push(`${name};dur=${ms}`);
34
+ }
35
+ timings.push(`total;dur=${options.totalMs}`);
36
+ headers['Server-Timing'] = timings.join(', ');
37
+
38
+ return headers;
39
+ }