@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,414 @@
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
+
15
+ import { Readable } from 'node:stream';
16
+
17
+ import {
18
+ S3Client,
19
+ AbortMultipartUploadCommand,
20
+ CompleteMultipartUploadCommand,
21
+ CreateMultipartUploadCommand,
22
+ DeleteObjectsCommand,
23
+ GetObjectCommand,
24
+ HeadBucketCommand,
25
+ HeadObjectCommand,
26
+ ListObjectsV2Command,
27
+ PutObjectCommand,
28
+ UploadPartCommand,
29
+ } from '@aws-sdk/client-s3';
30
+
31
+ import {
32
+ HeaderSize,
33
+ LTXError,
34
+ formatFilename,
35
+ noopLogger,
36
+ parseFilename,
37
+ unmarshalHeader,
38
+ type FileInfo,
39
+ type Logger,
40
+ type LTXFilesOptions,
41
+ type OpenLTXFileOptions,
42
+ type ReplicaClient,
43
+ type WriteLTXFileOptions,
44
+ } from '@platformatic/sqlite-js-stream';
45
+
46
+ /** The strategy identifier reported by {@link S3ReplicaClient.type}. */
47
+ export const ReplicaClientType = 's3';
48
+
49
+ /**
50
+ * Minimal shape of a client used to send commands. The AWS SDK v3 `S3Client`
51
+ * satisfies this; tests inject an in-memory fake instead of hitting the network.
52
+ */
53
+ export interface S3ClientLike {
54
+ send(command: any): Promise<any>;
55
+ }
56
+
57
+ /** Configuration for {@link S3ReplicaClient}. */
58
+ export interface S3ReplicaClientOptions {
59
+ /** S3 bucket name. Required. */
60
+ bucket: string;
61
+ /** Object key prefix (e.g. `backup`). Trailing slash is added as needed. */
62
+ prefix?: string;
63
+ /** AWS region. Defaults to the AWS SDK region resolution chain. */
64
+ region?: string;
65
+ /** Custom endpoint URL (e.g. for MinIO / LocalStack). */
66
+ endpoint?: string;
67
+ accessKeyId?: string;
68
+ secretAccessKey?: string;
69
+ sessionToken?: string;
70
+ /** Inject a preconfigured client. Mostly for tests. */
71
+ client?: S3ClientLike;
72
+ }
73
+
74
+ /** Normalized base prefix (no leading/trailing slash, `''` when empty). */
75
+ function normalizePrefix(prefix: string): string {
76
+ return prefix.replace(/^\/+/, '').replace(/\/+$/, '');
77
+ }
78
+
79
+ /** Concatenate byte chunks into a single Uint8Array. */
80
+ function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
81
+ let total = 0;
82
+ for (const c of chunks) total += c.length;
83
+ const out = new Uint8Array(total);
84
+ let o = 0;
85
+ for (const c of chunks) {
86
+ out.set(c, o);
87
+ o += c.length;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /** S3 requires every multipart part (except the last) to be at least 5 MiB. */
93
+ const MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024;
94
+
95
+ /** S3ReplicaClient writes LTX files to an Amazon S3 bucket. */
96
+ export class S3ReplicaClient implements ReplicaClient {
97
+ readonly bucket: string;
98
+ readonly prefix: string;
99
+ private client: S3ClientLike;
100
+ private logger: Logger = noopLogger;
101
+
102
+ constructor(opts: S3ReplicaClientOptions) {
103
+ if (!opts.bucket) {
104
+ throw new Error('S3 replica client: bucket is required');
105
+ }
106
+ this.bucket = opts.bucket;
107
+ this.prefix = normalizePrefix(opts.prefix ?? '');
108
+ this.client =
109
+ opts.client ??
110
+ new S3Client({
111
+ region: opts.region,
112
+ endpoint: opts.endpoint,
113
+ credentials: opts.accessKeyId
114
+ ? {
115
+ accessKeyId: opts.accessKeyId,
116
+ secretAccessKey: opts.secretAccessKey ?? '',
117
+ sessionToken: opts.sessionToken,
118
+ }
119
+ : undefined,
120
+ });
121
+ }
122
+
123
+ type(): string {
124
+ return ReplicaClientType;
125
+ }
126
+
127
+ /** Best-effort connectivity check; real I/O errors surface on first use. */
128
+ async init(): Promise<void> {
129
+ try {
130
+ await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
131
+ } catch (err) {
132
+ this.logger.debug('s3 head bucket failed', err);
133
+ }
134
+ }
135
+
136
+ setLogger(logger: Logger): void {
137
+ this.logger = logger;
138
+ }
139
+
140
+ /** Returns the object key prefix (empty string if none). */
141
+ prefixKey(): string {
142
+ return this.prefix;
143
+ }
144
+
145
+ /** Returns `{prefix}ltx/{level}/`. */
146
+ private levelPrefix(level: number): string {
147
+ return `${this.prefix ? `${this.prefix}/` : ''}ltx/${level}/`;
148
+ }
149
+
150
+ /** Returns the object key for a level and TXID range. */
151
+ private fileKey(level: number, minTXID: bigint, maxTXID: bigint): string {
152
+ return `${this.levelPrefix(level)}${formatFilename(minTXID, maxTXID)}`;
153
+ }
154
+
155
+ async *ltxFiles(opts: LTXFilesOptions): AsyncIterable<FileInfo> {
156
+ const prefix = this.levelPrefix(opts.level);
157
+ let token: string | undefined;
158
+ do {
159
+ const res = await this.client.send(
160
+ new ListObjectsV2Command({
161
+ Bucket: this.bucket,
162
+ Prefix: prefix,
163
+ ContinuationToken: token,
164
+ }),
165
+ );
166
+ for (const obj of res.Contents ?? []) {
167
+ if (!obj.Key) continue;
168
+ const name = obj.Key.slice(prefix.length);
169
+ let minTXID: bigint;
170
+ let maxTXID: bigint;
171
+ try {
172
+ ({ minTXID, maxTXID } = parseFilename(name));
173
+ } catch {
174
+ continue; // skip non-conforming keys
175
+ }
176
+ if (minTXID < opts.seek) continue;
177
+ yield {
178
+ level: opts.level,
179
+ minTXID,
180
+ maxTXID,
181
+ size: obj.Size ?? 0,
182
+ createdAt: (obj.LastModified ?? new Date(0)).getTime(),
183
+ };
184
+ }
185
+ token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
186
+ } while (token);
187
+ }
188
+
189
+ async openLtxFile(opts: OpenLTXFileOptions): Promise<Readable> {
190
+ const key = this.fileKey(opts.level, opts.minTXID, opts.maxTXID);
191
+
192
+ // A zero offset/size is a full read; no HeadObject round-trip needed.
193
+ if (opts.offset === 0 && opts.size === 0) {
194
+ try {
195
+ const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
196
+ return res.Body as Readable;
197
+ } catch (err) {
198
+ throw new LTXError('open', key, opts.level, opts.minTXID, opts.maxTXID, err);
199
+ }
200
+ }
201
+
202
+ // Need the object size to build a range and to handle offset >= size.
203
+ let head;
204
+ try {
205
+ head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
206
+ } catch (err) {
207
+ throw new LTXError('open', key, opts.level, opts.minTXID, opts.maxTXID, err);
208
+ }
209
+ const len = Number(head.ContentLength ?? 0);
210
+ if (opts.offset >= len) return Readable.from([]);
211
+
212
+ const end = opts.size > 0 ? Math.min(opts.offset + opts.size - 1, len - 1) : len - 1;
213
+ if (end < opts.offset) return Readable.from([]);
214
+
215
+ try {
216
+ const res = await this.client.send(
217
+ new GetObjectCommand({
218
+ Bucket: this.bucket,
219
+ Key: key,
220
+ Range: `bytes=${opts.offset}-${end}`,
221
+ }),
222
+ );
223
+ return res.Body as Readable;
224
+ } catch (err) {
225
+ throw new LTXError('open', key, opts.level, opts.minTXID, opts.maxTXID, err);
226
+ }
227
+ }
228
+
229
+ async writeLtxFile(opts: WriteLTXFileOptions, body: AsyncIterable<Uint8Array | string>): Promise<FileInfo> {
230
+ const key = this.fileKey(opts.level, opts.minTXID, opts.maxTXID);
231
+
232
+ // Capture the LTX header (timestamp) from the leading bytes, and stream the
233
+ // rest. Small objects use a single PutObject; large objects (> 5 MiB) are
234
+ // uploaded with the multipart API so the whole file is never buffered.
235
+ let headerBytes: Uint8Array | null = null;
236
+ let head: Uint8Array[] = [];
237
+ let headLen = 0;
238
+
239
+ let acc: Uint8Array[] = [];
240
+ let accLen = 0;
241
+ let size = 0;
242
+
243
+ let uploadId: string | null = null;
244
+ let partNumber = 0;
245
+ const uploadedParts: Array<{ PartNumber: number; ETag: string }> = [];
246
+
247
+ const uploadPart = async (buf: Uint8Array): Promise<void> => {
248
+ partNumber++;
249
+ const res = await this.client.send(
250
+ new UploadPartCommand({
251
+ Bucket: this.bucket,
252
+ Key: key,
253
+ UploadId: uploadId!,
254
+ PartNumber: partNumber,
255
+ Body: buf,
256
+ }),
257
+ );
258
+ uploadedParts.push({ PartNumber: partNumber, ETag: res.ETag ?? `etag-${partNumber}` });
259
+ };
260
+
261
+ try {
262
+ for await (const chunk of body) {
263
+ const bytes =
264
+ typeof chunk === 'string' ? new TextEncoder().encode(chunk) : Uint8Array.from(chunk);
265
+ if (bytes.length === 0) continue;
266
+ size += bytes.length;
267
+
268
+ if (headerBytes === null) {
269
+ head.push(bytes);
270
+ headLen += bytes.length;
271
+ if (headLen >= HeaderSize) {
272
+ headerBytes = concatBytes(head).subarray(0, HeaderSize);
273
+ head = [];
274
+ headLen = 0;
275
+ }
276
+ }
277
+
278
+ acc.push(bytes);
279
+ accLen += bytes.length;
280
+
281
+ if (uploadId === null && accLen >= MULTIPART_MIN_PART_SIZE) {
282
+ const res = await this.client.send(
283
+ new CreateMultipartUploadCommand({
284
+ Bucket: this.bucket,
285
+ Key: key,
286
+ Metadata: { 'ltx-timestamp': String(Number(unmarshalHeader(headerBytes!).timestamp)) },
287
+ }),
288
+ );
289
+ uploadId = res.UploadId;
290
+ await uploadPart(concatBytes(acc));
291
+ acc = [];
292
+ accLen = 0;
293
+ } else if (uploadId !== null && accLen >= MULTIPART_MIN_PART_SIZE) {
294
+ await uploadPart(concatBytes(acc));
295
+ acc = [];
296
+ accLen = 0;
297
+ }
298
+ }
299
+
300
+ if (headerBytes === null) {
301
+ throw new Error('write ltx file: input too short to contain a header');
302
+ }
303
+ const timestamp = Number(unmarshalHeader(headerBytes).timestamp);
304
+ const metadata = { 'ltx-timestamp': String(timestamp) };
305
+
306
+ if (uploadId === null) {
307
+ // Single PutObject for small objects.
308
+ const buf = concatBytes(acc);
309
+ await this.client.send(
310
+ new PutObjectCommand({
311
+ Bucket: this.bucket,
312
+ Key: key,
313
+ Body: buf,
314
+ ContentLength: size,
315
+ Metadata: metadata,
316
+ }),
317
+ );
318
+ } else {
319
+ // Multipart: upload any remaining bytes as the final part, then complete.
320
+ if (accLen > 0) await uploadPart(concatBytes(acc));
321
+ await this.client.send(
322
+ new CompleteMultipartUploadCommand({
323
+ Bucket: this.bucket,
324
+ Key: key,
325
+ UploadId: uploadId,
326
+ MultipartUpload: { Parts: uploadedParts },
327
+ }),
328
+ );
329
+ }
330
+
331
+ return {
332
+ level: opts.level,
333
+ minTXID: opts.minTXID,
334
+ maxTXID: opts.maxTXID,
335
+ size,
336
+ createdAt: timestamp,
337
+ };
338
+ } catch (err) {
339
+ if (uploadId !== null) {
340
+ await this.client
341
+ .send(
342
+ new AbortMultipartUploadCommand({
343
+ Bucket: this.bucket,
344
+ Key: key,
345
+ UploadId: uploadId,
346
+ }),
347
+ )
348
+ .catch(() => {});
349
+ }
350
+ throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
351
+ }
352
+ }
353
+
354
+ async deleteLtxFiles(infos: FileInfo[]): Promise<void> {
355
+ const objects = infos.map((info) => ({
356
+ Key: this.fileKey(info.level, info.minTXID, info.maxTXID),
357
+ }));
358
+ for (let i = 0; i < objects.length; i += 1000) {
359
+ await this.client.send(
360
+ new DeleteObjectsCommand({
361
+ Bucket: this.bucket,
362
+ Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
363
+ }),
364
+ );
365
+ }
366
+ }
367
+
368
+ async deleteAll(): Promise<void> {
369
+ const prefix = this.prefix ? `${this.prefix}/` : '';
370
+ let token: string | undefined;
371
+ do {
372
+ const res = await this.client.send(
373
+ new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: token }),
374
+ );
375
+ const contents = (res.Contents ?? []) as Array<{ Key?: string }>;
376
+ const objects = contents.filter((o) => o.Key).map((o) => ({ Key: o.Key! }));
377
+ for (let i = 0; i < objects.length; i += 1000) {
378
+ await this.client.send(
379
+ new DeleteObjectsCommand({
380
+ Bucket: this.bucket,
381
+ Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
382
+ }),
383
+ );
384
+ }
385
+ token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
386
+ } while (token);
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Builds an S3ReplicaClient from parsed replica URL parts. `host` is the bucket,
392
+ * `urlPath` is the prefix, `query` may carry `region`/`endpoint`, and `userinfo`
393
+ * carries `accessKeyId:secretAccessKey`.
394
+ */
395
+ export function newReplicaClientFromURL(
396
+ _scheme: string,
397
+ host: string,
398
+ urlPath: string,
399
+ query: URLSearchParams | null,
400
+ userinfo: string | null,
401
+ ): ReplicaClient {
402
+ const opts: S3ReplicaClientOptions = {
403
+ bucket: host,
404
+ prefix: normalizePrefix(urlPath),
405
+ region: query?.get('region') ?? undefined,
406
+ endpoint: query?.get('endpoint') ?? undefined,
407
+ };
408
+ if (userinfo) {
409
+ const [accessKeyId, secretAccessKey] = userinfo.split(':');
410
+ opts.accessKeyId = accessKeyId;
411
+ opts.secretAccessKey = secretAccessKey;
412
+ }
413
+ return new S3ReplicaClient(opts);
414
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.