@hozon/store-blob 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Le Cam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,46 @@
1
- # Temporary Holding Version
1
+ # @hozon/store-blob
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Content-addressed blob metadata, persistent chunk manifests, and resumable transfer progress.
4
+ Byte storage and digest verification belong to the caller.
5
+
6
+ ```sh
7
+ pnpm add @hozon/store-blob @hozon/db
8
+ ```
9
+
10
+ ```ts
11
+ import { blobStoreDefinition, getBlobStore } from '@hozon/store-blob'
12
+
13
+ db.register(blobStoreDefinition)
14
+ const blobs = await getBlobStore(db)
15
+ await blobs.insertEntry(
16
+ {
17
+ blobID: 'content-key',
18
+ contentLength: 0,
19
+ chunkSize: 4,
20
+ state: 'local',
21
+ createdAt: Date.now(),
22
+ },
23
+ [],
24
+ )
25
+ ```
26
+
27
+ Entries map integer flags to booleans and expose camelCase fields.
28
+ Encryption and key IDs are optional metadata that the store does not interpret.
29
+ Repeated entry and manifest inserts preserve existing rows.
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.
33
+ Changing chunk size while a manifest exists rejects with `Cannot change chunk size for blob <blobID>: manifest already exists`.
34
+ 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
+ `beginTransfer`, `recordTransferChunk`, and `finalizeTransfer` reject absent entries with `Blob entry <blobID> not found`.
37
+ `recordTransferChunk` rejects unknown manifest indexes with `Blob chunk <blobID> at index <index> not found`.
38
+ Finalisation rejects missing chunks with `Cannot finalize transfer <blobID>: <count> chunk(s) missing`.
39
+ `deleteEntry` removes the entry, manifest, and transfer progress together.
40
+ Manifest chunks reference entries, and transfer rows reference manifest chunks, with cascading deletion preventing orphan progress.
41
+
42
+ Tables follow the database's configured prefix, which defaults to `hozon`.
43
+ Multi-statement mutations are atomic and reuse a provider transaction.
44
+ Manifest inserts use batches of 166 rows to stay below 500 bound parameters.
45
+
46
+ See the [store reference](../../docs/reference/stores/blob.md).
package/lib/api.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { Adapter } from '@hozon/adapter';
2
+ import type { Kysely } from '@hozon/db';
3
+ import type { BlobTables } from './tables.js';
4
+ import type { BlobStoreAPI } from './types.js';
5
+ export declare function createBlobStoreAPI(db: Kysely<BlobTables>, adapter: Adapter): BlobStoreAPI;
package/lib/api.js ADDED
@@ -0,0 +1,121 @@
1
+ import { chunk, withStoreTransaction } from '@hozon/db';
2
+ export function createBlobStoreAPI(db, adapter) {
3
+ async function insertManifest(trx, blobID, chunks) {
4
+ const rows = chunks.map((item)=>({
5
+ blob_id: blobID,
6
+ index: item.index,
7
+ digest: adapter.encodeBinary(item.digest)
8
+ }));
9
+ // Three bindings per row leave each statement below 500 parameters.
10
+ for (const batch of chunk(rows, 166)){
11
+ await trx.insertInto('blob_chunks').values(batch).onConflict((oc)=>oc.columns([
12
+ 'blob_id',
13
+ 'index'
14
+ ]).doNothing()).execute();
15
+ }
16
+ }
17
+ return {
18
+ async insertEntry (entry, chunks) {
19
+ await withStoreTransaction(db, async (trx)=>{
20
+ await trx.insertInto('blob_entries').values({
21
+ blob_id: entry.blobID,
22
+ content_length: entry.contentLength,
23
+ encrypted: entry.encrypted ? 1 : 0,
24
+ key_id: entry.keyID ?? null,
25
+ chunk_size: entry.chunkSize,
26
+ state: entry.state,
27
+ pinned: entry.pinned ? 1 : 0,
28
+ created_at: entry.createdAt
29
+ }).onConflict((oc)=>oc.column('blob_id').doNothing()).execute();
30
+ await insertManifest(trx, entry.blobID, chunks);
31
+ });
32
+ },
33
+ async getEntry (blobID) {
34
+ const row = await db.selectFrom('blob_entries').selectAll().where('blob_id', '=', blobID).executeTakeFirst();
35
+ return row === undefined ? null : {
36
+ blobID: row.blob_id,
37
+ contentLength: row.content_length,
38
+ encrypted: row.encrypted === 1,
39
+ keyID: row.key_id,
40
+ chunkSize: row.chunk_size,
41
+ state: row.state,
42
+ pinned: row.pinned === 1,
43
+ createdAt: row.created_at
44
+ };
45
+ },
46
+ async getChunkDigests (blobID) {
47
+ const rows = await db.selectFrom('blob_chunks').select('digest').where('blob_id', '=', blobID).orderBy('index', 'asc').execute();
48
+ return rows.map((row)=>row.digest);
49
+ },
50
+ async setPinned (blobID, pinned) {
51
+ await db.updateTable('blob_entries').set({
52
+ pinned: pinned ? 1 : 0
53
+ }).where('blob_id', '=', blobID).execute();
54
+ },
55
+ async deleteEntry (blobID) {
56
+ await withStoreTransaction(db, async (trx)=>{
57
+ await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
58
+ await trx.deleteFrom('blob_chunks').where('blob_id', '=', blobID).execute();
59
+ await trx.deleteFrom('blob_entries').where('blob_id', '=', blobID).execute();
60
+ });
61
+ },
62
+ async beginTransfer (blobID, chunkSize, chunks) {
63
+ await withStoreTransaction(db, async (trx)=>{
64
+ let query = trx.selectFrom('blob_entries').select([
65
+ 'blob_id',
66
+ 'chunk_size'
67
+ ]).where('blob_id', '=', blobID);
68
+ if (adapter.kind === 'postgres') query = query.forUpdate();
69
+ const entry = await query.executeTakeFirst();
70
+ if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
71
+ if (entry.chunk_size !== chunkSize) {
72
+ const manifest = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).executeTakeFirst();
73
+ if (manifest !== undefined) {
74
+ throw new Error(`Cannot change chunk size for blob ${blobID}: manifest already exists`);
75
+ }
76
+ }
77
+ await trx.updateTable('blob_entries').set({
78
+ chunk_size: chunkSize,
79
+ state: 'partial'
80
+ }).where('blob_id', '=', blobID).execute();
81
+ await insertManifest(trx, blobID, chunks);
82
+ });
83
+ },
84
+ async recordTransferChunk (blobID, index) {
85
+ await withStoreTransaction(db, async (trx)=>{
86
+ const entry = await trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID).executeTakeFirst();
87
+ if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
88
+ const chunk = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).where('index', '=', index).executeTakeFirst();
89
+ if (chunk === undefined) throw new Error(`Blob chunk ${blobID} at index ${index} not found`);
90
+ await trx.insertInto('blob_transfers').values({
91
+ blob_id: blobID,
92
+ index
93
+ }).onConflict((oc)=>oc.columns([
94
+ 'blob_id',
95
+ 'index'
96
+ ]).doNothing()).execute();
97
+ });
98
+ },
99
+ async getPresentChunkIndexes (blobID) {
100
+ const rows = await db.selectFrom('blob_transfers').select('index').where('blob_id', '=', blobID).orderBy('index', 'asc').execute();
101
+ return rows.map((row)=>row.index);
102
+ },
103
+ async finalizeTransfer (blobID) {
104
+ await withStoreTransaction(db, async (trx)=>{
105
+ const entry = await trx.selectFrom('blob_entries').select('blob_id').where('blob_id', '=', blobID).executeTakeFirst();
106
+ if (entry === undefined) throw new Error(`Blob entry ${blobID} not found`);
107
+ const manifest = await trx.selectFrom('blob_chunks').select('index').where('blob_id', '=', blobID).execute();
108
+ const present = await trx.selectFrom('blob_transfers').select('index').where('blob_id', '=', blobID).execute();
109
+ const presentSet = new Set(present.map((row)=>row.index));
110
+ const missing = manifest.filter((row)=>!presentSet.has(row.index));
111
+ if (missing.length > 0) {
112
+ throw new Error(`Cannot finalize transfer ${blobID}: ${missing.length} chunk(s) missing`);
113
+ }
114
+ await trx.updateTable('blob_entries').set({
115
+ state: 'local'
116
+ }).where('blob_id', '=', blobID).execute();
117
+ await trx.deleteFrom('blob_transfers').where('blob_id', '=', blobID).execute();
118
+ });
119
+ }
120
+ };
121
+ }
@@ -0,0 +1,6 @@
1
+ import type { StoreDefinition, StoreProvider } from '@hozon/db';
2
+ import type { BlobTables } from './tables.js';
3
+ import type { BlobStoreAPI } from './types.js';
4
+ export declare const BLOB_STORE = "blob";
5
+ export declare const blobStoreDefinition: StoreDefinition<BlobTables, BlobStoreAPI>;
6
+ export declare function getBlobStore(provider: StoreProvider): Promise<BlobStoreAPI>;
@@ -0,0 +1,11 @@
1
+ import { createBlobStoreAPI } from './api.js';
2
+ import { blobStoreMigrations } from './migrations.js';
3
+ export const BLOB_STORE = 'blob';
4
+ export const blobStoreDefinition = {
5
+ name: BLOB_STORE,
6
+ migrations: blobStoreMigrations,
7
+ createAPI: createBlobStoreAPI
8
+ };
9
+ export async function getBlobStore(provider) {
10
+ return provider.getStore(BLOB_STORE);
11
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,3 @@
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';
package/lib/index.js ADDED
@@ -0,0 +1 @@
1
+ export { BLOB_STORE, blobStoreDefinition, getBlobStore } from './definition.js';
@@ -0,0 +1,2 @@
1
+ import type { Migration, MigrationContext } from '@hozon/db';
2
+ export declare function blobStoreMigrations(ctx: MigrationContext): Record<string, Migration>;
@@ -0,0 +1,33 @@
1
+ export function blobStoreMigrations(ctx) {
2
+ return {
3
+ '0-init': {
4
+ async up (db) {
5
+ await db.schema.createTable('blob_entries').addColumn('blob_id', ctx.types.text, (column)=>column.notNull().primaryKey())// Byte lengths and epoch milliseconds can exceed int32.
6
+ .addColumn('content_length', ctx.types.bigint, (column)=>column.notNull()).addColumn('encrypted', 'integer', (column)=>column.notNull().defaultTo(0)).addColumn('key_id', ctx.types.text).addColumn('chunk_size', 'integer', (column)=>column.notNull()).addColumn('state', ctx.types.text, (column)=>column.notNull()).addColumn('pinned', 'integer', (column)=>column.notNull().defaultTo(0)).addColumn('created_at', ctx.types.bigint, (column)=>column.notNull()).execute();
7
+ await db.schema.createTable('blob_chunks').addColumn('blob_id', ctx.types.text, (column)=>column.notNull()).addColumn('index', 'integer', (column)=>column.notNull()).addColumn('digest', ctx.types.binary, (column)=>column.notNull()).addPrimaryKeyConstraint(`${ctx.tablePrefix}_blob_chunks_pkey`, [
8
+ 'blob_id',
9
+ 'index'
10
+ ]).addForeignKeyConstraint(`${ctx.tablePrefix}_blob_chunks_entry_fkey`, [
11
+ 'blob_id'
12
+ ], 'blob_entries', [
13
+ 'blob_id'
14
+ ], (constraint)=>constraint.onDelete('cascade')).execute();
15
+ await db.schema.createTable('blob_transfers').addColumn('blob_id', ctx.types.text, (column)=>column.notNull()).addColumn('index', 'integer', (column)=>column.notNull()).addPrimaryKeyConstraint(`${ctx.tablePrefix}_blob_transfers_pkey`, [
16
+ 'blob_id',
17
+ 'index'
18
+ ]).addForeignKeyConstraint(`${ctx.tablePrefix}_blob_transfers_chunk_fkey`, [
19
+ 'blob_id',
20
+ 'index'
21
+ ], 'blob_chunks', [
22
+ 'blob_id',
23
+ 'index'
24
+ ], (constraint)=>constraint.onDelete('cascade')).execute();
25
+ },
26
+ async down (db) {
27
+ await db.schema.dropTable('blob_transfers').execute();
28
+ await db.schema.dropTable('blob_chunks').execute();
29
+ await db.schema.dropTable('blob_entries').execute();
30
+ }
31
+ }
32
+ };
33
+ }
@@ -0,0 +1,25 @@
1
+ import type { BlobState } from './types.js';
2
+ export type BlobEntryTable = {
3
+ blob_id: string;
4
+ content_length: number;
5
+ encrypted: number;
6
+ key_id: string | null;
7
+ chunk_size: number;
8
+ state: BlobState;
9
+ pinned: number;
10
+ created_at: number;
11
+ };
12
+ export type BlobChunkTable = {
13
+ blob_id: string;
14
+ index: number;
15
+ digest: Uint8Array;
16
+ };
17
+ export type BlobTransferTable = {
18
+ blob_id: string;
19
+ index: number;
20
+ };
21
+ export type BlobTables = {
22
+ blob_entries: BlobEntryTable;
23
+ blob_chunks: BlobChunkTable;
24
+ blob_transfers: BlobTransferTable;
25
+ };
package/lib/tables.js ADDED
@@ -0,0 +1 @@
1
+ export { };
package/lib/types.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ export type BlobState = 'local' | 'partial' | 'remote-only';
2
+ export type BlobEntryInput = {
3
+ blobID: string;
4
+ contentLength: number;
5
+ encrypted?: boolean;
6
+ keyID?: string | null;
7
+ chunkSize: number;
8
+ state: BlobState;
9
+ pinned?: boolean;
10
+ createdAt: number;
11
+ };
12
+ export type BlobChunkInput = {
13
+ index: number;
14
+ digest: Uint8Array;
15
+ };
16
+ export type BlobEntry = {
17
+ blobID: string;
18
+ contentLength: number;
19
+ encrypted: boolean;
20
+ keyID: string | null;
21
+ chunkSize: number;
22
+ state: BlobState;
23
+ pinned: boolean;
24
+ createdAt: number;
25
+ };
26
+ export type BlobStoreAPI = {
27
+ insertEntry(entry: BlobEntryInput, chunks: Array<BlobChunkInput>): Promise<void>;
28
+ getEntry(blobID: string): Promise<BlobEntry | null>;
29
+ getChunkDigests(blobID: string): Promise<Array<Uint8Array>>;
30
+ setPinned(blobID: string, pinned: boolean): Promise<void>;
31
+ deleteEntry(blobID: string): Promise<void>;
32
+ beginTransfer(blobID: string, chunkSize: number, chunks: Array<BlobChunkInput>): Promise<void>;
33
+ recordTransferChunk(blobID: string, index: number): Promise<void>;
34
+ getPresentChunkIndexes(blobID: string): Promise<Array<number>>;
35
+ finalizeTransfer(blobID: string): Promise<void>;
36
+ };
package/lib/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // Persists metadata and transfer progress. Callers own byte storage and verification.
2
+ export { };
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@hozon/store-blob",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Content-addressed blob metadata and resumable transfer store for Hozon",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/TairuFramework/hozon.git",
8
+ "directory": "packages/store-blob"
9
+ },
10
+ "license": "MIT",
11
+ "sideEffects": false,
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./lib/index.js"
15
+ },
16
+ "files": [
17
+ "lib/*"
18
+ ],
19
+ "dependencies": {
20
+ "@hozon/adapter": "^0.1.0",
21
+ "@hozon/db": "^0.2.1"
22
+ },
23
+ "devDependencies": {
24
+ "@hozon/node-sqlite": "^0.1.0",
25
+ "@types/node": "^26.6.4",
26
+ "del-cli": "^7.0.0",
27
+ "vitest": "5.0.3"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
34
+ "build:clean": "del-cli lib",
35
+ "build:js": "del-cli \"lib/**/*.js\" && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
36
+ "build:types": "del-cli \"lib/**/*.d.ts\" \"lib/**/*.d.ts.map\" && tsc --emitDeclarationOnly --skipLibCheck",
37
+ "build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck --declarationMap false",
38
+ "test": "pnpm run test:types && pnpm run test:unit",
39
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
40
+ "test:unit": "vitest run"
41
+ }
6
42
  }