@everystack/cli 0.2.34 → 0.2.36

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/cli",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/cli/aws.ts CHANGED
@@ -141,6 +141,48 @@ export async function deleteS3(
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * Resolve a live Lambda function name, healing a stale hash suffix.
146
+ *
147
+ * SST names functions `{app}-{stage}-{Resource}{Random}-{hash}`; a redeploy can
148
+ * rotate the trailing `-{hash}` while the base prefix stays stable. Given a name
149
+ * that may be stale, returns the live function matching the base prefix (most
150
+ * recently modified), or the original name if no better match is found.
151
+ *
152
+ * Symmetric with resolveLogGroup — needs only lambda:ListFunctions.
153
+ */
154
+ export async function resolveFunctionName(
155
+ region: string,
156
+ functionName: string,
157
+ ): Promise<string> {
158
+ const lastDash = functionName.lastIndexOf('-');
159
+ if (lastDash <= 0) return functionName;
160
+ // Include the trailing dash so the prefix can't match a longer resource name.
161
+ const basePrefix = functionName.slice(0, lastDash + 1).toLowerCase();
162
+
163
+ const { LambdaClient, ListFunctionsCommand } = await import('@aws-sdk/client-lambda');
164
+ const client = new LambdaClient({ region });
165
+
166
+ const candidates: Array<{ name: string; modified: number }> = [];
167
+ let marker: string | undefined;
168
+ do {
169
+ const res = await client.send(new ListFunctionsCommand({ Marker: marker, MaxItems: 50 }));
170
+ for (const fn of res.Functions || []) {
171
+ if (fn.FunctionName && fn.FunctionName.toLowerCase().startsWith(basePrefix)) {
172
+ candidates.push({
173
+ name: fn.FunctionName,
174
+ modified: fn.LastModified ? Date.parse(fn.LastModified) : 0,
175
+ });
176
+ }
177
+ }
178
+ marker = res.NextMarker;
179
+ } while (marker);
180
+
181
+ if (candidates.length === 0) return functionName;
182
+ candidates.sort((a, b) => b.modified - a.modified);
183
+ return candidates[0].name;
184
+ }
185
+
144
186
  export async function invokeAction(
145
187
  region: string,
146
188
  functionName: string,
@@ -149,13 +191,35 @@ export async function invokeAction(
149
191
  ): Promise<unknown> {
150
192
  const client = await getLambda(region);
151
193
  const { InvokeCommand } = await import('@aws-sdk/client-lambda');
152
- const response = await client.send(new InvokeCommand({
153
- FunctionName: functionName,
154
- Payload: new TextEncoder().encode(JSON.stringify({
155
- _action: action,
156
- _payload: payload,
157
- })),
194
+
195
+ const encoded = new TextEncoder().encode(JSON.stringify({
196
+ _action: action,
197
+ _payload: payload,
158
198
  }));
199
+ const invoke = (fnName: string) =>
200
+ client.send(new InvokeCommand({ FunctionName: fnName, Payload: encoded }));
201
+
202
+ let response;
203
+ try {
204
+ response = await invoke(functionName);
205
+ } catch (err: unknown) {
206
+ // Stale function hash (e.g. the discovery cache outlived a redeploy that
207
+ // rotated the Lambda name). Re-resolve the live function and retry once,
208
+ // then bust the stale cache so the next command discovers fresh.
209
+ if (err && typeof err === 'object' && (err as { name?: string }).name === 'ResourceNotFoundException') {
210
+ const live = await resolveFunctionName(region, functionName);
211
+ if (live === functionName) throw err;
212
+ try {
213
+ const { invalidateCacheByFunctionName } = await import('./discover.js');
214
+ await invalidateCacheByFunctionName(functionName);
215
+ } catch {
216
+ // Cache invalidation is best-effort
217
+ }
218
+ response = await invoke(live);
219
+ } else {
220
+ throw err;
221
+ }
222
+ }
159
223
 
160
224
  if (response.FunctionError) {
161
225
  const errorBody = response.Payload
@@ -1,15 +1,37 @@
1
1
  /**
2
- * Logs commands — query application logs and tail CloudWatch logs.
2
+ * Logs commands — query the observability log archive and tail CloudWatch logs.
3
3
  *
4
- * These commands help debug production issues by accessing:
5
- * - Application logs stored in the database (logs:errors)
6
- * - Lambda container logs from CloudWatch (logs:tail)
4
+ * All three commands read their store DIRECTLY no API Lambda invoke:
5
+ * - logs:errors / logs:query S3 NDJSON event archive (s3:ListBucket + s3:GetObject)
6
+ * - logs:tail → Lambda container logs from CloudWatch
7
+ *
8
+ * Reading the store directly (rather than invoking the function being diagnosed)
9
+ * means a read-only observability role works uniformly, and the commands are
10
+ * immune to the Lambda hash rotating on redeploy.
7
11
  */
8
12
 
9
13
  import { resolveConfig } from '../config.js';
10
- import { invokeAction, resolveLogGroup } from '../aws.js';
14
+ import { resolveLogGroup } from '../aws.js';
15
+ import { queryLogArchive, type ObservabilityQuery } from '../observability.js';
11
16
  import { step, success, fail, info } from '../output.js';
12
17
 
18
+ /** Default S3 key prefix for the observability archive (loggingPlugin default). */
19
+ const DEFAULT_PREFIX = '_observability';
20
+
21
+ /** Parse a `5m` / `1h` / `2d` duration into milliseconds; 0 if unparseable. */
22
+ function parseDurationMs(duration: string): number {
23
+ const match = duration.match(/^(\d+)([smhd])$/);
24
+ if (!match) return 0;
25
+ const value = parseInt(match[1], 10);
26
+ switch (match[2]) {
27
+ case 's': return value * 1000;
28
+ case 'm': return value * 60 * 1000;
29
+ case 'h': return value * 60 * 60 * 1000;
30
+ case 'd': return value * 24 * 60 * 60 * 1000;
31
+ default: return 0;
32
+ }
33
+ }
34
+
13
35
  export async function logsErrorsCommand(flags: Record<string, string>): Promise<void> {
14
36
  step('Resolving deployed config...');
15
37
  let config;
@@ -20,50 +42,40 @@ export async function logsErrorsCommand(flags: Record<string, string>): Promise<
20
42
  process.exit(1);
21
43
  }
22
44
 
23
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
45
+ const prefix = flags.prefix || DEFAULT_PREFIX;
46
+ info(`Region: ${config.region}, Bucket: ${config.updatesBucket}/${prefix}`);
24
47
 
25
48
  const limit = flags.limit ? parseInt(flags.limit, 10) : 20;
26
49
  const source = flags.source;
27
- const level = flags.level || 'error'; // error or fatal
50
+ const level = flags.level || 'error'; // error (includes fatal) or fatal
51
+
52
+ const query: ObservabilityQuery = { level, source, limit };
53
+ if (flags.since) {
54
+ const sinceMs = parseDurationMs(flags.since);
55
+ if (sinceMs > 0) query.startTime = new Date(Date.now() - sinceMs);
56
+ }
28
57
 
29
58
  step(`Fetching last ${limit} ${level} logs...`);
30
59
 
31
60
  try {
32
- const result: any = await invokeAction(
33
- config.region,
34
- config.apiFunctionName,
35
- 'logs:errors',
36
- { limit, source, level },
37
- );
38
-
39
- if (result?.error) {
40
- fail(`Failed to fetch errors: ${result.error}`);
41
- process.exit(1);
42
- }
43
-
44
- const errors = result.errors || [];
61
+ const errors = await queryLogArchive(config.region, config.updatesBucket, prefix, query);
45
62
 
46
63
  if (errors.length === 0) {
47
64
  success('No errors found!');
48
65
  return;
49
66
  }
50
67
 
51
- const sourceLabel = result.source === 's3' ? ' (s3)' : '';
52
- success(`Found ${errors.length} ${level} log(s)${sourceLabel}:\n`);
68
+ success(`Found ${errors.length} ${level} log(s) (s3):\n`);
53
69
 
54
- // Format and display errors
55
70
  for (const error of errors) {
56
- console.log(`[${error.timestamp}] ${error.level.toUpperCase()}`);
71
+ const ts = new Date(error.timestamp).toISOString();
72
+ console.log(`[${ts}] ${(error.level || 'error').toUpperCase()}`);
57
73
  console.log(`Source: ${error.source || 'unknown'}`);
58
74
  console.log(`Message: ${error.message}`);
59
75
 
60
- if (error.traceId) {
61
- console.log(`TraceID: ${error.traceId}`);
62
- }
63
-
64
- if (error.userId) {
65
- console.log(`UserID: ${error.userId}`);
66
- }
76
+ const userId = error.userId ?? error.data?.userId;
77
+ if (error.traceId) console.log(`TraceID: ${error.traceId}`);
78
+ if (userId) console.log(`UserID: ${userId}`);
67
79
 
68
80
  if (error.error) {
69
81
  console.log(`Error: ${error.error.name}: ${error.error.message}`);
@@ -80,7 +92,7 @@ export async function logsErrorsCommand(flags: Record<string, string>): Promise<
80
92
  }
81
93
  } catch (err: any) {
82
94
  fail(`Failed to fetch errors: ${err.message}`);
83
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
95
+ info('Ensure your IAM user/role has s3:ListBucket + s3:GetObject on the updates bucket.');
84
96
  process.exit(1);
85
97
  }
86
98
  }
@@ -156,8 +168,8 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
156
168
  // Resolve actual log group from Lambda config (handles SST name changes)
157
169
  const logGroupName = await resolveLogGroup(config.region, functionName);
158
170
 
159
- // Parse since duration to milliseconds
160
- const sinceMs = parseDuration(since);
171
+ // Parse since duration to milliseconds (default 5m if unparseable)
172
+ const sinceMs = parseDurationMs(since) || 5 * 60 * 1000;
161
173
  const startTime = Date.now() - sinceMs;
162
174
 
163
175
  success(`Streaming logs from ${logGroupName}...\n`);
@@ -227,10 +239,11 @@ export async function logsQueryCommand(flags: Record<string, string>): Promise<v
227
239
  process.exit(1);
228
240
  }
229
241
 
230
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
242
+ const prefix = flags.prefix || DEFAULT_PREFIX;
243
+ info(`Region: ${config.region}, Bucket: ${config.updatesBucket}/${prefix}`);
231
244
 
232
245
  // Build query parameters
233
- const query: Record<string, any> = {
246
+ const query: ObservabilityQuery = {
234
247
  limit: flags.limit ? parseInt(flags.limit, 10) : 50,
235
248
  };
236
249
 
@@ -243,32 +256,22 @@ export async function logsQueryCommand(flags: Record<string, string>): Promise<v
243
256
  if (flags.platform) query.platform = flags.platform;
244
257
  if (flags.environment) query.environment = flags.environment;
245
258
  if (flags.search) query.search = flags.search;
246
- if (flags.since) query.since = flags.since;
259
+ if (flags.since) {
260
+ const sinceMs = parseDurationMs(flags.since);
261
+ if (sinceMs > 0) query.startTime = new Date(Date.now() - sinceMs);
262
+ }
247
263
 
248
- step(`Querying logs with filters: ${JSON.stringify(query)}...`);
264
+ step(`Querying logs with filters: ${JSON.stringify(flags)}...`);
249
265
 
250
266
  try {
251
- const result: any = await invokeAction(
252
- config.region,
253
- config.apiFunctionName,
254
- 'logs:query',
255
- query,
256
- );
257
-
258
- if (result?.error) {
259
- fail(`Failed to query logs: ${result.error}`);
260
- process.exit(1);
261
- }
262
-
263
- const logs = result.logs || [];
267
+ const logs = await queryLogArchive(config.region, config.updatesBucket, prefix, query);
264
268
 
265
269
  if (logs.length === 0) {
266
270
  success('No logs found matching the criteria.');
267
271
  return;
268
272
  }
269
273
 
270
- const sourceLabel = result.source === 's3' ? ' (s3)' : '';
271
- success(`Found ${logs.length} log(s)${sourceLabel}:\n`);
274
+ success(`Found ${logs.length} log(s) (s3):\n`);
272
275
 
273
276
  // Format and display logs
274
277
  for (const log of logs) {
@@ -278,17 +281,11 @@ export async function logsQueryCommand(flags: Record<string, string>): Promise<v
278
281
  console.log(`[${timestamp}] ${level} ${log.source || 'unknown'}`);
279
282
  console.log(` ${log.message || '(no message)'}`);
280
283
 
281
- if (log.traceId) {
282
- console.log(` TraceID: ${log.traceId}`);
283
- }
284
-
285
- if (log.userId) {
286
- console.log(` UserID: ${log.userId}`);
287
- }
288
-
289
- if (log.deviceId) {
290
- console.log(` DeviceID: ${log.deviceId}`);
291
- }
284
+ const userId = log.userId ?? log.data?.userId;
285
+ const deviceId = log.deviceId ?? log.data?.deviceId;
286
+ if (log.traceId) console.log(` TraceID: ${log.traceId}`);
287
+ if (userId) console.log(` UserID: ${userId}`);
288
+ if (deviceId) console.log(` DeviceID: ${deviceId}`);
292
289
 
293
290
  if (log.error) {
294
291
  console.log(` Error: ${log.error.name}: ${log.error.message}`);
@@ -302,25 +299,7 @@ export async function logsQueryCommand(flags: Record<string, string>): Promise<v
302
299
  }
303
300
  } catch (err: any) {
304
301
  fail(`Failed to query logs: ${err.message}`);
305
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
302
+ info('Ensure your IAM user/role has s3:ListBucket + s3:GetObject on the updates bucket.');
306
303
  process.exit(1);
307
304
  }
308
305
  }
309
-
310
- function parseDuration(duration: string): number {
311
- const match = duration.match(/^(\d+)([smhd])$/);
312
- if (!match) {
313
- return 5 * 60 * 1000; // Default: 5 minutes
314
- }
315
-
316
- const value = parseInt(match[1], 10);
317
- const unit = match[2];
318
-
319
- switch (unit) {
320
- case 's': return value * 1000;
321
- case 'm': return value * 60 * 1000;
322
- case 'h': return value * 60 * 60 * 1000;
323
- case 'd': return value * 24 * 60 * 60 * 1000;
324
- default: return 5 * 60 * 1000;
325
- }
326
- }
@@ -430,10 +430,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
430
430
  info('Add `mediaBucket: media.name` to the return block in sst.config.ts and redeploy.');
431
431
  } else {
432
432
  step('Syncing media assets...');
433
- // @ts-ignore minimatch lacks type declarations in this project
434
- const minimatchMod = await import('minimatch');
435
- const minimatch: (p: string, pattern: string, opts?: { matchBase?: boolean }) => boolean =
436
- minimatchMod.minimatch || minimatchMod.default || minimatchMod;
433
+ const { minimatch } = await import('minimatch');
437
434
  const MEDIA_CONCURRENCY = 10;
438
435
  let mediaUploaded = 0;
439
436
  let mediaSkipped = 0;
@@ -384,6 +384,45 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
384
384
  }
385
385
  }
386
386
 
387
+ /**
388
+ * Delete any per-stage config cache that pins `staleName` as a function name.
389
+ *
390
+ * Called after an invoke hits ResourceNotFoundException on a cached name — the
391
+ * cache outlived a redeploy that rotated the Lambda hash. Removing the file
392
+ * forces the next stage command to re-discover live. Best-effort; only touches
393
+ * `outputs.{stage}.json` caches, never the deploy-written `outputs.json`.
394
+ */
395
+ export async function invalidateCacheByFunctionName(staleName: string): Promise<void> {
396
+ const dir = path.resolve('.sst');
397
+ let entries: string[];
398
+ try {
399
+ entries = await fs.readdir(dir);
400
+ } catch {
401
+ return;
402
+ }
403
+
404
+ for (const entry of entries) {
405
+ if (!entry.startsWith('outputs.') || !entry.endsWith('.json') || entry === 'outputs.json') {
406
+ continue;
407
+ }
408
+ const filePath = path.join(dir, entry);
409
+ try {
410
+ const cached: CachedOutputs = JSON.parse(await fs.readFile(filePath, 'utf8'));
411
+ const names = [
412
+ cached.apiFunctionName,
413
+ cached.opsFunctionName,
414
+ cached.imageFunctionName,
415
+ cached.workerFunctionName,
416
+ ];
417
+ if (names.includes(staleName)) {
418
+ await fs.unlink(filePath);
419
+ }
420
+ } catch {
421
+ // Unreadable/non-matching cache — skip
422
+ }
423
+ }
424
+ }
425
+
387
426
  export async function setCachedConfig(stage: string, config: DiscoveredConfig): Promise<void> {
388
427
  const data: CachedOutputs = {
389
428
  _cachedAt: Date.now(),
package/src/cli/index.ts CHANGED
@@ -188,9 +188,9 @@ Usage:
188
188
  everystack diag [url] [--path /about] [--channel <name>] [--stage <name>] Diagnose page freshness
189
189
  everystack diag [url] --hydration Analyze SSR hydration issues (fetches full HTML)
190
190
  everystack analyze:ssr [--app ./app] Scan app code for SSR anti-patterns
191
- everystack logs:errors [--stage <name>] [--limit 20] [--level error|fatal] [--source <name>]
191
+ everystack logs:errors [--stage <name>] [--limit 20] [--level error|fatal] [--source <name>] [--since 24h] [--prefix _observability]
192
192
  everystack logs:tail [--stage <name>] [--origin api|media|worker] [--filter <pattern>] [--since 5m]
193
- everystack logs:query [--stage <name>] [--level <level>] [--source <name>] [--traceId <id>] [--search <text>]
193
+ everystack logs:query [--stage <name>] [--level <level>] [--source <name>] [--traceId <id>] [--search <text>] [--since 1h] [--prefix _observability]
194
194
  everystack branches list
195
195
  everystack branches create --name <name>
196
196
  everystack branches delete --name <name>
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Direct reader for the S3 observability log archive.
3
+ *
4
+ * Reads the NDJSON event archive written by @everystack/logging's `S3LogStorage`
5
+ * WITHOUT invoking the API Lambda — the read-only analog of how `logs:tail` reads
6
+ * CloudWatch directly. Needs only `s3:ListBucket` + `s3:GetObject` on the updates
7
+ * bucket, so an observability/read-only role can run `logs:errors` / `logs:query`.
8
+ *
9
+ * FORMAT CONTRACT — mirrors @everystack/logging `S3LogStorage`. Keep the two in sync:
10
+ * {prefix}/events/YYYY/MM/DD/HH/{ts}-{id}.ndjson (one JSON LogEvent per line)
11
+ * Hour partitions are UTC. Canonical writer + query semantics:
12
+ * packages/logging/src/service/s3-log-storage.ts
13
+ *
14
+ * We read the format rather than import the class because @everystack/logging
15
+ * pulls React Native (admin/ui) through its peer graph — wrong for a standalone CLI.
16
+ */
17
+
18
+ import { listS3, getFromS3 } from './aws.js';
19
+
20
+ /** Severity ordering — mirrors LogLevel in @everystack/logging core/types. */
21
+ const LOG_LEVELS: Record<string, number> = {
22
+ debug: 0,
23
+ info: 1,
24
+ warn: 2,
25
+ error: 3,
26
+ fatal: 4,
27
+ };
28
+
29
+ /** Minimal shape of an archived event — matches @everystack/logging LogEvent. */
30
+ export interface LogEventRecord {
31
+ id: string;
32
+ timestamp: number;
33
+ level: string;
34
+ message: string;
35
+ source?: string;
36
+ traceId?: string;
37
+ data?: Record<string, unknown>;
38
+ error?: { name: string; message: string; stack?: string };
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ export interface ObservabilityQuery {
43
+ level?: string;
44
+ source?: string;
45
+ traceId?: string;
46
+ userId?: string;
47
+ deviceId?: string;
48
+ sessionId?: string;
49
+ platform?: string;
50
+ environment?: string;
51
+ search?: string;
52
+ startTime?: Date;
53
+ endTime?: Date;
54
+ limit?: number;
55
+ offset?: number;
56
+ }
57
+
58
+ /** Default lookback when no startTime is given — matches S3LogStorage. */
59
+ const DEFAULT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
60
+
61
+ /**
62
+ * Max in-flight S3 requests. A busy hour partition can hold thousands of NDJSON
63
+ * files (S3LogStorage writes one per ingest flush); an unbounded `Promise.all`
64
+ * over all of them — run from a laptop, not in-Lambda — exhausts the SDK socket
65
+ * pool and trips throttling. 32 keeps the pipe full without stampeding.
66
+ */
67
+ const MAX_S3_CONCURRENCY = 32;
68
+
69
+ /**
70
+ * Async map with bounded concurrency. Preserves input order in the output.
71
+ * A small pool of workers drains a shared index — at most `limit` run at once.
72
+ */
73
+ async function mapLimit<T, R>(
74
+ items: T[],
75
+ limit: number,
76
+ fn: (item: T) => Promise<R>,
77
+ ): Promise<R[]> {
78
+ const results = new Array<R>(items.length);
79
+ let next = 0;
80
+ const worker = async (): Promise<void> => {
81
+ while (next < items.length) {
82
+ const i = next;
83
+ next += 1;
84
+ results[i] = await fn(items[i]);
85
+ }
86
+ };
87
+ const pool = Array.from({ length: Math.min(limit, items.length) }, () => worker());
88
+ await Promise.all(pool);
89
+ return results;
90
+ }
91
+
92
+ function padTwo(n: number): string {
93
+ return n.toString().padStart(2, '0');
94
+ }
95
+
96
+ function hourPath(date: Date): string {
97
+ return `${date.getUTCFullYear()}/${padTwo(date.getUTCMonth() + 1)}/${padTwo(
98
+ date.getUTCDate(),
99
+ )}/${padTwo(date.getUTCHours())}`;
100
+ }
101
+
102
+ /** All UTC hour partitions touched by [start, end], inclusive. */
103
+ function getHourPrefixes(start: Date, end: Date): string[] {
104
+ const prefixes: string[] = [];
105
+ const current = new Date(start);
106
+ current.setUTCMinutes(0, 0, 0);
107
+ while (current <= end) {
108
+ prefixes.push(hourPath(current));
109
+ current.setUTCHours(current.getUTCHours() + 1);
110
+ }
111
+ return prefixes;
112
+ }
113
+
114
+ function parseNdjson(data: string): LogEventRecord[] {
115
+ const records: LogEventRecord[] = [];
116
+ for (const line of data.split('\n')) {
117
+ if (line.trim().length === 0) continue;
118
+ try {
119
+ records.push(JSON.parse(line) as LogEventRecord);
120
+ } catch {
121
+ // Skip a truncated/partial line (e.g. an interrupted multipart write)
122
+ // rather than failing the whole query over one malformed record.
123
+ }
124
+ }
125
+ return records;
126
+ }
127
+
128
+ /** Read a `data`-nested or top-level field (the app nests userId/platform under data). */
129
+ function field(e: LogEventRecord, key: string): unknown {
130
+ if (e[key] !== undefined) return e[key];
131
+ return e.data?.[key];
132
+ }
133
+
134
+ function matches(e: LogEventRecord, q: ObservabilityQuery): boolean {
135
+ if (q.level) {
136
+ const threshold = LOG_LEVELS[q.level] ?? 0;
137
+ if ((LOG_LEVELS[e.level] ?? 0) < threshold) return false;
138
+ }
139
+ if (q.source && e.source !== q.source) return false;
140
+ if (q.traceId && e.traceId !== q.traceId) return false;
141
+ if (q.userId && String(field(e, 'userId') ?? '') !== String(q.userId)) return false;
142
+ if (q.deviceId && String(field(e, 'deviceId') ?? '') !== String(q.deviceId)) return false;
143
+ if (q.sessionId && String(field(e, 'sessionId') ?? '') !== String(q.sessionId)) return false;
144
+ if (q.platform && String(field(e, 'platform') ?? '') !== String(q.platform)) return false;
145
+ if (q.environment && String(field(e, 'environment') ?? '') !== String(q.environment)) return false;
146
+ if (q.startTime && e.timestamp < q.startTime.getTime()) return false;
147
+ if (q.endTime && e.timestamp > q.endTime.getTime()) return false;
148
+ if (q.search) {
149
+ const term = q.search.toLowerCase();
150
+ const hay = `${e.message ?? ''} ${JSON.stringify(e.data ?? {})}`.toLowerCase();
151
+ if (!hay.includes(term)) return false;
152
+ }
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * Query the S3 event archive directly. Returns newest-first, filtered + paginated.
158
+ */
159
+ export async function queryLogArchive(
160
+ region: string,
161
+ bucket: string,
162
+ prefix: string,
163
+ query: ObservabilityQuery = {},
164
+ ): Promise<LogEventRecord[]> {
165
+ const endTime = query.endTime ?? new Date();
166
+ const startTime =
167
+ query.startTime ?? new Date(endTime.getTime() - DEFAULT_LOOKBACK_MS);
168
+
169
+ const hourPrefixes = getHourPrefixes(startTime, endTime);
170
+
171
+ // List every hour partition (bounded), then flatten to one key list. A wide
172
+ // window (`--since 7d` → 168 hours) lists concurrently instead of serially.
173
+ const keyLists = await mapLimit(hourPrefixes, MAX_S3_CONCURRENCY, hour =>
174
+ listS3(region, bucket, `${prefix}/events/${hour}`),
175
+ );
176
+ const keys = keyLists.flat().filter(k => k.endsWith('.ndjson'));
177
+
178
+ // Fetch + parse with the same bound — caps total in-flight GETs across the
179
+ // whole window, not just within a single hour.
180
+ const files = await mapLimit(keys, MAX_S3_CONCURRENCY, k =>
181
+ getFromS3(region, bucket, k),
182
+ );
183
+
184
+ const all: LogEventRecord[] = [];
185
+ for (const file of files) {
186
+ if (file) all.push(...parseNdjson(file.toString()));
187
+ }
188
+
189
+ let results = all.filter(e => matches(e, { ...query, startTime, endTime }));
190
+ results.sort((a, b) => b.timestamp - a.timestamp);
191
+
192
+ if (query.offset) results = results.slice(query.offset);
193
+ if (query.limit) results = results.slice(0, query.limit);
194
+
195
+ return results;
196
+ }