@hozon/store-blob 0.1.1 → 0.2.1

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/README.md CHANGED
@@ -28,15 +28,22 @@ Entries map integer flags to booleans and expose camelCase fields.
28
28
  Encryption and key IDs are optional metadata that the store does not interpret.
29
29
  Repeated entry and manifest inserts preserve existing rows.
30
30
 
31
- For a known remote entry, `beginTransfer` records its manifest and sets its state to `partial`.
32
- Retries with the same chunk size preserve manifest digests and progress.
31
+ For a known remote entry, `beginTransfer({ blobID, chunkSize, chunks, stagingID })` records its manifest, sets its state to `partial`, and records a transfer session for `stagingID`.
32
+ Retries with the same chunk size preserve manifest digests. Progress is preserved only while `stagingID` matches the session; a different `stagingID` clears it.
33
+ `getTransfer`, `getTransferByStagingID`, and `touchTransfer` read and refresh the session.
33
34
  Changing chunk size while a manifest exists rejects with `Cannot change chunk size for blob <blobID>: manifest already exists`.
34
35
  Record each verified chunk with `recordTransferChunk`, then call `finalizeTransfer` when all chunks are present.
35
- Finalisation preserves the manifest, clears transfer progress, and sets the entry state to `local`.
36
+ Finalisation preserves the manifest, clears transfer progress and the session, and sets the entry state to `local`.
36
37
  `beginTransfer`, `recordTransferChunk`, and `finalizeTransfer` reject absent entries with `Blob entry <blobID> not found`.
37
38
  `recordTransferChunk` rejects unknown manifest indexes with `Blob chunk <blobID> at index <index> not found`.
38
39
  Finalisation rejects missing chunks with `Cannot finalize transfer <blobID>: <count> chunk(s) missing`.
39
- `deleteEntry` removes the entry, manifest, and transfer progress together.
40
+ `deleteEntry` removes the entry, manifest, transfer progress, and session together.
41
+ Entries carry an optional `contentType` (`string | null`).
42
+ `listEntries({ limit, cursor })` pages entries by `(createdAt, blobID)` with an opaque cursor; `limit` is 1 to 1000.
43
+ `promoteEntry(entry, chunks)` atomically replaces an entry's manifest and marks it `local`, keeping `pinned` and `createdAt` and clearing transfers and session.
44
+ `fillContentType(blobID, contentType)` sets the content type only when the stored one is null.
45
+ `resetTransfer(blobID)` atomically drops the session, progress, and manifest and sets the entry to `remote-only`.
46
+ The `1-sessions` migration resets existing `partial` entries to `remote-only`, dropping their manifests; this is not reversible.
40
47
  Manifest chunks reference entries, and transfer rows reference manifest chunks, with cascading deletion preventing orphan progress.
41
48
 
42
49
  Tables follow the database's configured prefix, which defaults to `hozon`.
package/lib/api.js CHANGED
@@ -1,4 +1,26 @@
1
1
  import { chunk, withStoreTransaction } from '@hozon/db';
