@platformatic/sqlite-js-stream-s3 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * S3 replica client — replicates LTX files to an Amazon S3 bucket.
3
+ *
4
+ * Object layout mirrors the file backend:
5
+ * `{prefix}ltx/{level}/{minTXID}-{maxTXID}.ltx`
6
+ *
7
+ * The implementation talks to S3 through the AWS SDK v3 {@link S3Client}. A
8
+ * minimal `send`-shaped client can be injected (e.g. an in-memory fake in
9
+ * tests) so the upload strategy is testable without network access.
10
+ *
11
+ * Creation timestamps are preserved on upload as the `ltx-timestamp` object
12
+ * metadata; listing falls back to the object's `LastModified`.
13
+ */
14
+ import { Readable } from 'node:stream';
15
+ import { S3Client, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectsCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, UploadPartCommand, } from '@aws-sdk/client-s3';
16
+ import { HeaderSize, LTXError, formatFilename, noopLogger, parseFilename, unmarshalHeader, } from '@platformatic/sqlite-js-stream';
17
+ /** The strategy identifier reported by {@link S3ReplicaClient.type}. */
18
+ export const ReplicaClientType = 's3';
19
+ /** Normalized base prefix (no leading/trailing slash, `''` when empty). */
20
+ function normalizePrefix(prefix) {
21
+ return prefix.replace(/^\/+/, '').replace(/\/+$/, '');
22
+ }
23
+ /** Concatenate byte chunks into a single Uint8Array. */
24
+ function concatBytes(chunks) {
25
+ let total = 0;
26
+ for (const c of chunks)
27
+ total += c.length;
28
+ const out = new Uint8Array(total);
29
+ let o = 0;
30
+ for (const c of chunks) {
31
+ out.set(c, o);
32
+ o += c.length;
33
+ }
34
+ return out;
35
+ }
36
+ /** S3 requires every multipart part (except the last) to be at least 5 MiB. */
37
+ const MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024;
38
+ /** S3ReplicaClient writes LTX files to an Amazon S3 bucket. */
39
+ export class S3ReplicaClient {
40
+ bucket;
41
+ prefix;
42
+ client;
43
+ logger = noopLogger;
44
+ constructor(opts) {
45
+ if (!opts.bucket) {
46
+ throw new Error('S3 replica client: bucket is required');
47
+ }
48
+ this.bucket = opts.bucket;
49
+ this.prefix = normalizePrefix(opts.prefix ?? '');
50
+ this.client =
51
+ opts.client ??
52
+ new S3Client({
53
+ region: opts.region,
54
+ endpoint: opts.endpoint,
55
+ credentials: opts.accessKeyId
56
+ ? {
57
+ accessKeyId: opts.accessKeyId,
58
+ secretAccessKey: opts.secretAccessKey ?? '',
59
+ sessionToken: opts.sessionToken,
60
+ }
61
+ : undefined,
62
+ });
63
+ }
64
+ type() {
65
+ return ReplicaClientType;
66
+ }
67
+ /** Best-effort connectivity check; real I/O errors surface on first use. */
68
+ async init() {
69
+ try {
70
+ await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
71
+ }
72
+ catch (err) {
73
+ this.logger.debug('s3 head bucket failed', err);
74
+ }
75
+ }
76
+ setLogger(logger) {
77
+ this.logger = logger;
78
+ }
79
+ /** Returns the object key prefix (empty string if none). */
80
+ prefixKey() {
81
+ return this.prefix;
82
+ }
83
+ /** Returns `{prefix}ltx/{level}/`. */
84
+ levelPrefix(level) {
85
+ return `${this.prefix ? `${this.prefix}/` : ''}ltx/${level}/`;
86
+ }
87
+ /** Returns the object key for a level and TXID range. */
88
+ fileKey(level, minTXID, maxTXID) {
89
+ return `${this.levelPrefix(level)}${formatFilename(minTXID, maxTXID)}`;
90
+ }
91
+ async *ltxFiles(opts) {
92
+ const prefix = this.levelPrefix(opts.level);
93
+ let token;
94
+ do {
95
+ const res = await this.client.send(new ListObjectsV2Command({
96
+ Bucket: this.bucket,
97
+ Prefix: prefix,
98
+ ContinuationToken: token,
99
+ }));
100
+ for (const obj of res.Contents ?? []) {
101
+ if (!obj.Key)
102
+ continue;
103
+ const name = obj.Key.slice(prefix.length);
104
+ let minTXID;
105
+ let maxTXID;
106
+ try {
107
+ ({ minTXID, maxTXID } = parseFilename(name));
108
+ }
109
+ catch {
110
+ continue; // skip non-conforming keys
111
+ }
112
+ if (minTXID < opts.seek)
113
+ continue;
114
+ yield {
115
+ level: opts.level,
116
+ minTXID,
117
+ maxTXID,
118
+ size: obj.Size ?? 0,
119
+ createdAt: (obj.LastModified ?? new Date(0)).getTime(),
120
+ };
121
+ }
122
+ token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
123
+ } while (token);
124
+ }
125
+ async openLtxFile(opts) {
126
+ const key = this.fileKey(opts.level, opts.minTXID, opts.maxTXID);
127
+ // A zero offset/size is a full read; no HeadObject round-trip needed.
128
+ if (opts.offset === 0 && opts.size === 0) {
129
+ try {
130
+ const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
131
+ return res.Body;
132
+ }
133
+ catch (err) {
134
+ throw new LTXError('open', key, opts.level, opts.minTXID, opts.maxTXID, err);
135
+ }
136
+ }
137
+ // Need the object size to build a range and to handle offset >= size.
138
+ let head;
139
+ try {
140
+ head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
141
+ }
142
+ catch (err) {
143
+ throw new LTXError('open', key, opts.level, opts.minTXID, opts.maxTXID, err);
144
+ }
145
+ const len = Number(head.ContentLength ?? 0);
146
+ if (opts.offset >= len)
147
+ return Readable.from([]);
148
+ const end = opts.size > 0 ? Math.min(opts.offset + opts.size - 1, len - 1) : len - 1;
149
+ if (end < opts.offset)
150
+ return Readable.from([]);
151
+ try {
152
+ const res = await this.client.send(new GetObjectCommand({
153
+ Bucket: this.bucket,
154
+ Key: key,
155
+ Range: `bytes=${opts.offset}-${end}`,
156
+ }));
157
+ return res.Body;
158
+ }
159
+ catch (err) {
160
+ throw new LTXError('open', key, opts.level, opts.minTXID, opts.maxTXID, err);
161
+ }
162
+ }
163
+ async writeLtxFile(opts, body) {
164
+ const key = this.fileKey(opts.level, opts.minTXID, opts.maxTXID);
165
+ // Capture the LTX header (timestamp) from the leading bytes, and stream the
166
+ // rest. Small objects use a single PutObject; large objects (> 5 MiB) are
167
+ // uploaded with the multipart API so the whole file is never buffered.
168
+ let headerBytes = null;
169
+ let head = [];
170
+ let headLen = 0;
171
+ let acc = [];
172
+ let accLen = 0;
173
+ let size = 0;
174
+ let uploadId = null;
175
+ let partNumber = 0;
176
+ const uploadedParts = [];
177
+ const uploadPart = async (buf) => {
178
+ partNumber++;
179
+ const res = await this.client.send(new UploadPartCommand({
180
+ Bucket: this.bucket,
181
+ Key: key,
182
+ UploadId: uploadId,
183
+ PartNumber: partNumber,
184
+ Body: buf,
185
+ }));
186
+ uploadedParts.push({ PartNumber: partNumber, ETag: res.ETag ?? `etag-${partNumber}` });
187
+ };
188
+ try {
189
+ for await (const chunk of body) {
190
+ const bytes = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : Uint8Array.from(chunk);
191
+ if (bytes.length === 0)
192
+ continue;
193
+ size += bytes.length;
194
+ if (headerBytes === null) {
195
+ head.push(bytes);
196
+ headLen += bytes.length;
197
+ if (headLen >= HeaderSize) {
198
+ headerBytes = concatBytes(head).subarray(0, HeaderSize);
199
+ head = [];
200
+ headLen = 0;
201
+ }
202
+ }
203
+ acc.push(bytes);
204
+ accLen += bytes.length;
205
+ if (uploadId === null && accLen >= MULTIPART_MIN_PART_SIZE) {
206
+ const res = await this.client.send(new CreateMultipartUploadCommand({
207
+ Bucket: this.bucket,
208
+ Key: key,
209
+ Metadata: { 'ltx-timestamp': String(Number(unmarshalHeader(headerBytes).timestamp)) },
210
+ }));
211
+ uploadId = res.UploadId;
212
+ await uploadPart(concatBytes(acc));
213
+ acc = [];
214
+ accLen = 0;
215
+ }
216
+ else if (uploadId !== null && accLen >= MULTIPART_MIN_PART_SIZE) {
217
+ await uploadPart(concatBytes(acc));
218
+ acc = [];
219
+ accLen = 0;
220
+ }
221
+ }
222
+ if (headerBytes === null) {
223
+ throw new Error('write ltx file: input too short to contain a header');
224
+ }
225
+ const timestamp = Number(unmarshalHeader(headerBytes).timestamp);
226
+ const metadata = { 'ltx-timestamp': String(timestamp) };
227
+ if (uploadId === null) {
228
+ // Single PutObject for small objects.
229
+ const buf = concatBytes(acc);
230
+ await this.client.send(new PutObjectCommand({
231
+ Bucket: this.bucket,
232
+ Key: key,
233
+ Body: buf,
234
+ ContentLength: size,
235
+ Metadata: metadata,
236
+ }));
237
+ }
238
+ else {
239
+ // Multipart: upload any remaining bytes as the final part, then complete.
240
+ if (accLen > 0)
241
+ await uploadPart(concatBytes(acc));
242
+ await this.client.send(new CompleteMultipartUploadCommand({
243
+ Bucket: this.bucket,
244
+ Key: key,
245
+ UploadId: uploadId,
246
+ MultipartUpload: { Parts: uploadedParts },
247
+ }));
248
+ }
249
+ return {
250
+ level: opts.level,
251
+ minTXID: opts.minTXID,
252
+ maxTXID: opts.maxTXID,
253
+ size,
254
+ createdAt: timestamp,
255
+ };
256
+ }
257
+ catch (err) {
258
+ if (uploadId !== null) {
259
+ await this.client
260
+ .send(new AbortMultipartUploadCommand({
261
+ Bucket: this.bucket,
262
+ Key: key,
263
+ UploadId: uploadId,
264
+ }))
265
+ .catch(() => { });
266
+ }
267
+ throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
268
+ }
269
+ }
270
+ async deleteLtxFiles(infos) {
271
+ const objects = infos.map((info) => ({
272
+ Key: this.fileKey(info.level, info.minTXID, info.maxTXID),
273
+ }));
274
+ for (let i = 0; i < objects.length; i += 1000) {
275
+ await this.client.send(new DeleteObjectsCommand({
276
+ Bucket: this.bucket,
277
+ Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
278
+ }));
279
+ }
280
+ }
281
+ async deleteAll() {
282
+ const prefix = this.prefix ? `${this.prefix}/` : '';
283
+ let token;
284
+ do {
285
+ const res = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: token }));
286
+ const contents = (res.Contents ?? []);
287
+ const objects = contents.filter((o) => o.Key).map((o) => ({ Key: o.Key }));
288
+ for (let i = 0; i < objects.length; i += 1000) {
289
+ await this.client.send(new DeleteObjectsCommand({
290
+ Bucket: this.bucket,
291
+ Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
292
+ }));
293
+ }
294
+ token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
295
+ } while (token);
296
+ }
297
+ }
298
+ /**
299
+ * Builds an S3ReplicaClient from parsed replica URL parts. `host` is the bucket,
300
+ * `urlPath` is the prefix, `query` may carry `region`/`endpoint`, and `userinfo`
301
+ * carries `accessKeyId:secretAccessKey`.
302
+ */
303
+ export function newReplicaClientFromURL(_scheme, host, urlPath, query, userinfo) {
304
+ const opts = {
305
+ bucket: host,
306
+ prefix: normalizePrefix(urlPath),
307
+ region: query?.get('region') ?? undefined,
308
+ endpoint: query?.get('endpoint') ?? undefined,
309
+ };
310
+ if (userinfo) {
311
+ const [accessKeyId, secretAccessKey] = userinfo.split(':');
312
+ opts.accessKeyId = accessKeyId;
313
+ opts.secretAccessKey = secretAccessKey;
314
+ }
315
+ return new S3ReplicaClient(opts);
316
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@platformatic/sqlite-js-stream-s3",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.4",
4
+ "description": "Amazon S3 upload strategy (replica client) for @platformatic/sqlite-js-stream",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=24"
8
+ },
9
+ "license": "Apache-2.0",
10
+ "author": "Matteo Collina",
11
+ "homepage": "https://github.com/platformatic/sqlite-js-stream/tree/main/packages/backend-s3",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/platformatic/sqlite-js-stream.git",
15
+ "directory": "packages/backend-s3"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/platformatic/sqlite-js-stream/issues"
19
+ },
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org"
37
+ },
38
+ "dependencies": {
39
+ "@aws-sdk/client-s3": "^3.1135.0",
40
+ "@platformatic/sqlite-js-stream": "0.1.4"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^24.0.0",
44
+ "typescript": "^5.7.0"
45
+ },
46
+ "scripts": {
47
+ "test": "node --test 'test/**/*.test.ts'",
48
+ "build": "tsc -p tsconfig.build.json"
49
+ }
6
50
  }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @platformatic/sqlite-js-stream-s3 — Amazon S3 upload strategy.
