@hozon/store-blob 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api.d.ts +2 -2
- package/lib/api.js +185 -147
- package/lib/types.d.ts +2 -0
- package/package.json +3 -3
package/lib/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Adapter } from '@hozon/adapter';
|
|
2
|
-
import type { Kysely } from '@hozon/db';
|
|
2
|
+
import type { Kysely, StoreContext } from '@hozon/db';
|
|
3
3
|
import type { BlobTables } from './tables.js';
|
|
4
4
|
import type { BlobStoreAPI } from './types.js';
|
|
5
|
-
export declare function createBlobStoreAPI(db: Kysely<BlobTables>, adapter: Adapter): BlobStoreAPI;
|
|
5
|
+
export declare function createBlobStoreAPI(db: Kysely<BlobTables>, adapter: Adapter, context: StoreContext): BlobStoreAPI;
|
package/lib/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chunk, withStoreTransaction } from '@hozon/db';
|
|
1
|
+
import { assertBatchSize, chunk, withStoreTransaction } from '@hozon/db';
|
|
2
2
|
import { b64uFromUTF, b64uToUTF } from '@sozai/codec';
|
|
3
3
|
import { InvalidCursorError } from './errors.js';
|
|
4
4
|
function encodeCursor(createdAt, blobID) {
|
|
@@ -21,7 +21,47 @@ function decodeCursor(cursor) {
|
|
|
21
21
|
}
|
|
22
22
|
throw new InvalidCursorError();
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
// The entry query binds only the IDs.
|
|
25
|
+
const ENTRY_BATCH_MAX = 500;
|
|
26
|
+
function toEntry(row) {
|
|
27
|
+
return {
|
|
28
|
+
blobID: row.blob_id,
|
|
29
|
+
contentLength: row.content_length,
|
|
30
|
+
encrypted: row.encrypted === 1,
|
|
31
|
+
keyID: row.key_id,
|
|
32
|
+
chunkSize: row.chunk_size,
|
|
33
|
+
state: row.state,
|
|
34
|
+
pinned: row.pinned === 1,
|
|
35
|
+
createdAt: row.created_at,
|
|
36
|
+
contentType: row.content_type
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function createBlobStoreAPI(db, adapter, context) {
|
|
40
|
+
async function getEntries(blobIDs, options) {
|
|
41
|
+
const unique = [
|
|
42
|
+
...new Set(blobIDs)
|
|
43
|
+
];
|
|
44
|
+
assertBatchSize(unique.length, ENTRY_BATCH_MAX, options?.batchLimit);
|
|
45
|
+
const result = new Map();
|
|
46
|
+
if (unique.length === 0) return result;
|
|
47
|
+
const rows = await db.selectFrom('blob_entries').selectAll().where('blob_id', 'in', unique).execute();
|
|
48
|
+
for (const id of unique)result.set(id, null);
|
|
49
|
+
for (const row of rows)result.set(row.blob_id, toEntry(row));
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
const entries = context.createLoader({
|
|
53
|
+
load: (ids)=>getEntries(ids),
|
|
54
|
+
maxBatchSize: ENTRY_BATCH_MAX
|
|
55
|
+
});
|
|
56
|
+
// Flushes pending reads before the write and drops the remembered entry afterwards.
|
|
57
|
+
async function mutate(blobID, run) {
|
|
58
|
+
entries.dispatch();
|
|
59
|
+
try {
|
|
60
|
+
return await run();
|
|
61
|
+
} finally{
|
|
62
|
+
entries.clear(blobID);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
25
65
|
async function insertManifest(trx, blobID, chunks) {
|
|
26
66
|
const rows = chunks.map((item)=>({
|
|
27
67
|
blob_id: blobID,
|
|
@@ -38,104 +78,102 @@ export function createBlobStoreAPI(db, adapter) {
|
|
|
38
78
|
}
|
|
39
79
|
return {
|
|
40
80
|
async insertEntry (entry, chunks) {
|
|
41
|
-
|
|
42
|
-
await trx
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
return mutate(entry.blobID, async ()=>{
|
|
82
|
+
await withStoreTransaction(db, async (trx)=>{
|
|
83
|
+
await trx.insertInto('blob_entries').values({
|
|
84
|
+
blob_id: entry.blobID,
|
|
85
|
+
content_length: entry.contentLength,
|
|
86
|
+
encrypted: entry.encrypted ? 1 : 0,
|
|
87
|
+
key_id: entry.keyID ?? null,
|
|
88
|
+
chunk_size: entry.chunkSize,
|
|
89
|
+
state: entry.state,
|
|
90
|
+
pinned: entry.pinned ? 1 : 0,
|
|
91
|
+
created_at: entry.createdAt,
|
|
92
|
+
content_type: entry.contentType ?? null
|
|
93
|
+
}).onConflict((oc)=>oc.column('blob_id').doNothing()).execute();
|
|
94
|
+
await insertManifest(trx, entry.blobID, chunks);
|
|
95
|
+
});
|
|
54
96
|
});
|
|
55
97
|
},
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return row === undefined ? null : {
|
|
59
|
-
blobID: row.blob_id,
|
|
60
|
-
contentLength: row.content_length,
|
|
61
|
-
encrypted: row.encrypted === 1,
|
|
62
|
-
keyID: row.key_id,
|
|
63
|
-
chunkSize: row.chunk_size,
|
|
64
|
-
state: row.state,
|
|
65
|
-
pinned: row.pinned === 1,
|
|
66
|
-
createdAt: row.created_at,
|
|
67
|
-
contentType: row.content_type
|
|
68
|
-
};
|
|
69
|
-
},
|
|
98
|
+
getEntry: (blobID)=>entries.load(blobID),
|
|
99
|
+
getEntries,
|
|
70
100
|
async getChunkDigests (blobID) {
|
|
71
101
|
const rows = await db.selectFrom('blob_chunks').select('digest').where('blob_id', '=', blobID).orderBy('index', 'asc').execute();
|
|
72
102
|
return rows.map((row)=>row.digest);
|
|
73
103
|
},
|
|
74
104
|
async setPinned (blobID, pinned) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
105
|
+
return mutate(blobID, async ()=>{
|
|
106
|
+
await db.updateTable('blob_entries').set({
|
|
107
|
+
pinned: pinned ? 1 : 0
|
|
108
|
+
}).where('blob_id', '=', blobID).execute();
|
|
109
|
+
});
|
|
78
110
|
},
|
|
79
111
|
async deleteEntry (blobID) {
|
|
80
|
-
|
|
81
|
-
await
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
return mutate(blobID, async ()=>{
|
|
113
|
+
await withStoreTransaction(db, async (trx)=>{
|
|
114
|
+
await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
|
|
115
|
+
await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
|
|
116
|
+
await trx.deleteFrom('blob_chunks').where('blob_id', '=', blobID).execute();
|
|
117
|
+
await trx.deleteFrom('blob_entries').where('blob_id', '=', blobID).execute();
|
|
118
|
+
});
|
|
85
119
|
});
|
|
86
120
|
},
|
|
87
121
|
async beginTransfer ({ blobID, chunkSize, chunks, stagingID }) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
'
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
122
|
+
return mutate(blobID, async ()=>{
|
|
123
|
+
await withStoreTransaction(db, async (trx)=>{
|
|
124
|
+
let query = trx.selectFrom('blob_entries').select([
|
|
125
|
+
'blob_id',
|
|
126
|
+
'chunk_size'
|
|
127
|
+
]).where('blob_id', '=', blobID);
|
|
128
|
+
if (adapter.kind === 'postgres') query = query.forUpdate();
|
|
129
|
+
const entry = await query.executeTakeFirst();
|
|
130
|
+
if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
|
|
131
|
+
if (entry.chunk_size !== chunkSize) {
|
|
132
|
+
const manifest = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).executeTakeFirst();
|
|
133
|
+
if (manifest !== undefined) {
|
|
134
|
+
throw new Error(`Cannot change chunk size for blob ${blobID}: manifest already exists`);
|
|
135
|
+
}
|
|
100
136
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
blob_id: blobID,
|
|
115
|
-
staging_id: stagingID,
|
|
116
|
-
updated_at: updatedAt
|
|
117
|
-
}).onConflict((oc)=>oc.column('blob_id').doUpdateSet({
|
|
137
|
+
await trx.updateTable('blob_entries').set({
|
|
138
|
+
chunk_size: chunkSize,
|
|
139
|
+
state: 'partial'
|
|
140
|
+
}).where('blob_id', '=', blobID).execute();
|
|
141
|
+
await insertManifest(trx, blobID, chunks);
|
|
142
|
+
const existing = await trx.selectFrom('blob_transfer_sessions').select('staging_id').where('blob_id', '=', blobID).executeTakeFirst();
|
|
143
|
+
// A new staging area holds none of the previous session's chunks.
|
|
144
|
+
if (existing !== undefined && existing.staging_id !== stagingID) {
|
|
145
|
+
await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
|
|
146
|
+
}
|
|
147
|
+
const updatedAt = Date.now();
|
|
148
|
+
await trx.insertInto('blob_transfer_sessions').values({
|
|
149
|
+
blob_id: blobID,
|
|
118
150
|
staging_id: stagingID,
|
|
119
151
|
updated_at: updatedAt
|
|
120
|
-
})).
|
|
152
|
+
}).onConflict((oc)=>oc.column('blob_id').doUpdateSet({
|
|
153
|
+
staging_id: stagingID,
|
|
154
|
+
updated_at: updatedAt
|
|
155
|
+
})).execute();
|
|
156
|
+
});
|
|
121
157
|
});
|
|
122
158
|
},
|
|
123
159
|
async recordTransferChunk (blobID, index) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
160
|
+
return mutate(blobID, async ()=>{
|
|
161
|
+
await withStoreTransaction(db, async (trx)=>{
|
|
162
|
+
const entry = await trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID).executeTakeFirst();
|
|
163
|
+
if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
|
|
164
|
+
const chunk = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).where('index', '=', index).executeTakeFirst();
|
|
165
|
+
if (chunk === undefined) throw new Error(`Blob chunk ${blobID} at index ${index} not found`);
|
|
166
|
+
await trx.insertInto('blob_transfers').values({
|
|
167
|
+
blob_id: blobID,
|
|
168
|
+
index
|
|
169
|
+
}).onConflict((oc)=>oc.columns([
|
|
170
|
+
'blob_id',
|
|
171
|
+
'index'
|
|
172
|
+
]).doNothing()).execute();
|
|
173
|
+
await trx.updateTable('blob_transfer_sessions').set({
|
|
174
|
+
updated_at: Date.now()
|
|
175
|
+
}).where('blob_id', '=', blobID).execute();
|
|
176
|
+
});
|
|
139
177
|
});
|
|
140
178
|
},
|
|
141
179
|
async getPresentChunkIndexes (blobID) {
|
|
@@ -143,21 +181,23 @@ export function createBlobStoreAPI(db, adapter) {
|
|
|
143
181
|
return rows.map((row)=>row.index);
|
|
144
182
|
},
|
|
145
183
|
async finalizeTransfer (blobID) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
184
|
+
return mutate(blobID, async ()=>{
|
|
185
|
+
await withStoreTransaction(db, async (trx)=>{
|
|
186
|
+
const entry = await trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID).executeTakeFirst();
|
|
187
|
+
if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
|
|
188
|
+
const manifest = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).execute();
|
|
189
|
+
const present = await trx.selectFrom('blob_transfers').select('index').where('blob_id', '=', blobID).execute();
|
|
190
|
+
const presentSet = new Set(present.map((row)=>row.index));
|
|
191
|
+
const missing = manifest.filter((row)=>!presentSet.has(row.index));
|
|
192
|
+
if (missing.length > 0) {
|
|
193
|
+
throw new Error(`Cannot finalize transfer ${blobID}: ${missing.length} chunk(s) missing`);
|
|
194
|
+
}
|
|
195
|
+
await trx.updateTable('blob_entries').set({
|
|
196
|
+
state: 'local'
|
|
197
|
+
}).where('blob_id', '=', blobID).execute();
|
|
198
|
+
await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
|
|
199
|
+
await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
|
|
200
|
+
});
|
|
161
201
|
});
|
|
162
202
|
},
|
|
163
203
|
async getTransfer (blobID) {
|
|
@@ -184,9 +224,11 @@ export function createBlobStoreAPI(db, adapter) {
|
|
|
184
224
|
};
|
|
185
225
|
},
|
|
186
226
|
async touchTransfer (blobID) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
227
|
+
return mutate(blobID, async ()=>{
|
|
228
|
+
await db.updateTable('blob_transfer_sessions').set({
|
|
229
|
+
updated_at: Date.now()
|
|
230
|
+
}).where('blob_id', '=', blobID).execute();
|
|
231
|
+
});
|
|
190
232
|
},
|
|
191
233
|
async listEntries (params) {
|
|
192
234
|
const { limit, cursor } = params;
|
|
@@ -206,17 +248,7 @@ export function createBlobStoreAPI(db, adapter) {
|
|
|
206
248
|
}
|
|
207
249
|
const rows = await query.orderBy('created_at', 'asc').orderBy('blob_id', 'asc').limit(limit + 1).execute();
|
|
208
250
|
const page = rows.slice(0, limit);
|
|
209
|
-
const entries = page.map(
|
|
210
|
-
blobID: row.blob_id,
|
|
211
|
-
contentLength: row.content_length,
|
|
212
|
-
encrypted: row.encrypted === 1,
|
|
213
|
-
keyID: row.key_id,
|
|
214
|
-
chunkSize: row.chunk_size,
|
|
215
|
-
state: row.state,
|
|
216
|
-
pinned: row.pinned === 1,
|
|
217
|
-
createdAt: row.created_at,
|
|
218
|
-
contentType: row.content_type
|
|
219
|
-
}));
|
|
251
|
+
const entries = page.map(toEntry);
|
|
220
252
|
const last = page[page.length - 1];
|
|
221
253
|
const nextCursor = rows.length > limit && last !== undefined ? encodeCursor(last.created_at, last.blob_id) : null;
|
|
222
254
|
return {
|
|
@@ -225,48 +257,54 @@ export function createBlobStoreAPI(db, adapter) {
|
|
|
225
257
|
};
|
|
226
258
|
},
|
|
227
259
|
async promoteEntry (entry, chunks) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
260
|
+
return mutate(entry.blobID, async ()=>{
|
|
261
|
+
await withStoreTransaction(db, async (trx)=>{
|
|
262
|
+
let lock = trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', entry.blobID);
|
|
263
|
+
if (adapter.kind === 'postgres') lock = lock.forUpdate();
|
|
264
|
+
await lock.executeTakeFirst();
|
|
265
|
+
const values = {
|
|
266
|
+
content_length: entry.contentLength,
|
|
267
|
+
encrypted: entry.encrypted ? 1 : 0,
|
|
268
|
+
key_id: entry.keyID ?? null,
|
|
269
|
+
chunk_size: entry.chunkSize,
|
|
270
|
+
state: 'local',
|
|
271
|
+
content_type: entry.contentType ?? null
|
|
272
|
+
};
|
|
273
|
+
await trx.insertInto('blob_entries').values({
|
|
274
|
+
blob_id: entry.blobID,
|
|
275
|
+
pinned: entry.pinned ? 1 : 0,
|
|
276
|
+
created_at: entry.createdAt,
|
|
277
|
+
...values
|
|
278
|
+
}).onConflict((oc)=>oc.column('blob_id').doUpdateSet(values)).execute();
|
|
279
|
+
await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', entry.blobID).execute();
|
|
280
|
+
await trx.deleteFrom('blob_transfers').where('blob_id', '=', entry.blobID).execute();
|
|
281
|
+
await trx.deleteFrom('blob_chunks').where('blob_id', '=', entry.blobID).execute();
|
|
282
|
+
await insertManifest(trx, entry.blobID, chunks);
|
|
283
|
+
});
|
|
250
284
|
});
|
|
251
285
|
},
|
|
252
286
|
async resetTransfer (blobID) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
287
|
+
return mutate(blobID, async ()=>{
|
|
288
|
+
await withStoreTransaction(db, async (trx)=>{
|
|
289
|
+
let lock = trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID);
|
|
290
|
+
if (adapter.kind === 'postgres') lock = lock.forUpdate();
|
|
291
|
+
const entry = await lock.executeTakeFirst();
|
|
292
|
+
if (entry === undefined) return;
|
|
293
|
+
await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
|
|
294
|
+
await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
|
|
295
|
+
await trx.deleteFrom('blob_chunks').where('blob_id', '=', blobID).execute();
|
|
296
|
+
await trx.updateTable('blob_entries').set({
|
|
297
|
+
state: 'remote-only'
|
|
298
|
+
}).where('blob_id', '=', blobID).execute();
|
|
299
|
+
});
|
|
264
300
|
});
|
|
265
301
|
},
|
|
266
302
|
async fillContentType (blobID, contentType) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
303
|
+
return mutate(blobID, async ()=>{
|
|
304
|
+
await db.updateTable('blob_entries').set({
|
|
305
|
+
content_type: contentType
|
|
306
|
+
}).where('blob_id', '=', blobID).where('content_type', 'is', null).execute();
|
|
307
|
+
});
|
|
270
308
|
}
|
|
271
309
|
};
|
|
272
310
|
}
|
package/lib/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BatchReadOptions } from '@hozon/db';
|
|
1
2
|
export type BlobState = 'local' | 'partial' | 'remote-only';
|
|
2
3
|
export type BlobEntryInput = {
|
|
3
4
|
blobID: string;
|
|
@@ -39,6 +40,7 @@ export type BlobTransfer = {
|
|
|
39
40
|
export type BlobStoreAPI = {
|
|
40
41
|
insertEntry(entry: BlobEntryInput, chunks: Array<BlobChunkInput>): Promise<void>;
|
|
41
42
|
getEntry(blobID: string): Promise<BlobEntry | null>;
|
|
43
|
+
getEntries(blobIDs: Array<string>, options?: BatchReadOptions): Promise<Map<string, BlobEntry | null>>;
|
|
42
44
|
getChunkDigests(blobID: string): Promise<Array<Uint8Array>>;
|
|
43
45
|
setPinned(blobID: string, pinned: boolean): Promise<void>;
|
|
44
46
|
deleteEntry(blobID: string): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hozon/store-blob",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Content-addressed blob metadata and resumable transfer store for Hozon",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@hozon/adapter": "^0.1.0",
|
|
21
|
-
"@hozon/db": "^0.
|
|
21
|
+
"@hozon/db": "^0.3.0",
|
|
22
22
|
"@sozai/codec": "^0.4.3"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@hozon/node-sqlite": "^0.1.0",
|
|
26
26
|
"@types/node": "^26.6.5",
|
|
27
27
|
"del-cli": "^8.0.1",
|
|
28
|
-
"vitest": "5.0.
|
|
28
|
+
"vitest": "5.0.5"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|