2
+ import { b64uFromUTF, b64uToUTF } from '@sozai/codec';
3
+ import { InvalidCursorError } from './errors.js';
4
+ function encodeCursor(createdAt, blobID) {
5
+ return b64uFromUTF(JSON.stringify([
6
+ createdAt,
7
+ blobID
8
+ ]));
9
+ }
10
+ function decodeCursor(cursor) {
11
+ try {
12
+ const value = JSON.parse(b64uToUTF(cursor));
13
+ if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number' && Number.isFinite(value[0]) && typeof value[1] === 'string') {
14
+ return [
15
+ value[0],
16
+ value[1]
17
+ ];
18
+ }
19
+ } catch {
20
+ // fall through
21
+ }
22
+ throw new InvalidCursorError();
23
+ }
2
24
  export function createBlobStoreAPI(db, adapter) {
3
25
  async function insertManifest(trx, blobID, chunks) {
4
26
  const rows = chunks.map((item)=>({
@@ -25,7 +47,8 @@ export function createBlobStoreAPI(db, adapter) {
25
47
  chunk_size: entry.chunkSize,
26
48
  state: entry.state,
27
49
  pinned: entry.pinned ? 1 : 0,
28
- created_at: entry.createdAt
50
+ created_at: entry.createdAt,
51
+ content_type: entry.contentType ?? null
29
52
  }).onConflict((oc)=>oc.column('blob_id').doNothing()).execute();
30
53
  await insertManifest(trx, entry.blobID, chunks);
31
54
  });
@@ -40,7 +63,8 @@ export function createBlobStoreAPI(db, adapter) {
40
63
  chunkSize: row.chunk_size,
41
64
  state: row.state,
42
65
  pinned: row.pinned === 1,
43
- createdAt: row.created_at
66
+ createdAt: row.created_at,
67
+ contentType: row.content_type
44
68
  };
45
69
  },
46
70
  async getChunkDigests (blobID) {
@@ -54,12 +78,13 @@ export function createBlobStoreAPI(db, adapter) {
54
78
  },
55
79
  async deleteEntry (blobID) {
56
80
  await withStoreTransaction(db, async (trx)=>{
81
+ await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
57
82
  await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
58
83
  await trx.deleteFrom('blob_chunks').where('blob_id', '=', blobID).execute();
59
84
  await trx.deleteFrom('blob_entries').where('blob_id', '=', blobID).execute();
60
85
  });
61
86
  },
62
- async beginTransfer (blobID, chunkSize, chunks) {
87
+ async beginTransfer ({ blobID, chunkSize, chunks, stagingID }) {
63
88
  await withStoreTransaction(db, async (trx)=>{
64
89
  let query = trx.selectFrom('blob_entries').select([
65
90
  'blob_id',
@@ -79,6 +104,20 @@ export function createBlobStoreAPI(db, adapter) {
79
104
  state: 'partial'
80
105
  }).where('blob_id', '=', blobID).execute();
81
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({
118
+ staging_id: stagingID,
119
+ updated_at: updatedAt
120
+ })).execute();
82
121
  });
83
122
  },
84
123
  async recordTransferChunk (blobID, index) {
@@ -94,6 +133,9 @@ export function createBlobStoreAPI(db, adapter) {
94
133
  'blob_id',
95
134
  'index'
96
135
  ]).doNothing()).execute();
136
+ await trx.updateTable('blob_transfer_sessions').set({
137
+ updated_at: Date.now()
138
+ }).where('blob_id', '=', blobID).execute();
97
139
  });
98
140
  },
99
141
  async getPresentChunkIndexes (blobID) {
@@ -115,7 +157,116 @@ export function createBlobStoreAPI(db, adapter) {
115
157
  state: 'local'
116
158
  }).where('blob_id', '=', blobID).execute();
117
159
  await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
160
+ await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
161
+ });
162
+ },
163
+ async getTransfer (blobID) {
164
+ const session = await db.selectFrom('blob_transfer_sessions').select([
165
+ 'staging_id',
166
+ 'updated_at'
167
+ ]).where('blob_id', '=', blobID).executeTakeFirst();
168
+ if (session === undefined) return null;
169
+ const rows = await db.selectFrom('blob_transfers').select('index').where('blob_id', '=', blobID).orderBy('index', 'asc').execute();
170
+ return {
171
+ stagingID: session.staging_id,
172
+ updatedAt: session.updated_at,
173
+ presentChunks: rows.map((row)=>row.index)
174
+ };
175
+ },
176
+ async getTransferByStagingID (stagingID) {
177
+ const row = await db.selectFrom('blob_transfer_sessions').select([
178
+ 'blob_id',
179
+ 'updated_at'
180
+ ]).where('staging_id', '=', stagingID).executeTakeFirst();
181
+ return row === undefined ? null : {
182
+ blobID: row.blob_id,
183
+ updatedAt: row.updated_at
184
+ };
185
+ },
186
+ async touchTransfer (blobID) {
187
+ await db.updateTable('blob_transfer_sessions').set({
188
+ updated_at: Date.now()
189
+ }).where('blob_id', '=', blobID).execute();
190
+ },
191
+ async listEntries (params) {
192
+ const { limit, cursor } = params;
193
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
194
+ throw new Error('Invalid limit: must be an integer between 1 and 1000');
195
+ }
196
+ let query = db.selectFrom('blob_entries').selectAll();
197
+ if (cursor !== undefined) {
198
+ const [createdAt, blobID] = decodeCursor(cursor);
199
+ query = query.where((eb)=>eb.or([
200
+ eb('created_at', '>', createdAt),
201
+ eb.and([
202
+ eb('created_at', '=', createdAt),
203
+ eb('blob_id', '>', blobID)
204
+ ])
205
+ ]));
206
+ }
207
+ const rows = await query.orderBy('created_at', 'asc').orderBy('blob_id', 'asc').limit(limit + 1).execute();
208
+ 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
+ }));
220
+ const last = page[page.length - 1];
221
+ const nextCursor = rows.length > limit && last !== undefined ? encodeCursor(last.created_at, last.blob_id) : null;
222
+ return {
223
+ entries,
224
+ nextCursor
225
+ };
226
+ },
227
+ 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);
250
+ });
251
+ },
252
+ 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();
118
264
  });