3
+ *
4
+ * Importing this module registers the `s3` scheme with the core's replica URL
5
+ * factory, so `newReplicaClientFromURL('s3://bucket/prefix')` resolves after
6
+ * this addon is loaded.
7
+ */
8
+
9
+ export {
10
+ S3ReplicaClient,
11
+ newReplicaClientFromURL,
12
+ ReplicaClientType,
13
+ type S3ClientLike,
14
+ type S3ReplicaClientOptions,
15
+ } from './replica-client.ts';
16
+
17
+ export { S3Leaser } from './leaser.ts';
18
+
19
+ import { registerReplicaClientFactory } from '@platformatic/sqlite-js-stream';
20
+ import { newReplicaClientFromURL } from './replica-client.ts';
21
+
22
+ // Register the 's3' URL scheme when the addon is imported.
23
+ registerReplicaClientFactory('s3', newReplicaClientFromURL);
package/src/leaser.ts ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * S3 Leaser — distributed lease backed by an S3 `lock.json` object.
3
+ *
4
+ * Mirrors litestream's `s3/leaser.go`. Acquire uses `If-None-Match: *` when no
5
+ * lease exists (and `If-Match: <etag>` when replacing an expired lease), so two
6
+ * racing acquirers cannot both win; one gets `412 PreconditionFailed`.
7
+ */
8
+
9
+ import {
10
+ DeleteObjectCommand,
11
+ GetObjectCommand,
12
+ PutObjectCommand,
13
+ } from '@aws-sdk/client-s3';
14
+
15
+ import {
16
+ DefaultLeasePath,
17
+ DefaultLeaseTTL,
18
+ ErrLeaseNotHeld,
19
+ Lease,
20
+ LeaseExistsError,
21
+ type Leaser,
22
+ } from '@platformatic/sqlite-js-stream';
23
+
24
+ import type { S3ClientLike } from './replica-client.ts';
25
+
26
+ /** Reads all bytes from an S3 body (stream or Buffer). */
27
+ async function readBody(body: any): Promise<Uint8Array> {
28
+ // AWS SDK v3 returns a mix; normalize to a Uint8Array.
29
+ if (body instanceof Uint8Array) return body;
30
+ if (typeof body?.on === 'function') {
31
+ // Node stream.
32
+ const chunks: Uint8Array[] = [];
33
+ for await (const chunk of body as AsyncIterable<Uint8Array | string>) {
34
+ chunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
35
+ }
36
+ let total = 0;
37
+ for (const c of chunks) total += c.length;
38
+ const out = new Uint8Array(total);
39
+ let o = 0;
40
+ for (const c of chunks) {
41
+ out.set(c, o);
42
+ o += c.length;
43
+ }
44
+ return out;
45
+ }
46
+ // Web ReadableStream or Buffer.
47
+ if (typeof body?.getReader === 'function') {
48
+ const reader = (body as ReadableStream<Uint8Array>).getReader();
49
+ const chunks: Uint8Array[] = [];
50
+ for (;;) {
51
+ const { done, value } = await reader.read();
52
+ if (done) break;
53
+ chunks.push(value);
54
+ }
55
+ let total = 0;
56
+ for (const c of chunks) total += c.length;
57
+ const out = new Uint8Array(total);
58
+ let o = 0;
59
+ for (const c of chunks) {
60
+ out.set(c, o);
61
+ o += c.length;
62
+ }
63
+ return out;
64
+ }
65
+ return new Uint8Array(body as Uint8Array);
66
+ }
67
+
68
+ function isPreconditionFailed(err: unknown): boolean {
69
+ const code = (err as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
70
+ const name = (err as { name?: string })?.name ?? '';
71
+ return code === 412 || name === 'PreconditionFailed';
72
+ }
73
+
74
+ function isNotFound(err: unknown): boolean {
75
+ const code = (err as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
76
+ const name = (err as { name?: string })?.name ?? '';
77
+ return code === 404 || name === 'NoSuchKey' || name === 'NotFound';
78
+ }
79
+
80
+ /** S3Leaser acquires/renews/releases a lease object in an S3 bucket. */
81
+ export class S3Leaser implements Leaser {
82
+ readonly bucket: string;
83
+ readonly prefix: string;
84
+ readonly key: string;
85
+ private client: S3ClientLike;
86
+ private owner: string;
87
+ private ttlMs: number;
88
+
89
+ constructor(opts: { bucket: string; prefix?: string; client: S3ClientLike; owner?: string; ttlMs?: number }) {
90
+ if (!opts.bucket) throw new Error('S3 leaser: bucket is required');
91
+ this.bucket = opts.bucket;
92
+ this.prefix = opts.prefix?.replace(/^\/+/, '').replace(/\/+$/, '') ?? '';
93
+ this.key = this.prefix ? `${this.prefix}/${DefaultLeasePath}` : DefaultLeasePath;
94
+ this.client = opts.client;
95
+ this.owner = opts.owner ?? `${process.pid}`;
96
+ this.ttlMs = opts.ttlMs ?? DefaultLeaseTTL;
97
+ }
98
+
99
+ async acquireLease(): Promise<Lease> {
100
+ const existing = await this.readLease();
101
+ if (existing !== null && !existing.isExpired()) {
102
+ throw new LeaseExistsError(existing.owner, existing.expiresAt);
103
+ }
104
+
105
+ let generation = 1;
106
+ if (existing !== null) generation = existing.generation + 1;
107
+
108
+ const lease = new Lease(generation, Date.now() + this.ttlMs, this.owner);
109
+ const data = new TextEncoder().encode(
110
+ JSON.stringify({
111
+ generation: lease.generation,
112
+ expires_at: new Date(lease.expiresAt).toISOString(),
113
+ owner: lease.owner,
114
+ }),
115
+ );
116
+
117
+ try {
118
+ await this.client.send(
119
+ new PutObjectCommand({
120
+ Bucket: this.bucket,
121
+ Key: this.key,
122
+ Body: data,
123
+ ContentType: 'application/json',
124
+ ...(existing === null ? { IfNoneMatch: '*' } : { IfMatch: existing.etag ?? '' }),
125
+ }),
126
+ );
127
+ } catch (err) {
128
+ if (isPreconditionFailed(err)) {
129
+ const current = await this.readLease();
130
+ if (current !== null) throw new LeaseExistsError(current.owner, current.expiresAt);
131
+ }
132
+ throw err;
133
+ }
134
+
135
+ return (await this.readLease()) ?? lease;
136
+ }
137
+
138
+ async renewLease(lease: Lease): Promise<Lease> {
139
+ if (lease.etag == null) throw new ErrLeaseNotHeld();
140
+ const renewed = new Lease(lease.generation, Date.now() + this.ttlMs, lease.owner);
141
+ const data = new TextEncoder().encode(
142
+ JSON.stringify({
143
+ generation: renewed.generation,
144
+ expires_at: new Date(renewed.expiresAt).toISOString(),
145
+ owner: renewed.owner,
146
+ }),
147
+ );
148
+ try {
149
+ await this.client.send(
150
+ new PutObjectCommand({
151
+ Bucket: this.bucket,
152
+ Key: this.key,
153
+ Body: data,
154
+ ContentType: 'application/json',
155
+ IfMatch: lease.etag,
156
+ }),
157
+ );
158
+ } catch (err) {
159
+ if (isPreconditionFailed(err)) throw new ErrLeaseNotHeld();
160
+ throw err;
161
+ }
162
+ return (await this.readLease()) ?? renewed;
163
+ }
164
+
165
+ async releaseLease(lease: Lease): Promise<void> {
166
+ if (lease.etag == null) throw new ErrLeaseNotHeld();
167
+ try {
168
+ await this.client.send(
169
+ new DeleteObjectCommand({
170
+ Bucket: this.bucket,
171
+ Key: this.key,
172
+ IfMatch: lease.etag,
173
+ }),
174
+ );
175
+ } catch (err) {
176
+ if (isNotFound(err)) return; // already released
177
+ if (isPreconditionFailed(err)) throw new ErrLeaseNotHeld();
178
+ throw err;
179
+ }
180
+ }
181
+
182
+ private async readLease(): Promise<Lease | null> {
183
+ try {
184
+ const res = await this.client.send(
185
+ new GetObjectCommand({ Bucket: this.bucket, Key: this.key }),
186
+ );
187
+ const data = await readBody(res.Body);
188
+ const parsed = JSON.parse(new TextDecoder().decode(data)) as {
189
+ generation?: number;
190
+ expires_at?: string;
191
+ owner?: string;
192
+ };
193
+ const expiresAt = Date.parse(parsed.expires_at ?? '');
194
+ if (Number.isNaN(expiresAt)) throw new Error('invalid lease file: bad expires_at');
195
+ const etag = typeof res.ETag === 'string' ? res.ETag : null;
196
+ return new Lease(parsed.generation ?? 1, expiresAt, parsed.owner ?? '', etag);
197
+ } catch (err) {
198
+ if (isNotFound(err)) return null;
199
+ throw err;
200
+ }
201
+ }
202
+ }