@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 +1 -1
- package/src/image.ts +6 -13
- package/src/index.ts +15 -0
- package/src/migrate.ts +79 -3
- package/src/plugin.ts +3 -0
- package/src/timing.ts +39 -0
package/package.json
CHANGED
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
+
}
|