@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 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
- export function createBlobStoreAPI(db, adapter) {
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
- await withStoreTransaction(db, async (trx)=>{
42
- await trx.insertInto('blob_entries').values({
43
- blob_id: entry.blobID,
44
- content_length: entry.contentLength,
45
- encrypted: entry.encrypted ? 1 : 0,
46
- key_id: entry.keyID ?? null,
47
- chunk_size: entry.chunkSize,
48
- state: entry.state,
49
- pinned: entry.pinned ? 1 : 0,
50
- created_at: entry.createdAt,
51
- content_type: entry.contentType ?? null
52
- }).onConflict((oc)=>oc.column('blob_id').doNothing()).execute();
53
- await insertManifest(trx, entry.blobID, chunks);
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
- async getEntry (blobID) {
57
- const row = await db.selectFrom('blob_entries').selectAll().where('blob_id', '=', blobID).executeTakeFirst();
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
- await db.updateTable('blob_entries').set({
76
- pinned: pinned ? 1 : 0
77
- }).where('blob_id', '=', blobID).execute();
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
- await withStoreTransaction(db, async (trx)=>{
81
- await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
82
- await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
83
- await trx.deleteFrom('blob_chunks').where('blob_id', '=', blobID).execute();
84
- await trx.deleteFrom('blob_entries').where('blob_id', '=', blobID).execute();
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
- await withStoreTransaction(db, async (trx)=>{
89
- let query = trx.selectFrom('blob_entries').select([
90
- 'blob_id',
91
- 'chunk_size'
92
- ]).where('blob_id', '=', blobID);
93
- if (adapter.kind === 'postgres') query = query.forUpdate();
94
- const entry = await query.executeTakeFirst();
95
- if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
96
- if (entry.chunk_size !== chunkSize) {
97
- const manifest = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).executeTakeFirst();
98
- if (manifest !== undefined) {
99
- throw new Error(`Cannot change chunk size for blob ${blobID}: manifest already exists`);
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
- await trx.updateTable('blob_entries').set({
103
- chunk_size: chunkSize,
104
- state: 'partial'
105
- }).where('blob_id', '=', blobID).execute();
106
- await insertManifest(trx, blobID, chunks);
107
- const existing = await trx.selectFrom('blob_transfer_sessions').select('staging_id').where('blob_id', '=', blobID).executeTakeFirst();
108
- // A new staging area holds none of the previous session's chunks.
109
- if (existing !== undefined && existing.staging_id !== stagingID) {
110
- await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
111
- }
112
- const updatedAt = Date.now();
113
- await trx.insertInto('blob_transfer_sessions').values({
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
- })).execute();
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
- await withStoreTransaction(db, async (trx)=>{
125
- const entry = await trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID).executeTakeFirst();
126
- if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
127
- const chunk = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).where('index', '=', index).executeTakeFirst();
128
- if (chunk === undefined) throw new Error(`Blob chunk ${blobID} at index ${index} not found`);
129
- await trx.insertInto('blob_transfers').values({
130
- blob_id: blobID,
131
- index
132
- }).onConflict((oc)=>oc.columns([
133
- 'blob_id',
134
- 'index'
135
- ]).doNothing()).execute();
136
- await trx.updateTable('blob_transfer_sessions').set({
137
- updated_at: Date.now()
138
- }).where('blob_id', '=', blobID).execute();
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
- await withStoreTransaction(db, async (trx)=>{
147
- const entry = await trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID).executeTakeFirst();
148
- if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
149
- const manifest = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).execute();
150
- const present = await trx.selectFrom('blob_transfers').select('index').where('blob_id', '=', blobID).execute();
151
- const presentSet = new Set(present.map((row)=>row.index));
152
- const missing = manifest.filter((row)=>!presentSet.has(row.index));
153
- if (missing.length > 0) {
154
- throw new Error(`Cannot finalize transfer ${blobID}: ${missing.length} chunk(s) missing`);
155
- }
156
- await trx.updateTable('blob_entries').set({
157
- state: 'local'
158
- }).where('blob_id', '=', blobID).execute();
159
- await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
160
- await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
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
- await db.updateTable('blob_transfer_sessions').set({
188
- updated_at: Date.now()
189
- }).where('blob_id', '=', blobID).execute();
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((row)=>({
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
- await withStoreTransaction(db, async (trx)=>{
229
- let lock = trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', entry.blobID);
230
- if (adapter.kind === 'postgres') lock = lock.forUpdate();
231
- await lock.executeTakeFirst();
232
- const values = {
233
- content_length: entry.contentLength,
234
- encrypted: entry.encrypted ? 1 : 0,
235
- key_id: entry.keyID ?? null,
236
- chunk_size: entry.chunkSize,
237
- state: 'local',
238
- content_type: entry.contentType ?? null
239
- };
240
- await trx.insertInto('blob_entries').values({
241
- blob_id: entry.blobID,
242
- pinned: entry.pinned ? 1 : 0,
243
- created_at: entry.createdAt,
244
- ...values
245
- }).onConflict((oc)=>oc.column('blob_id').doUpdateSet(values)).execute();
246
- await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', entry.blobID).execute();
247
- await trx.deleteFrom('blob_transfers').where('blob_id', '=', entry.blobID).execute();
248
- await trx.deleteFrom('blob_chunks').where('blob_id', '=', entry.blobID).execute();
249
- await insertManifest(trx, entry.blobID, chunks);
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
- await withStoreTransaction(db, async (trx)=>{
254
- let lock = trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID);
255
- if (adapter.kind === 'postgres') lock = lock.forUpdate();
256
- const entry = await lock.executeTakeFirst();
257
- if (entry === undefined) return;
258
- await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
259
- await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
260
- await trx.deleteFrom('blob_chunks').where('blob_id', '=', blobID).execute();
261
- await trx.updateTable('blob_entries').set({
262
- state: 'remote-only'
263
- }).where('blob_id', '=', blobID).execute();
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
- await db.updateTable('blob_entries').set({
268
- content_type: contentType
269
- }).where('blob_id', '=', blobID).where('content_type', 'is', null).execute();
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.1",
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.2.3",
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.4"
28
+ "vitest": "5.0.5"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"