@hozon/store-blob 0.1.0 → 0.2.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.
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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Adapter } from '@hozon/adapter';
2
- import type { Kysely } from 'kysely';
2
+ import type { Kysely } from '@hozon/db';
3
3
  import type { BlobTables } from './tables.js';
4
4
  import type { BlobStoreAPI } from './types.js';
5
5
  export declare function createBlobStoreAPI(db: Kysely<BlobTables>, adapter: Adapter): BlobStoreAPI;
package/lib/api.js CHANGED
@@ -1,4 +1,25 @@
1
1
  import { chunk, withStoreTransaction } from '@hozon/db';
2
+ import { b64uFromUTF, b64uToUTF } from '@sozai/codec';
3
+ function encodeCursor(createdAt, blobID) {
4
+ return b64uFromUTF(JSON.stringify([
5
+ createdAt,
6
+ blobID
7
+ ]));
8
+ }
9
+ function decodeCursor(cursor) {
10
+ try {
11
+ const value = JSON.parse(b64uToUTF(cursor));
12
+ if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number' && Number.isFinite(value[0]) && typeof value[1] === 'string') {
13
+ return [
14
+ value[0],
15
+ value[1]
16
+ ];
17
+ }
18
+ } catch {
19
+ // fall through
20
+ }
21
+ throw new Error('Invalid cursor');
22
+ }
2
23
  export function createBlobStoreAPI(db, adapter) {
3
24
  async function insertManifest(trx, blobID, chunks) {
4
25
  const rows = chunks.map((item)=>({
@@ -25,7 +46,8 @@ export function createBlobStoreAPI(db, adapter) {
25
46
  chunk_size: entry.chunkSize,
26
47
  state: entry.state,
27
48
  pinned: entry.pinned ? 1 : 0,
28
- created_at: entry.createdAt
49
+ created_at: entry.createdAt,
50
+ content_type: entry.contentType ?? null
29
51
  }).onConflict((oc)=>oc.column('blob_id').doNothing()).execute();
30
52
  await insertManifest(trx, entry.blobID, chunks);
31
53
  });
@@ -40,7 +62,8 @@ export function createBlobStoreAPI(db, adapter) {
40
62
  chunkSize: row.chunk_size,
41
63
  state: row.state,
42
64
  pinned: row.pinned === 1,
43
- createdAt: row.created_at
65
+ createdAt: row.created_at,
66
+ contentType: row.content_type
44
67
  };
45
68
  },
46
69
  async getChunkDigests (blobID) {
@@ -54,12 +77,13 @@ export function createBlobStoreAPI(db, adapter) {
54
77
  },
55
78
  async deleteEntry (blobID) {
56
79
  await withStoreTransaction(db, async (trx)=>{
80
+ await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
57
81
  await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
58
82
  await trx.deleteFrom('blob_chunks').where('blob_id', '=', blobID).execute();
59
83
  await trx.deleteFrom('blob_entries').where('blob_id', '=', blobID).execute();
60
84
  });
61
85
  },
62
- async beginTransfer (blobID, chunkSize, chunks) {
86
+ async beginTransfer ({ blobID, chunkSize, chunks, stagingID }) {
63
87
  await withStoreTransaction(db, async (trx)=>{
64
88
  let query = trx.selectFrom('blob_entries').select([
65
89
  'blob_id',
@@ -79,6 +103,20 @@ export function createBlobStoreAPI(db, adapter) {
79
103
  state: 'partial'
80
104
  }).where('blob_id', '=', blobID).execute();
81
105
  await insertManifest(trx, blobID, chunks);
106
+ const existing = await trx.selectFrom('blob_transfer_sessions').select('staging_id').where('blob_id', '=', blobID).executeTakeFirst();
107
+ // A new staging area holds none of the previous session's chunks.
108
+ if (existing !== undefined && existing.staging_id !== stagingID) {
109
+ await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
110
+ }
111
+ const updatedAt = Date.now();
112
+ await trx.insertInto('blob_transfer_sessions').values({
113
+ blob_id: blobID,
114
+ staging_id: stagingID,
115
+ updated_at: updatedAt
116
+ }).onConflict((oc)=>oc.column('blob_id').doUpdateSet({
117
+ staging_id: stagingID,
118
+ updated_at: updatedAt
119
+ })).execute();
82
120
  });
83
121
  },
84
122
  async recordTransferChunk (blobID, index) {
@@ -94,6 +132,9 @@ export function createBlobStoreAPI(db, adapter) {
94
132
  'blob_id',
95
133
  'index'
96
134
  ]).doNothing()).execute();
135
+ await trx.updateTable('blob_transfer_sessions').set({
136
+ updated_at: Date.now()
137
+ }).where('blob_id', '=', blobID).execute();
97
138
  });
98
139
  },
99
140
  async getPresentChunkIndexes (blobID) {
@@ -115,7 +156,116 @@ export function createBlobStoreAPI(db, adapter) {
115
156
  state: 'local'
116
157
  }).where('blob_id', '=', blobID).execute();
117
158
  await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
