@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.36",
3
+ "version": "0.2.37",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -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;
@@ -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;