@git-stunts/git-cas 1.6.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/index.js ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @fileoverview Content Addressable Store - Managed blob storage in Git.
3
+ */
4
+
5
+ import { createReadStream, writeFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import CasService from './src/domain/services/CasService.js';
8
+ import GitPersistenceAdapter from './src/infrastructure/adapters/GitPersistenceAdapter.js';
9
+ import NodeCryptoAdapter from './src/infrastructure/adapters/NodeCryptoAdapter.js';
10
+ import Manifest from './src/domain/value-objects/Manifest.js';
11
+ import Chunk from './src/domain/value-objects/Chunk.js';
12
+ import CryptoPort from './src/ports/CryptoPort.js';
13
+ import JsonCodec from './src/infrastructure/codecs/JsonCodec.js';
14
+ import CborCodec from './src/infrastructure/codecs/CborCodec.js';
15
+
16
+ export {
17
+ CasService,
18
+ GitPersistenceAdapter,
19
+ NodeCryptoAdapter,
20
+ CryptoPort,
21
+ Manifest,
22
+ Chunk,
23
+ JsonCodec,
24
+ CborCodec
25
+ };
26
+
27
+ /**
28
+ * Detects the best crypto adapter for the current runtime.
29
+ * @returns {Promise<import('./src/ports/CryptoPort.js').default>} A runtime-appropriate CryptoPort implementation.
30
+ */
31
+ async function getDefaultCryptoAdapter() {
32
+ if (globalThis.Bun) {
33
+ const { default: BunCryptoAdapter } = await import('./src/infrastructure/adapters/BunCryptoAdapter.js');
34
+ return new BunCryptoAdapter();
35
+ }
36
+ if (globalThis.Deno) {
37
+ const { default: WebCryptoAdapter } = await import('./src/infrastructure/adapters/WebCryptoAdapter.js');
38
+ return new WebCryptoAdapter();
39
+ }
40
+ return new NodeCryptoAdapter();
41
+ }
42
+
43
+ /**
44
+ * High-level facade for the Content Addressable Store library.
45
+ *
46
+ * Wraps {@link CasService} with lazy initialization, runtime-adaptive crypto
47
+ * selection, and convenience helpers for file I/O.
48
+ */
49
+ export default class ContentAddressableStore {
50
+ /**
51
+ * @param {Object} options
52
+ * @param {import('@git-stunts/plumbing').default} options.plumbing - GitPlumbing instance for Git operations.
53
+ * @param {number} [options.chunkSize] - Chunk size in bytes (default 256 KiB).
54
+ * @param {import('./src/ports/CodecPort.js').default} [options.codec] - Manifest codec (default JsonCodec).
55
+ * @param {import('./src/ports/CryptoPort.js').default} [options.crypto] - Crypto adapter (auto-detected if omitted).
56
+ * @param {import('@git-stunts/alfred').Policy} [options.policy] - Resilience policy for Git I/O.
57
+ */
58
+ constructor({ plumbing, chunkSize, codec, policy, crypto }) {
59
+ this.plumbing = plumbing;
60
+ this.chunkSizeConfig = chunkSize;
61
+ this.codecConfig = codec;
62
+ this.policyConfig = policy;
63
+ this.cryptoConfig = crypto;
64
+ this.service = null;
65
+ this.#servicePromise = null;
66
+ }
67
+
68
+ #servicePromise = null;
69
+
70
+ /**
71
+ * Lazily initializes the service, handling async adapter discovery.
72
+ * @private
73
+ * @returns {Promise<CasService>}
74
+ */
75
+ async #getService() {
76
+ if (!this.#servicePromise) {
77
+ this.#servicePromise = this.#initService();
78
+ }
79
+ return await this.#servicePromise;
80
+ }
81
+
82
+ /**
83
+ * Constructs the persistence adapter, resolves crypto, and creates the CasService.
84
+ * @private
85
+ * @returns {Promise<CasService>}
86
+ */
87
+ async #initService() {
88
+ const persistence = new GitPersistenceAdapter({
89
+ plumbing: this.plumbing,
90
+ policy: this.policyConfig
91
+ });
92
+ const crypto = this.cryptoConfig || await getDefaultCryptoAdapter();
93
+ this.service = new CasService({
94
+ persistence,
95
+ chunkSize: this.chunkSizeConfig,
96
+ codec: this.codecConfig || new JsonCodec(),
97
+ crypto,
98
+ });
99
+ return this.service;
100
+ }
101
+
102
+ /**
103
+ * Lazily initializes and returns the underlying {@link CasService}.
104
+ * @returns {Promise<CasService>}
105
+ */
106
+ async getService() {
107
+ return await this.#getService();
108
+ }
109
+
110
+ /**
111
+ * Factory to create a CAS with JSON codec.
112
+ * @param {Object} options
113
+ * @param {import('@git-stunts/plumbing').default} options.plumbing - GitPlumbing instance.
114
+ * @param {number} [options.chunkSize] - Chunk size in bytes.
115
+ * @param {import('@git-stunts/alfred').Policy} [options.policy] - Resilience policy.
116
+ * @returns {ContentAddressableStore}
117
+ */
118
+ static createJson({ plumbing, chunkSize, policy }) {
119
+ return new ContentAddressableStore({ plumbing, chunkSize, codec: new JsonCodec(), policy });
120
+ }
121
+
122
+ /**
123
+ * Factory to create a CAS with CBOR codec.
124
+ * @param {Object} options
125
+ * @param {import('@git-stunts/plumbing').default} options.plumbing - GitPlumbing instance.
126
+ * @param {number} [options.chunkSize] - Chunk size in bytes.
127
+ * @param {import('@git-stunts/alfred').Policy} [options.policy] - Resilience policy.
128
+ * @returns {ContentAddressableStore}
129
+ */
130
+ static createCbor({ plumbing, chunkSize, policy }) {
131
+ return new ContentAddressableStore({ plumbing, chunkSize, codec: new CborCodec(), policy });
132
+ }
133
+
134
+ /**
135
+ * Returns the configured chunk size in bytes.
136
+ * @returns {number}
137
+ */
138
+ get chunkSize() {
139
+ return this.service?.chunkSize || this.chunkSizeConfig || 256 * 1024;
140
+ }
141
+
142
+ /**
143
+ * Encrypts a buffer using AES-256-GCM.
144
+ * @param {Object} options
145
+ * @param {Buffer} options.buffer - Plaintext data to encrypt.
146
+ * @param {Buffer} options.key - 32-byte encryption key.
147
+ * @returns {Promise<{ buf: Buffer, meta: { algorithm: string, nonce: string, tag: string, encrypted: boolean } }>}
148
+ */
149
+ async encrypt(options) {
150
+ const service = await this.#getService();
151
+ return await service.encrypt(options);
152
+ }
153
+
154
+ /**
155
+ * Decrypts a buffer. Returns it unchanged if `meta.encrypted` is falsy.
156
+ * @param {Object} options
157
+ * @param {Buffer} options.buffer - Ciphertext to decrypt.
158
+ * @param {Buffer} options.key - 32-byte encryption key.
159
+ * @param {{ encrypted: boolean, algorithm: string, nonce: string, tag: string }} options.meta - Encryption metadata.
160
+ * @returns {Promise<Buffer>}
161
+ */
162
+ async decrypt(options) {
163
+ const service = await this.#getService();
164
+ return await service.decrypt(options);
165
+ }
166
+
167
+ /**
168
+ * Reads a file from disk and stores it in Git as chunked blobs.
169
+ *
170
+ * Convenience wrapper that opens a read stream and delegates to
171
+ * {@link CasService#store}.
172
+ *
173
+ * @param {Object} options
174
+ * @param {string} options.filePath - Absolute or relative path to the file.
175
+ * @param {string} options.slug - Logical identifier for the stored asset.
176
+ * @param {string} [options.filename] - Override filename (defaults to basename of filePath).
177
+ * @param {Buffer} [options.encryptionKey] - 32-byte key for AES-256-GCM encryption.
178
+ * @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>} The resulting manifest.
179
+ */
180
+ async storeFile({ filePath, slug, filename, encryptionKey }) {
181
+ const source = createReadStream(filePath);
182
+ const service = await this.#getService();
183
+ return await service.store({
184
+ source,
185
+ slug,
186
+ filename: filename || path.basename(filePath),
187
+ encryptionKey,
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Stores an async iterable source in Git as chunked blobs.
193
+ * @param {Object} options
194
+ * @param {AsyncIterable<Buffer>} options.source - Data to store.
195
+ * @param {string} options.slug - Logical identifier for the stored asset.
196
+ * @param {string} options.filename - Filename for the manifest.
197
+ * @param {Buffer} [options.encryptionKey] - 32-byte key for AES-256-GCM encryption.
198
+ * @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>} The resulting manifest.
199
+ */
200
+ async store(options) {
201
+ const service = await this.#getService();
202
+ return await service.store(options);
203
+ }
204
+
205
+ /**
206
+ * Restores a file from its manifest and writes it to disk.
207
+ * @param {Object} options
208
+ * @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest - The file manifest.
209
+ * @param {Buffer} [options.encryptionKey] - 32-byte key, required if manifest is encrypted.
210
+ * @param {string} options.outputPath - Destination file path.
211
+ * @returns {Promise<{ bytesWritten: number }>}
212
+ */
213
+ async restoreFile({ manifest, encryptionKey, outputPath }) {
214
+ const service = await this.#getService();
215
+ const { buffer, bytesWritten } = await service.restore({
216
+ manifest,
217
+ encryptionKey,
218
+ });
219
+ writeFileSync(outputPath, buffer);
220
+ return { bytesWritten };
221
+ }
222
+
223
+ /**
224
+ * Restores a file from its manifest, returning the buffer directly.
225
+ * @param {Object} options
226
+ * @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest - The file manifest.
227
+ * @param {Buffer} [options.encryptionKey] - 32-byte key, required if manifest is encrypted.
228
+ * @returns {Promise<{ buffer: Buffer, bytesWritten: number }>}
229
+ */
230
+ async restore(options) {
231
+ const service = await this.#getService();
232
+ return await service.restore(options);
233
+ }
234
+
235
+ /**
236
+ * Creates a Git tree object from a manifest.
237
+ * @param {Object} options
238
+ * @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest - The file manifest.
239
+ * @returns {Promise<string>} Git OID of the created tree.
240
+ */
241
+ async createTree(options) {
242
+ const service = await this.#getService();
243
+ return await service.createTree(options);
244
+ }
245
+
246
+ /**
247
+ * Verifies the integrity of a stored file by re-hashing its chunks.
248
+ * @param {import('./src/domain/value-objects/Manifest.js').default} manifest - The file manifest.
249
+ * @returns {Promise<boolean>} `true` if all chunks pass verification.
250
+ */
251
+ async verifyIntegrity(manifest) {
252
+ const service = await this.#getService();
253
+ return await service.verifyIntegrity(manifest);
254
+ }
255
+
256
+ /**
257
+ * Reads a manifest from a Git tree OID.
258
+ * @param {Object} options
259
+ * @param {string} options.treeOid - Git tree OID to read the manifest from.
260
+ * @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>}
261
+ */
262
+ async readManifest(options) {
263
+ const service = await this.#getService();
264
+ return await service.readManifest(options);
265
+ }
266
+
267
+ /**
268
+ * Returns deletion metadata for an asset stored in a Git tree.
269
+ * Does not perform any destructive Git operations.
270
+ * @param {Object} options
271
+ * @param {string} options.treeOid - Git tree OID of the asset.
272
+ * @returns {Promise<{ slug: string, chunksOrphaned: number }>}
273
+ */
274
+ async deleteAsset(options) {
275
+ const service = await this.#getService();
276
+ return await service.deleteAsset(options);
277
+ }
278
+
279
+ /**
280
+ * Aggregates referenced chunk blob OIDs across multiple stored assets.
281
+ * Analysis only — does not delete or modify anything.
282
+ * @param {Object} options
283
+ * @param {string[]} options.treeOids - Git tree OIDs to analyze.
284
+ * @returns {Promise<{ referenced: Set<string>, total: number }>}
285
+ */
286
+ async findOrphanedChunks(options) {
287
+ const service = await this.#getService();
288
+ return await service.findOrphanedChunks(options);
289
+ }
290
+ }
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@git-stunts/git-cas",
3
+ "version": "1.6.0",
4
+ "description": "Content-addressed storage backed by Git's object database, with optional encryption and pluggable codecs",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "git-cas": "./bin/git-cas.js"
9
+ },
10
+ "exports": {
11
+ ".": "./index.js",
12
+ "./service": "./src/domain/services/CasService.js",
13
+ "./schema": "./src/domain/schemas/ManifestSchema.js"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "bin/",
18
+ "src/",
19
+ "README.md",
20
+ "LICENSE",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22.0.0"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/git-stunts/git-cas.git"
29
+ },
30
+ "keywords": [
31
+ "git",
32
+ "cas",
33
+ "content-addressable",
34
+ "storage",
35
+ "deduplication",
36
+ "encryption",
37
+ "blob",
38
+ "bun",
39
+ "deno"
40
+ ],
41
+ "author": "James Ross <james@flyingrobots.dev>",
42
+ "license": "Apache-2.0",
43
+ "bugs": {
44
+ "url": "https://github.com/git-stunts/git-cas/issues"
45
+ },
46
+ "homepage": "https://github.com/git-stunts/git-cas#readme",
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "provenance": true
50
+ },
51
+ "scripts": {
52
+ "test": "vitest run test/unit",
53
+ "test:local": "vitest run test/unit",
54
+ "test:node": "docker compose run --build --rm test-node",
55
+ "test:bun": "docker compose run --build --rm test-bun",
56
+ "test:deno": "docker compose run --build --rm test-deno",
57
+ "test:integration": "vitest run test/integration",
58
+ "test:integration:node": "docker compose run --build --rm test-node npx vitest run test/integration",
59
+ "test:integration:bun": "docker compose run --build --rm test-bun bunx vitest run test/integration",
60
+ "test:integration:deno": "docker compose run --build --rm test-deno deno run -A npm:vitest run test/integration",
61
+ "test:platforms": "bats --jobs 3 test/platform/runtimes.bats",
62
+ "benchmark": "vitest bench test/benchmark",
63
+ "benchmark:local": "vitest bench test/benchmark",
64
+ "lint": "eslint .",
65
+ "format": "prettier --write ."
66
+ },
67
+ "dependencies": {
68
+ "@git-stunts/alfred": "^0.10.0",
69
+ "@git-stunts/plumbing": "^2.8.0",
70
+ "cbor-x": "^1.6.0",
71
+ "commander": "^14.0.3",
72
+ "zod": "^3.24.1"
73
+ },
74
+ "devDependencies": {
75
+ "@eslint/js": "^9.17.0",
76
+ "eslint": "^9.17.0",
77
+ "jsr": "^0.14.2",
78
+ "prettier": "^3.4.2",
79
+ "vitest": "^2.1.8"
80
+ }
81
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Base error class for CAS operations.
3
+ *
4
+ * Carries a machine-readable `code` and an optional `meta` bag for
5
+ * structured error context.
6
+ */
7
+ export default class CasError extends Error {
8
+ /**
9
+ * @param {string} message - Human-readable error description.
10
+ * @param {string} code - Machine-readable error code (e.g. `'INTEGRITY_ERROR'`).
11
+ * @param {Object} [meta={}] - Arbitrary metadata for diagnostics.
12
+ */
13
+ constructor(message, code, meta = {}) {
14
+ super(message);
15
+ this.name = this.constructor.name;
16
+ this.code = code;
17
+ this.meta = meta;
18
+ Error.captureStackTrace(this, this.constructor);
19
+ }
20
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @fileoverview Zod schemas for validating CAS manifest and chunk data.
3
+ */
4
+
5
+ import z from 'zod';
6
+
7
+ /** Validates a single chunk entry within a manifest. */
8
+ export const ChunkSchema = z.object({
9
+ index: z.number().int().min(0),
10
+ size: z.number().int().positive(),
11
+ digest: z.string().length(64), // SHA-256
12
+ blob: z.string().min(1), // Git OID
13
+ });
14
+
15
+ /** Validates the encryption metadata attached to an encrypted manifest. */
16
+ export const EncryptionSchema = z.object({
17
+ algorithm: z.string(),
18
+ nonce: z.string(),
19
+ tag: z.string(),
20
+ encrypted: z.boolean().default(true),
21
+ });
22
+
23
+ /** Validates a complete file manifest. */
24
+ export const ManifestSchema = z.object({
25
+ slug: z.string().min(1),
26
+ filename: z.string().min(1),
27
+ size: z.number().int().min(0),
28
+ chunks: z.array(ChunkSchema),
29
+ encryption: EncryptionSchema.optional(),
30
+ });