159
+ await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
160
+ });
161
+ },
162
+ async getTransfer (blobID) {
163
+ const session = await db.selectFrom('blob_transfer_sessions').select([
164
+ 'staging_id',
165
+ 'updated_at'
166
+ ]).where('blob_id', '=', blobID).executeTakeFirst();
167
+ if (session === undefined) return null;
168
+ const rows = await db.selectFrom('blob_transfers').select('index').where('blob_id', '=', blobID).orderBy('index', 'asc').execute();
169
+ return {
170
+ stagingID: session.staging_id,
171
+ updatedAt: session.updated_at,
172
+ presentChunks: rows.map((row)=>row.index)
173
+ };
174
+ },
175
+ async getTransferByStagingID (stagingID) {
176
+ const row = await db.selectFrom('blob_transfer_sessions').select([
177
+ 'blob_id',
178
+ 'updated_at'
179
+ ]).where('staging_id', '=', stagingID).executeTakeFirst();
180
+ return row === undefined ? null : {
181
+ blobID: row.blob_id,
182
+ updatedAt: row.updated_at
183
+ };
184
+ },
185
+ async touchTransfer (blobID) {
186
+ await db.updateTable('blob_transfer_sessions').set({
187
+ updated_at: Date.now()
188
+ }).where('blob_id', '=', blobID).execute();
189
+ },
190
+ async listEntries (params) {
191
+ const { limit, cursor } = params;
192
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
193
+ throw new Error('Invalid limit: must be an integer between 1 and 1000');
194
+ }
195
+ let query = db.selectFrom('blob_entries').selectAll();
196
+ if (cursor !== undefined) {
197
+ const [createdAt, blobID] = decodeCursor(cursor);
198
+ query = query.where((eb)=>eb.or([
199
+ eb('created_at', '>', createdAt),
200
+ eb.and([
201
+ eb('created_at', '=', createdAt),
202
+ eb('blob_id', '>', blobID)
203
+ ])
204
+ ]));
205
+ }
206
+ const rows = await query.orderBy('created_at', 'asc').orderBy('blob_id', 'asc').limit(limit + 1).execute();
207
+ const page = rows.slice(0, limit);
208
+ const entries = page.map((row)=>({
209
+ blobID: row.blob_id,
210
+ contentLength: row.content_length,
211
+ encrypted: row.encrypted === 1,
212
+ keyID: row.key_id,
213
+ chunkSize: row.chunk_size,
214
+ state: row.state,
215
+ pinned: row.pinned === 1,
216
+ createdAt: row.created_at,
217
+ contentType: row.content_type
218
+ }));
219
+ const last = page[page.length - 1];
220
+ const nextCursor = rows.length > limit && last !== undefined ? encodeCursor(last.created_at, last.blob_id) : null;
221
+ return {
222
+ entries,
223
+ nextCursor
224
+ };
225
+ },
226
+ async promoteEntry (entry, chunks) {
227
+ await withStoreTransaction(db, async (trx)=>{
228
+ let lock = trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', entry.blobID);
229
+ if (adapter.kind === 'postgres') lock = lock.forUpdate();
230
+ await lock.executeTakeFirst();
231
+ const values = {
232
+ content_length: entry.contentLength,
233
+ encrypted: entry.encrypted ? 1 : 0,
234
+ key_id: entry.keyID ?? null,
235
+ chunk_size: entry.chunkSize,
236
+ state: 'local',
237
+ content_type: entry.contentType ?? null
238
+ };
239
+ await trx.insertInto('blob_entries').values({
240
+ blob_id: entry.blobID,
241
+ pinned: entry.pinned ? 1 : 0,
242
+ created_at: entry.createdAt,
243
+ ...values
244
+ }).onConflict((oc)=>oc.column('blob_id').doUpdateSet(values)).execute();
245
+ await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', entry.blobID).execute();
246
+ await trx.deleteFrom('blob_transfers').where('blob_id', '=', entry.blobID).execute();
247
+ await trx.deleteFrom('blob_chunks').where('blob_id', '=', entry.blobID).execute();
248
+ await insertManifest(trx, entry.blobID, chunks);
249
+ });
250
+ },
251
+ async resetTransfer (blobID) {
252
+ await withStoreTransaction(db, async (trx)=>{
253
+ let lock = trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID);
254
+ if (adapter.kind === 'postgres') lock = lock.forUpdate();
255
+ const entry = await lock.executeTakeFirst();
256
+ if (entry === undefined) return;
257
+ await trx.deleteFrom('blob_transfer_sessions').where('blob_id', '=', blobID).execute();
258
+ await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
259
+ await trx.deleteFrom('blob_chunks').where('blob_id', '=', blobID).execute();
260
+ await trx.updateTable('blob_entries').set({
261
+ state: 'remote-only'
262
+ }).where('blob_id', '=', blobID).execute();
118
263
  });
264
+ },
265
+ async fillContentType (blobID, contentType) {
266
+ await db.updateTable('blob_entries').set({
267
+ content_type: contentType
268
+ }).where('blob_id', '=', blobID).where('content_type', 'is', null).execute();
119
269
  }
120
270
  };
121
271
  }
package/lib/index.d.ts CHANGED
@@ -1,3 +1,3 @@
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 type { BlobChunkTable, BlobEntryTable, BlobTables, BlobTransferSessionTable, BlobTransferTable, } from './tables.js';
3
+ export type { BeginTransferParams, BlobChunkInput, BlobEntry, BlobEntryInput, BlobState, BlobStoreAPI, BlobTransfer, } from './types.js';
@@ -1,3 +1,2 @@
1
- import type { MigrationContext } from '@hozon/db';
2
- import type { Migration } from 'kysely/migration';
1
+ import type { Migration, MigrationContext } from '@hozon/db';
3
2
  export declare function blobStoreMigrations(ctx: MigrationContext): Record<string, Migration>;
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.0",
3
+ "version": "0.2.0",
4
4
  "description": "Content-addressed blob metadata and resumable transfer store for Hozon",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,8 +18,8 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@hozon/adapter": "^0.1.0",
21
- "@hozon/db": "^0.2.0",
22
- "kysely": "^0.29.6"
21
+ "@hozon/db": "^0.2.2",
22
+ "@sozai/codec": "^0.4.2"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@hozon/node-sqlite": "^0.1.0",