@objy/server 0.0.0-stage → 0.1.0

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.
Files changed (55) hide show
  1. package/API.md +1469 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +9 -0
  4. package/README.md +21 -2
  5. package/objydb.config.json +48 -0
  6. package/package.json +42 -4
  7. package/public/index.html +4134 -0
  8. package/public/objy-db-logo.png +0 -0
  9. package/src/aggregate.js +438 -0
  10. package/src/api.js +15 -0
  11. package/src/auth.js +472 -0
  12. package/src/clock.js +18 -0
  13. package/src/cluster-secret.js +25 -0
  14. package/src/cluster-status.js +562 -0
  15. package/src/compression-stats.js +32 -0
  16. package/src/config.js +360 -0
  17. package/src/crdt.js +1076 -0
  18. package/src/derived-state-scheduler.js +128 -0
  19. package/src/files.js +324 -0
  20. package/src/hnsw.js +269 -0
  21. package/src/http-cors.js +31 -0
  22. package/src/http-router.js +162 -0
  23. package/src/index.js +137 -0
  24. package/src/interfaces/auth.js +123 -0
  25. package/src/interfaces/cluster.js +296 -0
  26. package/src/interfaces/public-object.js +2452 -0
  27. package/src/interfaces/ui.js +38 -0
  28. package/src/lib/crypto.cjs +54 -0
  29. package/src/lib/jwt.cjs +231 -0
  30. package/src/local-wal.js +122 -0
  31. package/src/memory-guard.js +152 -0
  32. package/src/ndjson.js +117 -0
  33. package/src/node-runtime.js +29 -0
  34. package/src/object-hooks.js +282 -0
  35. package/src/object-id.js +7 -0
  36. package/src/object-storage.js +263 -0
  37. package/src/object-store.js +288 -0
  38. package/src/patch.js +87 -0
  39. package/src/permissions.js +178 -0
  40. package/src/persistence.js +1918 -0
  41. package/src/plugin-loader.js +21 -0
  42. package/src/query-planner.js +266 -0
  43. package/src/query.js +416 -0
  44. package/src/references.js +349 -0
  45. package/src/replication-scheduler.js +303 -0
  46. package/src/restore-status.js +77 -0
  47. package/src/runtime.js +47 -0
  48. package/src/server.js +242 -0
  49. package/src/shape.js +286 -0
  50. package/src/shard-map-store.js +85 -0
  51. package/src/shard-map.js +333 -0
  52. package/src/shard-router.js +737 -0
  53. package/src/store.js +3410 -0
  54. package/src/system-secrets.js +107 -0
  55. package/src/vector.js +83 -0