265
+ },
266
+ 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();
119
270
  }
120
271
  };
121
272
  }
@@ -0,0 +1,3 @@
1
+ export declare class InvalidCursorError extends Error {
2
+ constructor(options?: ErrorOptions);
3
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,6 @@
1
+ export class InvalidCursorError extends Error {
2
+ constructor(options){
3
+ super('Invalid cursor', options);
4
+ this.name = 'InvalidCursorError';
5
+ }
6
+ }
package/lib/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { BLOB_STORE, blobStoreDefinition, getBlobStore } from './definition.js';
2
- export type { BlobChunkTable, BlobEntryTable, BlobTables, BlobTransferTable } from './tables.js';
3
- export type { BlobChunkInput, BlobEntry, BlobEntryInput, BlobState, BlobStoreAPI } from './types.js';
2
+ export { InvalidCursorError } from './errors.js';
3
+ export type { BlobChunkTable, BlobEntryTable, BlobTables, BlobTransferSessionTable, BlobTransferTable, } from './tables.js';
4
+ export type { BeginTransferParams, BlobChunkInput, BlobEntry, BlobEntryInput, BlobState, BlobStoreAPI, BlobTransfer, } from './types.js';
package/lib/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { BLOB_STORE, blobStoreDefinition, getBlobStore } from './definition.js';
2
+ export { InvalidCursorError } from './errors.js';
package/lib/migrations.js CHANGED
@@ -28,6 +28,29 @@ export function blobStoreMigrations(ctx) {
28
28
  await db.schema.dropTable('blob_chunks').execute();
29
29
  await db.schema.dropTable('blob_entries').execute();
30
30
  }
31
+ },
32
+ '1-sessions': {
33
+ async up (db) {
34
+ await db.schema.alterTable('blob_entries').addColumn('content_type', ctx.types.text).execute();
35
+ await db.schema.createTable('blob_transfer_sessions').addColumn('blob_id', ctx.types.text, (column)=>column.notNull()).addColumn('staging_id', ctx.types.text, (column)=>column.notNull().unique()).addColumn('updated_at', ctx.types.bigint, (column)=>column.notNull()).addPrimaryKeyConstraint(`${ctx.tablePrefix}_blob_transfer_sessions_pkey`, [
36
+ 'blob_id'
37
+ ]).addForeignKeyConstraint(`${ctx.tablePrefix}_blob_transfer_sessions_entry_fkey`, [
38
+ 'blob_id'
39
+ ], 'blob_entries', [
40
+ 'blob_id'
41
+ ], (constraint)=>constraint.onDelete('cascade')).execute();
42
+ // Partial entries predate sessions and cannot be resumed: drop their progress.
43
+ await db.deleteFrom('blob_transfers').where('blob_id', 'in', db.selectFrom('blob_entries').select('blob_id').where('state', '=', 'partial')).execute();
44
+ await db.deleteFrom('blob_chunks').where('blob_id', 'in', db.selectFrom('blob_entries').select('blob_id').where('state', '=', 'partial')).execute();
45
+ await db.updateTable('blob_entries').set({
46
+ state: 'remote-only'
47
+ }).where('state', '=', 'partial').execute();
48
+ },
49
+ // The backfill (partial entries reset to remote-only, manifests dropped) is not reversible.
50
+ async down (db) {
51
+ await db.schema.dropTable('blob_transfer_sessions').execute();
52
+ await db.schema.alterTable('blob_entries').dropColumn('content_type').execute();
53
+ }
31
54
  }
32
55
  };
33
56
  }
