@everystack/cli 0.2.36 → 0.2.37
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/cli/observability.ts +2 -0
- package/src/storage/filesystem.ts +26 -0
- package/src/storage/index.ts +18 -0
- package/src/storage/s3.ts +51 -0
package/package.json
CHANGED
package/src/cli/observability.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* {prefix}/events/YYYY/MM/DD/HH/{ts}-{id}.ndjson (one JSON LogEvent per line)
|
|
11
11
|
* Hour partitions are UTC. Canonical writer + query semantics:
|
|
12
12
|
* packages/logging/src/service/s3-log-storage.ts
|
|
13
|
+
* A contract test fails loudly if the writer drifts from what this reader assumes:
|
|
14
|
+
* packages/logging/__tests__/service/cli-reader-format.contract.test.ts
|
|
13
15
|
*
|
|
14
16
|
* We read the format rather than import the class because @everystack/logging
|
|
15
17
|
* pulls React Native (admin/ui) through its peer graph — wrong for a standalone CLI.
|
|
@@ -32,6 +32,32 @@ export class FilesystemStorageAdapter implements StorageAdapter {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
async getWithEtag(key: string): Promise<{ data: Buffer; contentType: string; etag: string } | null> {
|
|
36
|
+
const obj = await this.get(key);
|
|
37
|
+
if (!obj) return null;
|
|
38
|
+
const meta = await this.head(key);
|
|
39
|
+
return { ...obj, etag: meta?.etag ?? '' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort CAS for the filesystem backend (dev). Not atomic across
|
|
44
|
+
* processes, but dev runs single-process — the precondition check + write is
|
|
45
|
+
* a faithful stand-in for S3's conditional PUT.
|
|
46
|
+
*/
|
|
47
|
+
async putConditional(
|
|
48
|
+
key: string,
|
|
49
|
+
data: Buffer | Uint8Array,
|
|
50
|
+
opts: { ifMatch?: string; ifNoneMatch?: string },
|
|
51
|
+
contentType?: string,
|
|
52
|
+
): Promise<{ etag: string } | null> {
|
|
53
|
+
const current = await this.head(key);
|
|
54
|
+
if (opts.ifNoneMatch === '*' && current) return null; // must not exist
|
|
55
|
+
if (opts.ifMatch && (!current || current.etag !== opts.ifMatch)) return null; // must match
|
|
56
|
+
await this.put(key, data, contentType);
|
|
57
|
+
const after = await this.head(key);
|
|
58
|
+
return { etag: after?.etag ?? '' };
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
async exists(key: string): Promise<boolean> {
|
|
36
62
|
const meta = await this.head(key);
|
|
37
63
|
return meta !== null;
|
package/src/storage/index.ts
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
export interface StorageAdapter {
|
|
2
2
|
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
|
3
3
|
get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
|
|
4
|
+
/**
|
|
5
|
+
* Read body + ETag in one request — the atomic basis for compare-and-swap.
|
|
6
|
+
* Optional: only the CAS-using paths (DB-free alert rules/state) need it; both
|
|
7
|
+
* shipped adapters (S3, filesystem) implement it.
|
|
8
|
+
*/
|
|
9
|
+
getWithEtag?(key: string): Promise<{ data: Buffer; contentType: string; etag: string } | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Conditional write (compare-and-swap). `ifNoneMatch: '*'` succeeds only if the
|
|
12
|
+
* object does not yet exist; `ifMatch: <etag>` only if the current ETag matches.
|
|
13
|
+
* Returns the new ETag, or null when the precondition failed (another writer won).
|
|
14
|
+
* Optional (see getWithEtag); both shipped adapters implement it.
|
|
15
|
+
*/
|
|
16
|
+
putConditional?(
|
|
17
|
+
key: string,
|
|
18
|
+
data: Buffer | Uint8Array,
|
|
19
|
+
opts: { ifMatch?: string; ifNoneMatch?: string },
|
|
20
|
+
contentType?: string,
|
|
21
|
+
): Promise<{ etag: string } | null>;
|
|
4
22
|
exists(key: string): Promise<boolean>;
|
|
5
23
|
/** Returns object metadata (ETag, size), or null if not found. */
|
|
6
24
|
head(key: string): Promise<{ etag: string; size?: number } | null>;
|
package/src/storage/s3.ts
CHANGED
|
@@ -55,6 +55,57 @@ export class S3StorageAdapter implements StorageAdapter {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
async getWithEtag(key: string): Promise<{ data: Buffer; contentType: string; etag: string } | null> {
|
|
59
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
60
|
+
const client = await this.getClient();
|
|
61
|
+
try {
|
|
62
|
+
const response = await client.send(new GetObjectCommand({
|
|
63
|
+
Bucket: this.bucket,
|
|
64
|
+
Key: key,
|
|
65
|
+
}));
|
|
66
|
+
const bodyBytes = await response.Body!.transformToByteArray();
|
|
67
|
+
return {
|
|
68
|
+
data: Buffer.from(bodyBytes),
|
|
69
|
+
contentType: response.ContentType || 'application/octet-stream',
|
|
70
|
+
etag: response.ETag || '',
|
|
71
|
+
};
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async putConditional(
|
|
81
|
+
key: string,
|
|
82
|
+
data: Buffer | Uint8Array,
|
|
83
|
+
opts: { ifMatch?: string; ifNoneMatch?: string },
|
|
84
|
+
contentType?: string,
|
|
85
|
+
): Promise<{ etag: string } | null> {
|
|
86
|
+
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
87
|
+
const client = await this.getClient();
|
|
88
|
+
try {
|
|
89
|
+
const response = await client.send(new PutObjectCommand({
|
|
90
|
+
Bucket: this.bucket,
|
|
91
|
+
Key: key,
|
|
92
|
+
Body: data,
|
|
93
|
+
ContentType: contentType || 'application/octet-stream',
|
|
94
|
+
...(opts.ifMatch ? { IfMatch: opts.ifMatch } : {}),
|
|
95
|
+
...(opts.ifNoneMatch ? { IfNoneMatch: opts.ifNoneMatch } : {}),
|
|
96
|
+
}));
|
|
97
|
+
return { etag: response.ETag || '' };
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
// 412 PreconditionFailed (If-Match/If-None-Match) or 409 conditional conflict
|
|
100
|
+
// both mean another writer raced us — not an error, just "you didn't win".
|
|
101
|
+
const status = err.$metadata?.httpStatusCode;
|
|
102
|
+
if (err.name === 'PreconditionFailed' || err.name === 'ConditionalRequestConflict' || status === 412 || status === 409) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
58
109
|
async exists(key: string): Promise<boolean> {
|
|
59
110
|
const meta = await this.head(key);
|
|
60
111
|
return meta !== null;
|