package/src/ndjson.js ADDED
@@ -0,0 +1,117 @@
1
+ import { Readable } from "node:stream";
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ export const NDJSON_CONTENT_TYPE = "application/x-ndjson";
5
+
6
+ export function isNdjsonContentType(value) {
7
+ const type = String(value || "").toLowerCase().split(";")[0].trim();
8
+ return type === "application/x-ndjson" || type === "application/ndjson";
9
+ }
10
+
11
+ export function acceptsNdjson(headers = {}) {
12
+ return String(headers.accept || "").toLowerCase().includes("application/x-ndjson") ||
13
+ String(headers.accept || "").toLowerCase().includes("application/ndjson");
14
+ }
15
+
16
+ export function ndjsonReadable(values = []) {
17
+ return Readable.from(ndjsonLines(values));
18
+ }
19
+
20
+ export async function* parseNdjsonBatches(stream, {
21
+ maxBatchObjects = 1000,
22
+ maxBatchBytes = 0,
23
+ maxObjectBytes = 0,
24
+ label = "NDJSON",
25
+ } = {}) {
26
+ let batch = [];
27
+ let bytes = 0;
28
+ const maxObjects = Math.max(1, Number(maxBatchObjects) || 1000);
29
+ const maxBytes = Math.max(0, Number(maxBatchBytes) || 0);
30
+
31
+ for await (const entry of parseNdjsonStream(stream, { maxObjectBytes, label })) {
32
+ if (batch.length && maxBytes && bytes + entry.bytes > maxBytes) {
33
+ yield batch;
34
+ batch = [];
35
+ bytes = 0;
36
+ }
37
+ batch.push(entry.value);
38
+ bytes += entry.bytes;
39
+ if (batch.length >= maxObjects || (maxBytes && bytes >= maxBytes)) {
40
+ yield batch;
41
+ batch = [];
42
+ bytes = 0;
43
+ }
44
+ }
45
+
46
+ if (batch.length) yield batch;
47
+ }
48
+
49
+ export async function* parseNdjsonStream(stream, { maxObjectBytes = 0, label = "NDJSON" } = {}) {
50
+ const decoder = new StringDecoder("utf8");
51
+ let buffer = "";
52
+ let line = 0;
53
+ const maxBytes = Number(maxObjectBytes) || 0;
54
+
55
+ for await (const chunk of asyncIterableStream(stream)) {
56
+ buffer += decoder.write(Buffer.from(chunk));
57
+
58
+ let newline;
59
+ while ((newline = buffer.indexOf("\n")) !== -1) {
60
+ const rawLine = stripTrailingCarriageReturn(buffer.slice(0, newline));
61
+ buffer = buffer.slice(newline + 1);
62
+ line += 1;
63
+ if (!rawLine.trim()) continue;
64
+ yield parseNdjsonLine(rawLine, line, maxBytes, label);
65
+ }
66
+
67
+ if (maxBytes && Buffer.byteLength(buffer) > maxBytes) {
68
+ throw ndjsonError(413, "object is too large", {
69
+ line: line + 1,
70
+ bytes: Buffer.byteLength(buffer),
71
+ maxObjectBytes: maxBytes,
72
+ });
73
+ }
74
+ }
75
+
76
+ buffer += decoder.end();
77
+ if (buffer.length > 0) {
78
+ const rawLine = stripTrailingCarriageReturn(buffer);
79
+ line += 1;
80
+ if (rawLine.trim()) yield parseNdjsonLine(rawLine, line, maxBytes, label);
81
+ }
82
+ }
83
+
84
+ async function* ndjsonLines(values) {
85
+ for (const value of values || []) {
86
+ yield `${JSON.stringify(value)}\n`;
87
+ }
88
+ }
89
+
90
+ function asyncIterableStream(stream) {
91
+ if (stream?.[Symbol.asyncIterator]) return stream;
92
+ if (stream?.getReader) return Readable.fromWeb(stream);
93
+ return stream || [];
94
+ }
95
+
96
+ function parseNdjsonLine(rawLine, line, maxObjectBytes, label) {
97
+ const bytes = Buffer.byteLength(rawLine);
98
+ if (maxObjectBytes && bytes > maxObjectBytes) {
99
+ throw ndjsonError(413, "object is too large", { line, bytes, maxObjectBytes });
100
+ }
101
+ try {
102
+ return { line, bytes, value: JSON.parse(rawLine) };
103
+ } catch {
104
+ throw ndjsonError(400, `invalid ${label} at line ${line}`, { line });
105
+ }
106
+ }
107
+
108
+ function stripTrailingCarriageReturn(value) {
109
+ return value.endsWith("\r") ? value.slice(0, -1) : value;
110
+ }
111
+
112
+ function ndjsonError(status, message, body) {
113
+ const err = new Error(message);
114
+ err.status = status;
115
+ err.body = { error: message, ...body };
116
+ return err;
117
+ }
@@ -0,0 +1,29 @@
1
+ import { getHeapStatistics } from "node:v8";
2
+ import { constants, deflateSync, inflateSync } from "node:zlib";
3
+
4
+ export function encodeUtf8(value) {
5
+ return Buffer.from(String(value));
6
+ }
7
+
8
+ export function decodeUtf8(value) {
9
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("utf8");
10
+ }
11
+
12
+ export function compress(value) {
13
+ const compressed = deflateSync(value, { level: constants.Z_BEST_SPEED });
14
+ const compact = new Uint8Array(compressed.byteLength);
15
+ compact.set(compressed);
16
+ return compact;
17
+ }
18
+
19
+ export function decompress(value) {
20
+ return inflateSync(value);
21
+ }
22
+
23
+ export const nodeCompression = Object.freeze({ encodeUtf8, decodeUtf8, compress, decompress });
24
+
25
+ export function readHeapUsageRatio() {
26
+ const stats = getHeapStatistics();
27
+ const limit = Number(stats.heap_size_limit) || 0;
28
+ return limit > 0 ? (Number(stats.used_heap_size) || 0) / limit : 0;
29
+ }
@@ -0,0 +1,282 @@
1
+ const OPERATIONS = Object.freeze(["create", "read", "update", "delete"]);
2
+ const OPERATION_SET = new Set(OPERATIONS);
3
+ const META_KEYS = new Set(["enabled", "use", ...OPERATIONS]);
4
+ const EMPTY_CHAIN = Object.freeze([]);
5
+
6
+ export function createObjectHookRegistry() {
7
+ return new ObjectHookRegistry();
8
+ }
9
+
10
+ export function createObjectHookRunner(node, registry = createObjectHookRegistry()) {
11
+ if (!(registry instanceof ObjectHookRegistry)) {
12
+ throw new TypeError("objectHooks must be an ObjectHookRegistry");
13
+ }
14
+ return new ObjectHookRunner(node, registry);
15
+ }
16
+
17
+ export function normalizeObjectHooksMeta(value) {
18
+ if (value === undefined || value === null) return null;
19
+ if (!isPlainObject(value)) throw new TypeError("collection meta hooks must be a plain object");
20
+
21
+ for (const key of Object.keys(value)) {
22
+ if (!META_KEYS.has(key)) throw new TypeError(`collection meta hooks.${key} is not supported`);
23
+ }
24
+ if (value.enabled !== undefined && typeof value.enabled !== "boolean") {
25
+ throw new TypeError("collection meta hooks.enabled must be a boolean");
26
+ }
27
+
28
+ const out = { enabled: value.enabled !== false };
29
+ for (const key of ["use", ...OPERATIONS]) {
30
+ const names = normalizeNames(value[key], `hooks.${key}`);
31
+ if (names.length) out[key] = names;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ export class ObjectHookRegistry {
37
+ constructor() {
38
+ this.definitions = new Map();
39
+ this.revision = 0;
40
+ }
41
+
42
+ register(name, definition) {
43
+ const key = normalizeName(name, "hook name");
44
+ if (this.definitions.has(key)) throw new TypeError(`object hook is already registered: ${key}`);
45
+
46
+ const handler = typeof definition === "function" ? definition : definition?.handler;
47
+ if (typeof handler !== "function") throw new TypeError(`object hook ${key} requires a handler`);
48
+ const validateMeta = typeof definition === "function" ? null : definition.validateMeta;
49
+ if (validateMeta != null && typeof validateMeta !== "function") {
50
+ throw new TypeError(`object hook ${key} validateMeta must be a function`);
51
+ }
52
+ const operations = normalizeOperations(typeof definition === "function" ? undefined : definition.operations);
53
+ this.definitions.set(key, { name: key, handler, operations, validateMeta });
54
+ this.revision++;
55
+ return this;
56
+ }
57
+
58
+ get(name) {
59
+ return this.definitions.get(String(name));
60
+ }
61
+
62
+ get size() {
63
+ return this.definitions.size;
64
+ }
65
+ }
66
+
67
+ export class ObjectHookRunner {
68
+ constructor(node, registry) {
69
+ if (!node) throw new TypeError("node is required");
70
+ this.node = node;
71
+ this.registry = registry;
72
+ this.configs = new Map();
73
+ this.plans = new Map();
74
+ this.registryRevision = registry.revision;
75
+ this._loadExistingCollections();
76
+ this.unsubscribe = node.onChange?.((change) => this._onChange(change)) || null;
77
+ }
78
+
79
+ validateMeta(meta = {}) {
80
+ const config = normalizeObjectHooksMeta(meta?.hooks);
81
+ if (!config?.enabled) return config;
82
+ for (const name of configuredNames(config)) {
83
+ const hook = this.registry.get(name);
84
+ if (!hook) throw new TypeError(`object hook is not registered: ${name}`);
85
+ hook.validateMeta?.(meta);
86
+ }
87
+ for (const operation of OPERATIONS) {
88
+ for (const name of config[operation] || []) {
89
+ if (!this.registry.get(name)?.operations.has(operation)) {
90
+ throw new TypeError(`object hook ${name} does not support ${operation}`);
91
+ }
92
+ }
93
+ }
94
+ return config;
95
+ }
96
+
97
+ run({ operation, variant, database, collection, input, principal = null, batch = null, lookup = null }, execute) {
98
+ if (!OPERATION_SET.has(operation)) throw new TypeError(`unsupported object hook operation: ${operation}`);
99
+ if (typeof execute !== "function") throw new TypeError("object hook operation requires an executor");
100
+
101
+ const chain = this._plan(database, collection, operation);
102
+ if (!chain.length) return execute(input, null);
103
+
104
+ const context = {
105
+ operation,
106
+ variant,
107
+ database,
108
+ collection,
109
+ input,
110
+ principal: principal ? deepFreeze(structuredClone(principal)) : null,
111
+ lookup,
112
+ batch: batch ? Object.freeze({ ...batch }) : null,
113
+ state: Object.create(null),
114
+ count: inputCount(input),
115
+ result: undefined,
116
+ hooked: true,
117
+ };
118
+ let cursor = -1;
119
+
120
+ const dispatch = async (index) => {
121
+ if (index <= cursor) throw hookExecutionError("object hook next() called more than once");
122
+ cursor = index;
123
+ if (index === chain.length) {
124
+ context.result = await execute(context.input, context);
125
+ return context.result;
126
+ }
127
+
128
+ let called = false;
129
+ let nextPromise;
130
+ const next = () => {
131
+ if (called) throw hookExecutionError(`object hook ${chain[index].name} called next() more than once`);
132
+ called = true;
133
+ nextPromise = dispatch(index + 1);
134
+ return nextPromise;
135
+ };
136
+ try {
137
+ await chain[index].handler(context, next);
138
+ } catch (err) {
139
+ if (!err.status) err.status = 500;
140
+ throw err;
141
+ }
142
+ if (!called) throw hookExecutionError(`object hook ${chain[index].name} must call next()`);
143
+ return nextPromise;
144
+ };
145
+
146
+ return dispatch(0);
147
+ }
148
+
149
+ dispose() {
150
+ this.unsubscribe?.();
151
+ this.unsubscribe = null;
152
+ this.configs.clear();
153
+ this.plans.clear();
154
+ }
155
+
156
+ _plan(database, collection, operation) {
157
+ if (!this.registry.size) return EMPTY_CHAIN;
158
+ if (this.registryRevision !== this.registry.revision) {
159
+ this.registryRevision = this.registry.revision;
160
+ this.plans.clear();
161
+ for (const config of this.configs.values()) this.validateMeta({ hooks: config });
162
+ }
163
+
164
+ const scope = scopeKey(database, collection);
165
+ const key = `${scope}\0${operation}`;
166
+ if (this.plans.has(key)) return this.plans.get(key);
167
+ const config = this.configs.get(scope);
168
+ if (!config?.enabled) {
169
+ this.plans.set(key, EMPTY_CHAIN);
170
+ return EMPTY_CHAIN;
171
+ }
172
+
173
+ const chain = [];
174
+ for (const name of config.use || []) {
175
+ const hook = this.registry.get(name);
176
+ if (!hook) throw hookExecutionError(`object hook is not registered: ${name}`);
177
+ if (hook.operations.has(operation)) chain.push(hook);
178
+ }
179
+ for (const name of config[operation] || []) {
180
+ const hook = this.registry.get(name);
181
+ if (!hook) throw hookExecutionError(`object hook is not registered: ${name}`);
182
+ if (!hook.operations.has(operation)) throw hookExecutionError(`object hook ${name} does not support ${operation}`);
183
+ if (!chain.includes(hook)) chain.push(hook);
184
+ }
185
+ Object.freeze(chain);
186
+ this.plans.set(key, chain);
187
+ return chain;
188
+ }
189
+
190
+ _loadExistingCollections() {
191
+ for (const database of this.node.listDatabases?.() || []) {
192
+ for (const collection of this.node.listCollections?.(database) || []) {
193
+ const meta = this.node.getCollectionMeta(database, collection) || {};
194
+ const config = this.validateMeta(meta);
195
+ if (config) this.configs.set(scopeKey(database, collection), config);
196
+ }
197
+ }
198
+ }
199
+
200
+ _onChange(change = {}) {
201
+ if (change.kind === "meta") {
202
+ const key = scopeKey(change.database, change.collection);
203
+ const config = normalizeObjectHooksMeta(change.meta?.hooks);
204
+ if (config) this.configs.set(key, config);
205
+ else this.configs.delete(key);
206
+ this._invalidateScope(key);
207
+ return;
208
+ }
209
+ if (change.kind === "dropCollection") {
210
+ const key = scopeKey(change.database, change.collection);
211
+ this.configs.delete(key);
212
+ this._invalidateScope(key);
213
+ return;
214
+ }
215
+ if (change.kind === "dropDatabase") {
216
+ const prefix = `${change.database}\0`;
217
+ for (const key of this.configs.keys()) if (key.startsWith(prefix)) this.configs.delete(key);
218
+ for (const key of this.plans.keys()) if (key.startsWith(prefix)) this.plans.delete(key);
219
+ }
220
+ }
221
+
222
+ _invalidateScope(scope) {
223
+ for (const operation of OPERATIONS) this.plans.delete(`${scope}\0${operation}`);
224
+ }
225
+ }
226
+
227
+ function normalizeOperations(value) {
228
+ const operations = value === undefined || value === "all" ? OPERATIONS : value;
229
+ if (!Array.isArray(operations)) throw new TypeError("object hook operations must be an array or 'all'");
230
+ const normalized = unique(operations.map((operation) => String(operation)));
231
+ for (const operation of normalized) {
232
+ if (!OPERATION_SET.has(operation)) throw new TypeError(`unsupported object hook operation: ${operation}`);
233
+ }
234
+ return new Set(normalized);
235
+ }
236
+
237
+ function normalizeNames(value, label) {
238
+ if (value === undefined || value === null) return [];
239
+ if (!Array.isArray(value)) throw new TypeError(`collection meta ${label} must be an array`);
240
+ return unique(value.map((name) => normalizeName(name, label)));
241
+ }
242
+
243
+ function normalizeName(value, label) {
244
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${label} must contain non-empty strings`);
245
+ return value.trim();
246
+ }
247
+
248
+ function configuredNames(config) {
249
+ return unique([...(config.use || []), ...OPERATIONS.flatMap((operation) => config[operation] || [])]);
250
+ }
251
+
252
+ function inputCount(input = {}) {
253
+ for (const key of ["objects", "patches", "ids"]) {
254
+ if (Array.isArray(input[key])) return input[key].length;
255
+ }
256
+ return 0;
257
+ }
258
+
259
+ function scopeKey(database, collection) {
260
+ return `${database}\0${collection}`;
261
+ }
262
+
263
+ function unique(values) {
264
+ return [...new Set(values)];
265
+ }
266
+
267
+ function isPlainObject(value) {
268
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
269
+ }
270
+
271
+ function deepFreeze(value) {
272
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
273
+ Object.freeze(value);
274
+ for (const item of Object.values(value)) deepFreeze(item);
275
+ return value;
276
+ }
277
+
278
+ function hookExecutionError(message) {
279
+ const err = new TypeError(message);
280
+ err.status = 500;
281
+ return err;
282
+ }
@@ -0,0 +1,7 @@
1
+ import { randomBase64Url } from "./runtime.js";
2
+
3
+ const DEFAULT_BYTES = 12;
4
+
5
+ export function createObjectId(bytes = DEFAULT_BYTES) {
6
+ return randomBase64Url(bytes);
7
+ }
@@ -0,0 +1,263 @@
1
+ import {
2
+ CreateBucketCommand,
3
+ DeleteObjectsCommand,
4
+ GetObjectCommand,
5
+ HeadBucketCommand,
6
+ ListObjectsV2Command,
7
+ PutObjectCommand,
8
+ S3Client,
9
+ } from "@aws-sdk/client-s3";
10
+ import { Readable } from "node:stream";
11
+
12
+ export class MemoryObjectStorage {
13
+ constructor() {
14
+ this.objects = new Map();
15
+ }
16
+
17
+ async put(key, body, contentType = "application/json") {
18
+ this.objects.set(key, {
19
+ body: typeof body === "string" ? body : JSON.stringify(body),
20
+ contentType,
21
+ });
22
+ }
23
+
24
+ async putIfAbsent(key, body, contentType = "application/json") {
25
+ if (this.objects.has(key)) return false;
26
+ await this.put(key, body, contentType);
27
+ return true;
28
+ }
29
+
30
+ async putBlob(key, body, contentType = "application/octet-stream") {
31
+ this.objects.set(key, {
32
+ body: Buffer.isBuffer(body) ? Buffer.from(body) : Buffer.from(body || ""),
33
+ contentType,
34
+ });
35
+ }
36
+
37
+ async get(key) {
38
+ return this.objects.get(key)?.body ?? null;
39
+ }
40
+
41
+ async getStream(key) {
42
+ const body = this.objects.get(key)?.body;
43
+ return body === undefined ? null : Readable.from([body]);
44
+ }
45
+
46
+ async getBlob(key) {
47
+ const object = this.objects.get(key);
48
+ if (!object) return null;
49
+ const body = Buffer.isBuffer(object.body) ? Buffer.from(object.body) : Buffer.from(String(object.body));
50
+ return {
51
+ body,
52
+ contentType: object.contentType || "application/octet-stream",
53
+ contentLength: body.length,
54
+ };
55
+ }
56
+
57
+ async list(prefix = "") {
58
+ return [...this.objects.keys()].filter((key) => key.startsWith(prefix)).sort();
59
+ }
60
+
61
+ async delete(keys) {
62
+ let count = 0;
63
+ for (const key of keys) {
64
+ if (this.objects.delete(key)) count++;
65
+ }
66
+ return count;
67
+ }
68
+
69
+ async deletePrefix(prefix) {
70
+ let count = 0;
71
+ for (const key of [...this.objects.keys()]) {
72
+ if (!key.startsWith(prefix)) continue;
73
+ this.objects.delete(key);
74
+ count++;
75
+ }
76
+ return count;
77
+ }
78
+ }
79
+
80
+ export class S3ObjectStorage {
81
+ constructor({
82
+ bucket,
83
+ endpoint,
84
+ region = "us-east-1",
85
+ accessKeyId,
86
+ secretAccessKey,
87
+ forcePathStyle,
88
+ } = {}) {
89
+ if (!bucket) throw new Error("S3 bucket is required");
90
+ this.bucket = bucket;
91
+ this.region = region;
92
+ this.ready = false;
93
+ this.client = new S3Client({
94
+ region,
95
+ ...(endpoint ? { endpoint, forcePathStyle: forcePathStyle ?? true } : {}),
96
+ ...(accessKeyId && secretAccessKey
97
+ ? { credentials: { accessKeyId, secretAccessKey } }
98
+ : {}),
99
+ });
100
+ }
101
+
102
+ async ensureBucket() {
103
+ if (this.ready) return;
104
+ try {
105
+ await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
106
+ } catch (err) {
107
+ const code = err.$metadata?.httpStatusCode;
108
+ if (code !== 404 && err.name !== "NotFound" && err.name !== "NoSuchBucket") throw err;
109
+ await this.client.send(new CreateBucketCommand({
110
+ Bucket: this.bucket,
111
+ ...createBucketLocation(this.region),
112
+ }));
113
+ }
114
+ this.ready = true;
115
+ }
116
+
117
+ async put(key, body, contentType = "application/json") {
118
+ await this.ensureBucket();
119
+ await this.client.send(new PutObjectCommand({
120
+ Bucket: this.bucket,
121
+ Key: key,
122
+ Body: typeof body === "string" ? body : JSON.stringify(body),
123
+ ContentType: contentType,
124
+ }));
125
+ }
126
+
127
+ async putIfAbsent(key, body, contentType = "application/json") {
128
+ await this.ensureBucket();
129
+ try {
130
+ await this.client.send(new PutObjectCommand({
131
+ Bucket: this.bucket,
132
+ Key: key,
133
+ Body: typeof body === "string" ? body : JSON.stringify(body),
134
+ ContentType: contentType,
135
+ IfNoneMatch: "*",
136
+ }));
137
+ return true;
138
+ } catch (err) {
139
+ const status = err.$metadata?.httpStatusCode;
140
+ if (status === 409 || status === 412 || err.name === "PreconditionFailed") return false;
141
+ throw err;
142
+ }
143
+ }
144
+
145
+ async putBlob(key, body, contentType = "application/octet-stream") {
146
+ await this.ensureBucket();
147
+ await this.client.send(new PutObjectCommand({
148
+ Bucket: this.bucket,
149
+ Key: key,
150
+ Body: body,
151
+ ContentType: contentType,
152
+ }));
153
+ }
154
+
155
+ async get(key) {
156
+ await this.ensureBucket();
157
+ try {
158
+ const got = await this.client.send(new GetObjectCommand({
159
+ Bucket: this.bucket,
160
+ Key: key,
161
+ }));
162
+ return bodyToString(got.Body);
163
+ } catch (err) {
164
+ const code = err.$metadata?.httpStatusCode;
165
+ if (code === 404 || err.name === "NoSuchKey" || err.name === "NotFound") return null;
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ async getStream(key) {
171
+ await this.ensureBucket();
172
+ try {
173
+ const got = await this.client.send(new GetObjectCommand({
174
+ Bucket: this.bucket,
175
+ Key: key,
176
+ }));
177
+ return got.Body || null;
178
+ } catch (err) {
179
+ const code = err.$metadata?.httpStatusCode;
180
+ if (code === 404 || err.name === "NoSuchKey" || err.name === "NotFound") return null;
181
+ throw err;
182
+ }
183
+ }
184
+
185
+ async getBlob(key) {
186
+ await this.ensureBucket();
187
+ try {
188
+ const got = await this.client.send(new GetObjectCommand({
189
+ Bucket: this.bucket,
190
+ Key: key,
191
+ }));
192
+ return {
193
+ body: got.Body,
194
+ contentType: got.ContentType || "application/octet-stream",
195
+ contentLength: got.ContentLength,
196
+ };
197
+ } catch (err) {
198
+ const code = err.$metadata?.httpStatusCode;
199
+ if (code === 404 || err.name === "NoSuchKey" || err.name === "NotFound") return null;
200
+ throw err;
201
+ }
202
+ }
203
+
204
+ async list(prefix = "") {
205
+ await this.ensureBucket();
206
+ const keys = [];
207
+ let ContinuationToken;
208
+ do {
209
+ const res = await this.client.send(new ListObjectsV2Command({
210
+ Bucket: this.bucket,
211
+ Prefix: prefix,
212
+ ContinuationToken,
213
+ }));
214
+ for (const item of res.Contents || []) if (item.Key) keys.push(item.Key);
215
+ ContinuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
216
+ } while (ContinuationToken);
217
+ return keys.sort();
218
+ }
219
+
220
+ async delete(keys) {
221
+ await this.ensureBucket();
222
+ let count = 0;
223
+ for (let i = 0; i < keys.length; i += 1000) {
224
+ const chunk = keys.slice(i, i + 1000);
225
+ if (!chunk.length) continue;
226
+ await this.client.send(new DeleteObjectsCommand({
227
+ Bucket: this.bucket,
228
+ Delete: { Objects: chunk.map((Key) => ({ Key })), Quiet: true },
229
+ }));
230
+ count += chunk.length;
231
+ }
232
+ return count;
233
+ }
234
+
235
+ async deletePrefix(prefix) {
236
+ const keys = await this.list(prefix);
237
+ return this.delete(keys);
238
+ }
239
+ }
240
+
241
+ export function createObjectStorage({ mode = "off", s3 = {} } = {}) {
242
+ if (mode === "memory") return new MemoryObjectStorage();
243
+ if (mode === "s3") return new S3ObjectStorage(s3);
244
+ if (mode === "off" || mode === false) return null;
245
+ throw new Error(`unsupported object storage mode: ${mode}`);
246
+ }
247
+
248
+ function createBucketLocation(region) {
249
+ if (!region || region === "us-east-1") return {};
250
+ return {
251
+ CreateBucketConfiguration: {
252
+ LocationConstraint: region,
253
+ },
254
+ };
255
+ }
256
+
257
+ async function bodyToString(body) {
258
+ if (!body) return "";
259
+ if (typeof body.transformToString === "function") return body.transformToString();
260
+ const chunks = [];
261
+ for await (const chunk of body) chunks.push(Buffer.from(chunk));
262
+ return Buffer.concat(chunks).toString("utf8");
263
+ }