package/lib/tables.d.ts CHANGED
@@ -8,6 +8,7 @@ export type BlobEntryTable = {
8
8
  state: BlobState;
9
9
  pinned: number;
10
10
  created_at: number;
11
+ content_type: string | null;
11
12
  };
12
13
  export type BlobChunkTable = {
13
14
  blob_id: string;
@@ -18,8 +19,14 @@ export type BlobTransferTable = {
18
19
  blob_id: string;
19
20
  index: number;
20
21
  };
22
+ export type BlobTransferSessionTable = {
23
+ blob_id: string;
24
+ staging_id: string;
25
+ updated_at: number;
26
+ };
21
27
  export type BlobTables = {
22
28
  blob_entries: BlobEntryTable;
23
29
  blob_chunks: BlobChunkTable;
24
30
  blob_transfers: BlobTransferTable;
31
+ blob_transfer_sessions: BlobTransferSessionTable;
25
32
  };
package/lib/types.d.ts CHANGED
@@ -8,6 +8,7 @@ export type BlobEntryInput = {
8
8
  state: BlobState;
9
9
  pinned?: boolean;
10
10
  createdAt: number;
11
+ contentType?: string | null;
11
12
  };
12
13
  export type BlobChunkInput = {
13
14
  index: number;
@@ -22,6 +23,18 @@ export type BlobEntry = {
22
23
  state: BlobState;
23
24
  pinned: boolean;
24
25
  createdAt: number;
26
+ contentType: string | null;
27
+ };
28
+ export type BeginTransferParams = {
29
+ blobID: string;
30
+ chunkSize: number;
31
+ chunks: Array<BlobChunkInput>;
32
+ stagingID: string;
33
+ };
34
+ export type BlobTransfer = {
35
+ stagingID: string;
36
+ updatedAt: number;
37
+ presentChunks: Array<number>;
25
38
  };
26
39
  export type BlobStoreAPI = {
27
40
  insertEntry(entry: BlobEntryInput, chunks: Array<BlobChunkInput>): Promise<void>;
@@ -29,8 +42,24 @@ export type BlobStoreAPI = {
29
42
  getChunkDigests(blobID: string): Promise<Array<Uint8Array>>;
30
43
  setPinned(blobID: string, pinned: boolean): Promise<void>;
31
44
  deleteEntry(blobID: string): Promise<void>;
32
- beginTransfer(blobID: string, chunkSize: number, chunks: Array<BlobChunkInput>): Promise<void>;
45
+ beginTransfer(params: BeginTransferParams): Promise<void>;
33
46
  recordTransferChunk(blobID: string, index: number): Promise<void>;
34
47
  getPresentChunkIndexes(blobID: string): Promise<Array<number>>;
35
48
  finalizeTransfer(blobID: string): Promise<void>;
49
+ getTransfer(blobID: string): Promise<BlobTransfer | null>;
50
+ getTransferByStagingID(stagingID: string): Promise<{
51
+ blobID: string;
52
+ updatedAt: number;
53
+ } | null>;
54
+ touchTransfer(blobID: string): Promise<void>;
55
+ listEntries(params: {
56
+ limit: number;
57
+ cursor?: string;
58
+ }): Promise<{
59
+ entries: Array<BlobEntry>;
60
+ nextCursor: string | null;
61
+ }>;
62
+ promoteEntry(entry: Omit<BlobEntryInput, 'state'>, chunks: Array<BlobChunkInput>): Promise<void>;
63
+ resetTransfer(blobID: string): Promise<void>;
64
+ fillContentType(blobID: string, contentType: string): Promise<void>;
36
65
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hozon/store-blob",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Content-addressed blob metadata and resumable transfer store for Hozon",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,13 +18,14 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@hozon/adapter": "^0.1.0",
21
- "@hozon/db": "^0.2.1"
21
+ "@hozon/db": "^0.2.3",
22
+ "@sozai/codec": "^0.4.3"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@hozon/node-sqlite": "^0.1.0",
25
- "@types/node": "^26.6.4",
26
- "del-cli": "^7.0.0",
27
- "vitest": "5.0.3"
26
+ "@types/node": "^26.6.5",
27
+ "del-cli": "^8.0.1",
28
+ "vitest": "5.0.4"
28
29
  },
29
30
  "publishConfig": {
30
31
  "access": "public"