zerobucket 0.0.0-stage → 0.4.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.
@@ -0,0 +1,307 @@
1
+ import { S as StorageBackend, O as OperationEvent, a as ObjectStorageLike, P as PreparedRow, C as CallOptions, b as StoredRecord, c as StoredRecordMetadata, d as StreamOptions, e as ContentValidator, V as ValidatedContent } from './client-WZ4VRCPi.cjs';
2
+ export { B as BatchDeleteResult, f as BatchGetResult, g as BatchPutResult, h as BeforeGetHook, i as BeforePutHook, j as Connection, D as DEFAULT_MAX_BYTES, G as GetOptions, I as Image, k as ImageInput, l as ImageMetadata, m as ObjectStorage, n as ObjectStorageOptions, o as OptimizationResult, p as OptimizeOptions, q as PutManyOptions, r as PutOptions, Q as Queryable, s as SqliteDatabaseLike, t as StreamOptions, W as WritableLike, Z as ZeroBucket, u as ZeroBucketOptions, v as optimizeImage, w as readInput } from './client-WZ4VRCPi.cjs';
3
+ import { Pool } from 'pg';
4
+
5
+ /**
6
+ * Error hierarchy for ZeroBucket. Mirrors the Python package's exceptions
7
+ * one-to-one so behaviour is portable across languages.
8
+ *
9
+ * Catch broadly with `instanceof ZeroBucketError`, or narrowly with a
10
+ * subclass such as `ImageNotFoundError`.
11
+ */
12
+ declare class ZeroBucketError extends Error {
13
+ constructor(message: string, options?: {
14
+ cause?: unknown;
15
+ });
16
+ }
17
+ /** Any content (image or custom-validated) failed validation. */
18
+ declare class ContentValidationError extends ZeroBucketError {
19
+ }
20
+ /** An image failed validation (bad format, too large, corrupted, ...). */
21
+ declare class ImageValidationError extends ContentValidationError {
22
+ }
23
+ declare class ImageTooLargeError extends ImageValidationError {
24
+ readonly sizeBytes: number;
25
+ readonly maxBytes: number;
26
+ constructor(sizeBytes: number, maxBytes: number);
27
+ }
28
+ declare class UnsupportedFormatError extends ImageValidationError {
29
+ readonly detectedFormat: string | null;
30
+ readonly allowed: readonly string[];
31
+ constructor(detectedFormat: string | null, allowed: readonly string[]);
32
+ }
33
+ /** Image bytes could not be decoded despite a recognizable header. */
34
+ declare class CorruptedImageError extends ImageValidationError {
35
+ }
36
+ /** `get()`, `getStream()` or `metadata()` was called with an id that does not exist. */
37
+ declare class ImageNotFoundError extends ZeroBucketError {
38
+ readonly imageId: string;
39
+ constructor(imageId: string);
40
+ }
41
+ /** Underlying storage / database failure not covered above. */
42
+ declare class StorageError extends ZeroBucketError {
43
+ }
44
+ /**
45
+ * A `beforeGet` / `beforePut` hook denied the operation by returning false.
46
+ *
47
+ * If the hook itself throws, that exception propagates unchanged: it is NOT
48
+ * wrapped in AccessDeniedError and NOT treated as an implicit allow. A broken
49
+ * authorization check must fail closed, never open.
50
+ */
51
+ declare class AccessDeniedError extends ZeroBucketError {
52
+ readonly operation: string;
53
+ readonly imageId?: string | undefined;
54
+ constructor(operation: string, imageId?: string | undefined);
55
+ }
56
+
57
+ /** Helpers shared by every storage adapter (kept here so Postgres and SQLite cannot drift apart). */
58
+
59
+ /** 1 MiB. Default chunk size for getStream(). */
60
+ declare const DEFAULT_STREAM_CHUNK_SIZE: number;
61
+
62
+ /**
63
+ * PostgreSQL storage adapter (classic mode).
64
+ *
65
+ * Uses the EXACT schema the Python package creates (zerobucket_images, including
66
+ * the object-storage tiering columns and CHECK constraint), so a Node app and a
67
+ * Python app can share one database and read each other's rows.
68
+ *
69
+ * Lessons from the Python build, applied here from day one:
70
+ * - Retry re-runs the WHOLE unit of work on a fresh connection after a full
71
+ * ROLLBACK. It never replays a single statement of a multi-statement
72
+ * transaction (the "half-applied put" hazard).
73
+ * - Retry never applies when the caller passes `connection` (the caller owns
74
+ * the transaction; restarting it is their decision).
75
+ * - Pool acquire has an explicit timeout.
76
+ * - Migration runs under an advisory lock so N concurrent first-callers do not
77
+ * race on CREATE TABLE.
78
+ */
79
+
80
+ interface PostgresBackendOptions {
81
+ /** postgresql:// connection string. Required unless `pool` is given. */
82
+ connectionString?: string;
83
+ /** Bring your own pg.Pool (not closed by `close()`). Pool sizing options are then ignored. */
84
+ pool?: Pool;
85
+ /** Create the schema on first use. Default true. */
86
+ autoMigrate?: boolean;
87
+ /** Retries for transient errors on pooled calls. Default 3; 0 disables. */
88
+ maxRetries?: number;
89
+ /** Base for exponential backoff with jitter (capped at 2s). Default 100. */
90
+ retryBaseDelayMs?: number;
91
+ poolMinSize?: number;
92
+ poolMaxSize?: number;
93
+ /** Max wait for a pooled connection. Default 10000. */
94
+ poolTimeoutMs?: number;
95
+ onOperation?: (event: OperationEvent) => void;
96
+ /**
97
+ * Enables transparent reads of tiered rows and `tierToObjectStorage()`.
98
+ * Configure it with the same bucket/credentials used to tier the images.
99
+ */
100
+ objectStorage?: ObjectStorageLike;
101
+ /**
102
+ * Content-addressed mode: identical bytes are stored once (zerobucket_blobs) and each
103
+ * put() adds a lightweight reference (zerobucket_image_refs) with its own id and filename.
104
+ * Separate tables from classic mode, so the two can never be confused. Cannot be combined
105
+ * with `objectStorage`. Same schema as the Python package's dedup mode.
106
+ */
107
+ dedup?: boolean;
108
+ }
109
+ declare class PostgresBackend implements StorageBackend {
110
+ private readonly opts;
111
+ private pool;
112
+ private readonly ownsPool;
113
+ private readyPromise;
114
+ private closed;
115
+ private readonly maxRetries;
116
+ private readonly retryBaseDelayMs;
117
+ private readonly autoMigrate;
118
+ private readonly dedup;
119
+ private readonly sql;
120
+ constructor(opts: PostgresBackendOptions);
121
+ ready(): Promise<void>;
122
+ private init;
123
+ /** Create the schema if missing. Serialized across processes with an advisory lock. */
124
+ migrate(): Promise<void>;
125
+ close(): Promise<void>;
126
+ private emit;
127
+ /**
128
+ * Run `work` and return its result, emitting exactly one OperationEvent.
129
+ *
130
+ * connection given -> run once on it, no BEGIN/COMMIT, no retry.
131
+ * connection absent -> pooled; `tx` wraps the work in BEGIN/COMMIT; transient
132
+ * failures retry the WHOLE closure on a fresh connection.
133
+ */
134
+ private run;
135
+ /**
136
+ * Make multi-statement work all-or-nothing on a CALLER-supplied connection.
137
+ * - Caller has an open transaction -> SAVEPOINT: a failure rolls back only our work and the
138
+ * caller's transaction stays usable; success joins their transaction.
139
+ * - No open transaction (an autocommit pg.Client) -> open and commit our own, so a failure
140
+ * can never leave half-applied state (e.g. an inflated dedup ref_count).
141
+ */
142
+ private atomic;
143
+ put(row: PreparedRow, { connection }?: CallOptions): Promise<string>;
144
+ putMany(rows: PreparedRow[], { connection }?: CallOptions): Promise<string[]>;
145
+ get(id: string, { connection }?: CallOptions): Promise<StoredRecord | null>;
146
+ getMany(ids: string[], { connection }?: CallOptions): Promise<StoredRecord[]>;
147
+ getMetadata(id: string, { connection }?: CallOptions): Promise<StoredRecordMetadata | null>;
148
+ getStream(id: string, opts: StreamOptions): Promise<AsyncIterable<Buffer> | null>;
149
+ delete(id: string, { connection }?: CallOptions): Promise<boolean>;
150
+ deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
151
+ exists(id: string, { connection }?: CallOptions): Promise<boolean>;
152
+ private deleteDedup;
153
+ private deleteManyDedup;
154
+ /**
155
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
156
+ * dedup instance's tables, preserving every id (external references keep working) and
157
+ * deduplicating identical content on the way. The classic table is never modified.
158
+ *
159
+ * Differences from the Python version, all deliberate: rows are copied in small batches (bounded
160
+ * memory instead of loading the whole table); re-running is safe (already-migrated ids are
161
+ * skipped and reported, not an error); and classic rows already tiered to object storage are
162
+ * refused up front with a clear message (their bytes are not in the database to copy).
163
+ * Still all-or-nothing: one transaction.
164
+ */
165
+ migrateClassicToDedup(): Promise<{
166
+ imagesMigrated: number;
167
+ distinctBlobsCreated: number;
168
+ duplicateReferencesFound: number;
169
+ alreadyMigrated: number;
170
+ }>;
171
+ private hydrate;
172
+ /**
173
+ * Remove tiered objects AFTER their rows are gone. Ordering is deliberate:
174
+ * once the row is deleted the image is correctly "not found" to everyone, so
175
+ * a failed object delete only leaves a harmless orphan, whereas deleting the
176
+ * object first and then failing the row delete would leave a row pointing at nothing.
177
+ *
178
+ * Never runs inside a caller-owned transaction (`connection`): we cannot know
179
+ * whether it will commit, and destroying data before the commit outcome is known
180
+ * could leave a rolled-back row pointing at a deleted object. In that case the
181
+ * object stays until you remove it yourself after committing (objectStorage.delete(id)).
182
+ * Failures are reported through onOperation and never fail the delete itself.
183
+ */
184
+ private cleanupObject;
185
+ /**
186
+ * Move an image's bytes out of Postgres into object storage, replacing the
187
+ * row's `data` with a pointer. Returns:
188
+ * null - no such image
189
+ * false - already tiered (a safe no-op, so backfills can be re-run)
190
+ * true - tiered just now
191
+ *
192
+ * SAFETY: the upload happens INSIDE the same transaction as the row lock
193
+ * (SELECT ... FOR UPDATE) and the UPDATE that flips the row. If the upload
194
+ * fails, the transaction rolls back and the row is untouched: there is no
195
+ * moment when the bytes exist nowhere, or when a row claims to be tiered
196
+ * without a completed upload.
197
+ *
198
+ * TRADEOFF: the row lock is held for the whole upload. Concurrent writes
199
+ * to THIS image (delete, tier) wait; every other row is unaffected, and plain
200
+ * reads are never blocked (Postgres MVCC). Meant for explicit maintenance, not hot paths.
201
+ *
202
+ * Retry is safe: a transient error replays the whole closure, and the
203
+ * deterministic key (the image id) makes the re-upload an idempotent overwrite.
204
+ */
205
+ tierToObjectStorage(id: string, { connection }?: CallOptions): Promise<boolean | null>;
206
+ }
207
+
208
+ interface SQLiteBackendOptions {
209
+ /** Database file path, or ":memory:" for a throwaway in-process database (tiering is unavailable for ":memory:"). */
210
+ path: string;
211
+ /** Create the schema on first use. Default true. */
212
+ autoMigrate?: boolean;
213
+ /** Content-addressed mode (separate tables; same schema as the Python package). Cannot be combined with objectStorage. */
214
+ dedup?: boolean;
215
+ /** Enables tiering and transparent reads of tiered rows. */
216
+ objectStorage?: ObjectStorageLike;
217
+ /** Total time to keep retrying when another connection/process holds the write lock. Default 10000. */
218
+ busyTimeoutMs?: number;
219
+ onOperation?: (event: OperationEvent) => void;
220
+ }
221
+ declare class SQLiteBackend implements StorageBackend {
222
+ private readonly opts;
223
+ private db;
224
+ private Database;
225
+ private readyPromise;
226
+ private closed;
227
+ private readonly dedup;
228
+ private readonly busyTimeoutMs;
229
+ private readonly writeLock;
230
+ constructor(opts: SQLiteBackendOptions);
231
+ ready(): Promise<void>;
232
+ private init;
233
+ close(): Promise<void>;
234
+ private emit;
235
+ /** Re-run `fn` while another connection holds a lock, sleeping asynchronously between attempts. `fn` must be atomic. */
236
+ private retrying;
237
+ /** BEGIN IMMEDIATE takes the write lock up front, so a BUSY comes before any work and the whole unit is safely re-runnable. */
238
+ private tx;
239
+ /**
240
+ * All-or-nothing work on a CALLER-supplied handle. SAVEPOINT works whether or not the caller has an open
241
+ * transaction: inside one it nests (a failure rolls back only our work); outside one it begins and, on
242
+ * RELEASE, commits.
243
+ */
244
+ private atomic;
245
+ private execute;
246
+ put(row: PreparedRow, { connection }?: CallOptions): Promise<string>;
247
+ private insertOne;
248
+ putMany(rows: PreparedRow[], { connection }?: CallOptions): Promise<string[]>;
249
+ get(id: string, { connection }?: CallOptions): Promise<StoredRecord | null>;
250
+ getMany(ids: string[], { connection }?: CallOptions): Promise<StoredRecord[]>;
251
+ getMetadata(id: string, { connection }?: CallOptions): Promise<StoredRecordMetadata | null>;
252
+ getStream(id: string, opts: StreamOptions): Promise<AsyncIterable<Buffer> | null>;
253
+ delete(id: string, { connection }?: CallOptions): Promise<boolean>;
254
+ deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
255
+ exists(id: string, { connection }?: CallOptions): Promise<boolean>;
256
+ /**
257
+ * Remove tiered objects AFTER their rows are gone (a failed object delete leaves a harmless orphan; the
258
+ * reverse order could leave a row pointing at nothing). Skipped inside a caller-owned transaction, since the
259
+ * commit outcome is unknown. Failures never fail the delete; they are reported through onOperation.
260
+ */
261
+ private cleanupObjects;
262
+ /**
263
+ * Move an image's bytes into object storage. null = no such image, false = already tiered, true = tiered now.
264
+ *
265
+ * SAFETY (same guarantee as Postgres): the upload happens INSIDE a BEGIN IMMEDIATE transaction; if it fails the
266
+ * transaction rolls back and the row is untouched.
267
+ *
268
+ * TRADEOFF (coarser than Postgres): SQLite has no row locks, so BEGIN IMMEDIATE locks the WHOLE DATABASE for the
269
+ * upload. Other processes' writes wait; this process's writes queue behind an async lock; reads are not blocked.
270
+ */
271
+ tierToObjectStorage(id: string, { connection }?: CallOptions): Promise<boolean | null>;
272
+ }
273
+
274
+ declare const SUPPORTED_FORMATS: readonly string[];
275
+ /** Reject images that would decode to more pixels than this, regardless of compressed size (~89 MP). */
276
+ declare const DEFAULT_MAX_PIXELS = 89000000;
277
+ interface ValidatedImage {
278
+ mimeType: string;
279
+ width: number;
280
+ height: number;
281
+ sizeBytes: number;
282
+ }
283
+ declare function validateImage(data: Buffer, opts: {
284
+ maxBytes: number;
285
+ maxPixels?: number;
286
+ allowedFormats?: readonly string[];
287
+ }): Promise<ValidatedImage>;
288
+
289
+ /**
290
+ * Validates that bytes look like a PDF: `%PDF-` magic bytes, non-empty, under a
291
+ * size ceiling. Deliberately minimal.
292
+ *
293
+ * Security note: this does NOT parse the PDF object structure and does not
294
+ * detect embedded JavaScript, forms or launch actions. Treat it as a sanity
295
+ * gate, not a security boundary, if you serve user-uploaded PDFs to other users.
296
+ */
297
+ declare class PDFValidator implements ContentValidator {
298
+ private readonly opts;
299
+ constructor(opts?: {
300
+ maxBytes?: number;
301
+ });
302
+ validate(data: Buffer, { maxBytes }: {
303
+ maxBytes: number;
304
+ }): ValidatedContent;
305
+ }
306
+
307
+ export { AccessDeniedError, CallOptions, ContentValidationError, ContentValidator, CorruptedImageError, DEFAULT_MAX_PIXELS, DEFAULT_STREAM_CHUNK_SIZE, ImageNotFoundError, ImageTooLargeError, ImageValidationError, ObjectStorageLike, OperationEvent, PDFValidator, PostgresBackend, type PostgresBackendOptions, PreparedRow, SQLiteBackend, type SQLiteBackendOptions, SUPPORTED_FORMATS, StorageBackend, StorageError, StoredRecord, StoredRecordMetadata, UnsupportedFormatError, ValidatedContent, type ValidatedImage, ZeroBucketError, validateImage };
@@ -0,0 +1,307 @@
1
+ import { S as StorageBackend, O as OperationEvent, a as ObjectStorageLike, P as PreparedRow, C as CallOptions, b as StoredRecord, c as StoredRecordMetadata, d as StreamOptions, e as ContentValidator, V as ValidatedContent } from './client-WZ4VRCPi.js';
2
+ export { B as BatchDeleteResult, f as BatchGetResult, g as BatchPutResult, h as BeforeGetHook, i as BeforePutHook, j as Connection, D as DEFAULT_MAX_BYTES, G as GetOptions, I as Image, k as ImageInput, l as ImageMetadata, m as ObjectStorage, n as ObjectStorageOptions, o as OptimizationResult, p as OptimizeOptions, q as PutManyOptions, r as PutOptions, Q as Queryable, s as SqliteDatabaseLike, t as StreamOptions, W as WritableLike, Z as ZeroBucket, u as ZeroBucketOptions, v as optimizeImage, w as readInput } from './client-WZ4VRCPi.js';
3
+ import { Pool } from 'pg';
4
+
5
+ /**
6
+ * Error hierarchy for ZeroBucket. Mirrors the Python package's exceptions
7
+ * one-to-one so behaviour is portable across languages.
8
+ *
9
+ * Catch broadly with `instanceof ZeroBucketError`, or narrowly with a
10
+ * subclass such as `ImageNotFoundError`.
11
+ */
12
+ declare class ZeroBucketError extends Error {
13
+ constructor(message: string, options?: {
14
+ cause?: unknown;
15
+ });
16
+ }
17
+ /** Any content (image or custom-validated) failed validation. */
18
+ declare class ContentValidationError extends ZeroBucketError {
19
+ }
20
+ /** An image failed validation (bad format, too large, corrupted, ...). */
21
+ declare class ImageValidationError extends ContentValidationError {
22
+ }
23
+ declare class ImageTooLargeError extends ImageValidationError {
24
+ readonly sizeBytes: number;
25
+ readonly maxBytes: number;
26
+ constructor(sizeBytes: number, maxBytes: number);
27
+ }
28
+ declare class UnsupportedFormatError extends ImageValidationError {
29
+ readonly detectedFormat: string | null;
30
+ readonly allowed: readonly string[];
31
+ constructor(detectedFormat: string | null, allowed: readonly string[]);
32
+ }
33
+ /** Image bytes could not be decoded despite a recognizable header. */
34
+ declare class CorruptedImageError extends ImageValidationError {
35
+ }
36
+ /** `get()`, `getStream()` or `metadata()` was called with an id that does not exist. */
37
+ declare class ImageNotFoundError extends ZeroBucketError {
38
+ readonly imageId: string;
39
+ constructor(imageId: string);
40
+ }
41
+ /** Underlying storage / database failure not covered above. */
42
+ declare class StorageError extends ZeroBucketError {
43
+ }
44
+ /**
45
+ * A `beforeGet` / `beforePut` hook denied the operation by returning false.
46
+ *
47
+ * If the hook itself throws, that exception propagates unchanged: it is NOT
48
+ * wrapped in AccessDeniedError and NOT treated as an implicit allow. A broken
49
+ * authorization check must fail closed, never open.
50
+ */
51
+ declare class AccessDeniedError extends ZeroBucketError {
52
+ readonly operation: string;
53
+ readonly imageId?: string | undefined;
54
+ constructor(operation: string, imageId?: string | undefined);
55
+ }
56
+
57
+ /** Helpers shared by every storage adapter (kept here so Postgres and SQLite cannot drift apart). */
58
+
59
+ /** 1 MiB. Default chunk size for getStream(). */
60
+ declare const DEFAULT_STREAM_CHUNK_SIZE: number;
61
+
62
+ /**
63
+ * PostgreSQL storage adapter (classic mode).
64
+ *
65
+ * Uses the EXACT schema the Python package creates (zerobucket_images, including
66
+ * the object-storage tiering columns and CHECK constraint), so a Node app and a
67
+ * Python app can share one database and read each other's rows.
68
+ *
69
+ * Lessons from the Python build, applied here from day one:
70
+ * - Retry re-runs the WHOLE unit of work on a fresh connection after a full
71
+ * ROLLBACK. It never replays a single statement of a multi-statement
72
+ * transaction (the "half-applied put" hazard).
73
+ * - Retry never applies when the caller passes `connection` (the caller owns
74
+ * the transaction; restarting it is their decision).
75
+ * - Pool acquire has an explicit timeout.
76
+ * - Migration runs under an advisory lock so N concurrent first-callers do not
77
+ * race on CREATE TABLE.
78
+ */
79
+
80
+ interface PostgresBackendOptions {
81
+ /** postgresql:// connection string. Required unless `pool` is given. */
82
+ connectionString?: string;
83
+ /** Bring your own pg.Pool (not closed by `close()`). Pool sizing options are then ignored. */
84
+ pool?: Pool;
85
+ /** Create the schema on first use. Default true. */
86
+ autoMigrate?: boolean;
87
+ /** Retries for transient errors on pooled calls. Default 3; 0 disables. */
88
+ maxRetries?: number;
89
+ /** Base for exponential backoff with jitter (capped at 2s). Default 100. */
90
+ retryBaseDelayMs?: number;
91
+ poolMinSize?: number;
92
+ poolMaxSize?: number;
93
+ /** Max wait for a pooled connection. Default 10000. */
94
+ poolTimeoutMs?: number;
95
+ onOperation?: (event: OperationEvent) => void;
96
+ /**
97
+ * Enables transparent reads of tiered rows and `tierToObjectStorage()`.
98
+ * Configure it with the same bucket/credentials used to tier the images.
99
+ */
100
+ objectStorage?: ObjectStorageLike;
101
+ /**
102
+ * Content-addressed mode: identical bytes are stored once (zerobucket_blobs) and each
103
+ * put() adds a lightweight reference (zerobucket_image_refs) with its own id and filename.
104
+ * Separate tables from classic mode, so the two can never be confused. Cannot be combined
105
+ * with `objectStorage`. Same schema as the Python package's dedup mode.
106
+ */
107
+ dedup?: boolean;
108
+ }
109
+ declare class PostgresBackend implements StorageBackend {
110
+ private readonly opts;
111
+ private pool;
112
+ private readonly ownsPool;
113
+ private readyPromise;
114
+ private closed;
115
+ private readonly maxRetries;
116
+ private readonly retryBaseDelayMs;
117
+ private readonly autoMigrate;
118
+ private readonly dedup;
119
+ private readonly sql;
120
+ constructor(opts: PostgresBackendOptions);
121
+ ready(): Promise<void>;
122
+ private init;
123
+ /** Create the schema if missing. Serialized across processes with an advisory lock. */
124
+ migrate(): Promise<void>;
125
+ close(): Promise<void>;
126
+ private emit;
127
+ /**
128
+ * Run `work` and return its result, emitting exactly one OperationEvent.
129
+ *
130
+ * connection given -> run once on it, no BEGIN/COMMIT, no retry.
131
+ * connection absent -> pooled; `tx` wraps the work in BEGIN/COMMIT; transient
132
+ * failures retry the WHOLE closure on a fresh connection.
133
+ */
134
+ private run;
135
+ /**
136
+ * Make multi-statement work all-or-nothing on a CALLER-supplied connection.
137
+ * - Caller has an open transaction -> SAVEPOINT: a failure rolls back only our work and the
138
+ * caller's transaction stays usable; success joins their transaction.
139
+ * - No open transaction (an autocommit pg.Client) -> open and commit our own, so a failure
140
+ * can never leave half-applied state (e.g. an inflated dedup ref_count).
141
+ */
142
+ private atomic;
143
+ put(row: PreparedRow, { connection }?: CallOptions): Promise<string>;
144
+ putMany(rows: PreparedRow[], { connection }?: CallOptions): Promise<string[]>;
145
+ get(id: string, { connection }?: CallOptions): Promise<StoredRecord | null>;
146
+ getMany(ids: string[], { connection }?: CallOptions): Promise<StoredRecord[]>;
147
+ getMetadata(id: string, { connection }?: CallOptions): Promise<StoredRecordMetadata | null>;
148
+ getStream(id: string, opts: StreamOptions): Promise<AsyncIterable<Buffer> | null>;
149
+ delete(id: string, { connection }?: CallOptions): Promise<boolean>;
150
+ deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
151
+ exists(id: string, { connection }?: CallOptions): Promise<boolean>;
152
+ private deleteDedup;
153
+ private deleteManyDedup;
154
+ /**
155
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
156
+ * dedup instance's tables, preserving every id (external references keep working) and
157
+ * deduplicating identical content on the way. The classic table is never modified.
158
+ *
159
+ * Differences from the Python version, all deliberate: rows are copied in small batches (bounded
160
+ * memory instead of loading the whole table); re-running is safe (already-migrated ids are
161
+ * skipped and reported, not an error); and classic rows already tiered to object storage are
162
+ * refused up front with a clear message (their bytes are not in the database to copy).
163
+ * Still all-or-nothing: one transaction.
164
+ */
165
+ migrateClassicToDedup(): Promise<{
166
+ imagesMigrated: number;
167
+ distinctBlobsCreated: number;
168
+ duplicateReferencesFound: number;
169
+ alreadyMigrated: number;
170
+ }>;
171
+ private hydrate;
172
+ /**
173
+ * Remove tiered objects AFTER their rows are gone. Ordering is deliberate:
174
+ * once the row is deleted the image is correctly "not found" to everyone, so
175
+ * a failed object delete only leaves a harmless orphan, whereas deleting the
176
+ * object first and then failing the row delete would leave a row pointing at nothing.
177
+ *
178
+ * Never runs inside a caller-owned transaction (`connection`): we cannot know
179
+ * whether it will commit, and destroying data before the commit outcome is known
180
+ * could leave a rolled-back row pointing at a deleted object. In that case the
181
+ * object stays until you remove it yourself after committing (objectStorage.delete(id)).
182
+ * Failures are reported through onOperation and never fail the delete itself.
183
+ */
184
+ private cleanupObject;
185
+ /**
186
+ * Move an image's bytes out of Postgres into object storage, replacing the
187
+ * row's `data` with a pointer. Returns:
188
+ * null - no such image
189
+ * false - already tiered (a safe no-op, so backfills can be re-run)
190
+ * true - tiered just now
191
+ *
192
+ * SAFETY: the upload happens INSIDE the same transaction as the row lock
193
+ * (SELECT ... FOR UPDATE) and the UPDATE that flips the row. If the upload
194
+ * fails, the transaction rolls back and the row is untouched: there is no
195
+ * moment when the bytes exist nowhere, or when a row claims to be tiered
196
+ * without a completed upload.
197
+ *
198
+ * TRADEOFF: the row lock is held for the whole upload. Concurrent writes
199
+ * to THIS image (delete, tier) wait; every other row is unaffected, and plain
200
+ * reads are never blocked (Postgres MVCC). Meant for explicit maintenance, not hot paths.
201
+ *
202
+ * Retry is safe: a transient error replays the whole closure, and the
203
+ * deterministic key (the image id) makes the re-upload an idempotent overwrite.
204
+ */
205
+ tierToObjectStorage(id: string, { connection }?: CallOptions): Promise<boolean | null>;
206
+ }
207
+
208
+ interface SQLiteBackendOptions {
209
+ /** Database file path, or ":memory:" for a throwaway in-process database (tiering is unavailable for ":memory:"). */
210
+ path: string;
211
+ /** Create the schema on first use. Default true. */
212
+ autoMigrate?: boolean;
213
+ /** Content-addressed mode (separate tables; same schema as the Python package). Cannot be combined with objectStorage. */
214
+ dedup?: boolean;
215
+ /** Enables tiering and transparent reads of tiered rows. */
216
+ objectStorage?: ObjectStorageLike;
217
+ /** Total time to keep retrying when another connection/process holds the write lock. Default 10000. */
218
+ busyTimeoutMs?: number;
219
+ onOperation?: (event: OperationEvent) => void;
220
+ }
221
+ declare class SQLiteBackend implements StorageBackend {
222
+ private readonly opts;
223
+ private db;
224
+ private Database;
225
+ private readyPromise;
226
+ private closed;
227
+ private readonly dedup;
228
+ private readonly busyTimeoutMs;
229
+ private readonly writeLock;
230
+ constructor(opts: SQLiteBackendOptions);
231
+ ready(): Promise<void>;
232
+ private init;
233
+ close(): Promise<void>;
234
+ private emit;
235
+ /** Re-run `fn` while another connection holds a lock, sleeping asynchronously between attempts. `fn` must be atomic. */
236
+ private retrying;
237
+ /** BEGIN IMMEDIATE takes the write lock up front, so a BUSY comes before any work and the whole unit is safely re-runnable. */
238
+ private tx;
239
+ /**
240
+ * All-or-nothing work on a CALLER-supplied handle. SAVEPOINT works whether or not the caller has an open
241
+ * transaction: inside one it nests (a failure rolls back only our work); outside one it begins and, on
242
+ * RELEASE, commits.
243
+ */
244
+ private atomic;
245
+ private execute;
246
+ put(row: PreparedRow, { connection }?: CallOptions): Promise<string>;
247
+ private insertOne;
248
+ putMany(rows: PreparedRow[], { connection }?: CallOptions): Promise<string[]>;
249
+ get(id: string, { connection }?: CallOptions): Promise<StoredRecord | null>;
250
+ getMany(ids: string[], { connection }?: CallOptions): Promise<StoredRecord[]>;
251
+ getMetadata(id: string, { connection }?: CallOptions): Promise<StoredRecordMetadata | null>;
252
+ getStream(id: string, opts: StreamOptions): Promise<AsyncIterable<Buffer> | null>;
253
+ delete(id: string, { connection }?: CallOptions): Promise<boolean>;
254
+ deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
255
+ exists(id: string, { connection }?: CallOptions): Promise<boolean>;
256
+ /**
257
+ * Remove tiered objects AFTER their rows are gone (a failed object delete leaves a harmless orphan; the
258
+ * reverse order could leave a row pointing at nothing). Skipped inside a caller-owned transaction, since the
259
+ * commit outcome is unknown. Failures never fail the delete; they are reported through onOperation.
260
+ */
261
+ private cleanupObjects;
262
+ /**
263
+ * Move an image's bytes into object storage. null = no such image, false = already tiered, true = tiered now.
264
+ *
265
+ * SAFETY (same guarantee as Postgres): the upload happens INSIDE a BEGIN IMMEDIATE transaction; if it fails the
266
+ * transaction rolls back and the row is untouched.
267
+ *
268
+ * TRADEOFF (coarser than Postgres): SQLite has no row locks, so BEGIN IMMEDIATE locks the WHOLE DATABASE for the
269
+ * upload. Other processes' writes wait; this process's writes queue behind an async lock; reads are not blocked.
270
+ */
271
+ tierToObjectStorage(id: string, { connection }?: CallOptions): Promise<boolean | null>;
272
+ }
273
+
274
+ declare const SUPPORTED_FORMATS: readonly string[];
275
+ /** Reject images that would decode to more pixels than this, regardless of compressed size (~89 MP). */
276
+ declare const DEFAULT_MAX_PIXELS = 89000000;
277
+ interface ValidatedImage {
278
+ mimeType: string;
279
+ width: number;
280
+ height: number;
281
+ sizeBytes: number;
282
+ }
283
+ declare function validateImage(data: Buffer, opts: {
284
+ maxBytes: number;
285
+ maxPixels?: number;
286
+ allowedFormats?: readonly string[];
287
+ }): Promise<ValidatedImage>;
288
+
289
+ /**
290
+ * Validates that bytes look like a PDF: `%PDF-` magic bytes, non-empty, under a
291
+ * size ceiling. Deliberately minimal.
292
+ *
293
+ * Security note: this does NOT parse the PDF object structure and does not
294
+ * detect embedded JavaScript, forms or launch actions. Treat it as a sanity
295
+ * gate, not a security boundary, if you serve user-uploaded PDFs to other users.
296
+ */
297
+ declare class PDFValidator implements ContentValidator {
298
+ private readonly opts;
299
+ constructor(opts?: {
300
+ maxBytes?: number;
301
+ });
302
+ validate(data: Buffer, { maxBytes }: {
303
+ maxBytes: number;
304
+ }): ValidatedContent;
305
+ }
306
+
307
+ export { AccessDeniedError, CallOptions, ContentValidationError, ContentValidator, CorruptedImageError, DEFAULT_MAX_PIXELS, DEFAULT_STREAM_CHUNK_SIZE, ImageNotFoundError, ImageTooLargeError, ImageValidationError, ObjectStorageLike, OperationEvent, PDFValidator, PostgresBackend, type PostgresBackendOptions, PreparedRow, SQLiteBackend, type SQLiteBackendOptions, SUPPORTED_FORMATS, StorageBackend, StorageError, StoredRecord, StoredRecordMetadata, UnsupportedFormatError, ValidatedContent, type ValidatedImage, ZeroBucketError, validateImage };