Skip to content

Commit d9cae48

Browse files
authored
Merge pull request #960 from constructive-io/devin/1775197970-upload-client
feat: add @constructive-io/upload-client package (Phase 2B) + README header fixes
2 parents 7b4274e + 76674ee commit d9cae48

16 files changed

Lines changed: 1252 additions & 1 deletion

File tree

graphile/graphile-presigned-url-plugin/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# graphile-presigned-url-plugin
22

3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
<p align="center" width="100%">
8+
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
9+
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
10+
</a>
11+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12+
<a href="https://www.npmjs.com/package/graphile-presigned-url-plugin"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-presigned-url-plugin%2Fpackage.json"/></a>
13+
</p>
14+
315
Presigned URL upload plugin for PostGraphile v5.
416

517
## Features

graphql/node-type-registry/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# node-type-registry
22

3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
<p align="center" width="100%">
8+
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
9+
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
10+
</a>
11+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12+
<a href="https://www.npmjs.com/package/node-type-registry"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphql%2Fnode-type-registry%2Fpackage.json"/></a>
13+
</p>
14+
315
Node type definitions for the Constructive blueprint system. Single source of truth for all Authz*, Data*, Relation*, View*, and Table* node types.
416

517
## Usage

jobs/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
n# Jobs (Knative)
1+
# Jobs (Knative)
2+
3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
<p align="center" width="100%">
8+
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
9+
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
10+
</a>
11+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12+
</p>
213

314
This document describes the **current** jobs setup using:
415

packages/upload-client/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# @constructive-io/upload-client
2+
3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
<p align="center" width="100%">
8+
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
9+
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
10+
</a>
11+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12+
<a href="https://www.npmjs.com/package/@constructive-io/upload-client"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=packages%2Fupload-client%2Fpackage.json"/></a>
13+
</p>
14+
15+
Client-side presigned URL upload utilities for Constructive.
16+
17+
## Usage
18+
19+
```typescript
20+
import { uploadFile, hashFile } from '@constructive-io/upload-client';
21+
22+
// Full orchestrated upload
23+
const result = await uploadFile({
24+
file: selectedFile,
25+
bucketKey: 'avatars',
26+
execute: myGraphQLExecutor,
27+
onProgress: (pct) => console.log(`${pct}%`),
28+
});
29+
30+
// Or use atomic functions individually
31+
const hash = await hashFile(myFile);
32+
```
33+
34+
## API
35+
36+
### `uploadFile(options)`
37+
38+
Orchestrates the full presigned URL upload flow: hash → requestUploadUrl → PUT → confirmUpload.
39+
40+
### `hashFile(file)`
41+
42+
Computes SHA-256 hash using the Web Crypto API.
43+
44+
### `hashFileChunked(file, chunkSize?, onProgress?)`
45+
46+
Computes SHA-256 hash in chunks for large files.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { hashFile, hashFileChunked } from '../src/hash';
2+
import { UploadError } from '../src/types';
3+
import type { FileInput } from '../src/types';
4+
5+
/**
6+
* Create a mock FileInput from a string body.
7+
*/
8+
function createMockFile(
9+
body: string,
10+
name = 'test.txt',
11+
type = 'text/plain',
12+
): FileInput {
13+
const encoder = new TextEncoder();
14+
const data = encoder.encode(body);
15+
return {
16+
name,
17+
size: data.byteLength,
18+
type,
19+
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
20+
slice: (start = 0, end = data.byteLength) => {
21+
const sliced = data.slice(start, end);
22+
return new Blob([sliced]);
23+
},
24+
};
25+
}
26+
27+
/**
28+
* Known SHA-256 hash of an empty string.
29+
* echo -n "" | sha256sum → e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
30+
*/
31+
const EMPTY_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
32+
33+
/**
34+
* Known SHA-256 of "hello world"
35+
* echo -n "hello world" | sha256sum → b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
36+
*/
37+
const HELLO_WORLD_HASH = 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9';
38+
39+
describe('hashFile', () => {
40+
it('should produce correct SHA-256 for known input', async () => {
41+
const file = createMockFile('hello world');
42+
const hash = await hashFile(file);
43+
expect(hash).toBe(HELLO_WORLD_HASH);
44+
});
45+
46+
it('should produce a 64-char lowercase hex string', async () => {
47+
const file = createMockFile('test content');
48+
const hash = await hashFile(file);
49+
expect(hash).toMatch(/^[a-f0-9]{64}$/);
50+
});
51+
52+
it('should produce deterministic output (same content = same hash)', async () => {
53+
const file1 = createMockFile('identical content');
54+
const file2 = createMockFile('identical content');
55+
const hash1 = await hashFile(file1);
56+
const hash2 = await hashFile(file2);
57+
expect(hash1).toBe(hash2);
58+
});
59+
60+
it('should produce different hashes for different content', async () => {
61+
const file1 = createMockFile('content A');
62+
const file2 = createMockFile('content B');
63+
const hash1 = await hashFile(file1);
64+
const hash2 = await hashFile(file2);
65+
expect(hash1).not.toBe(hash2);
66+
});
67+
68+
it('should handle empty file (zero bytes)', async () => {
69+
const file = createMockFile('');
70+
// size is 0, but the function should still hash it
71+
const hash = await hashFile(file);
72+
expect(hash).toBe(EMPTY_HASH);
73+
});
74+
75+
it('should throw UploadError for null file', async () => {
76+
await expect(hashFile(null as any)).rejects.toThrow(UploadError);
77+
await expect(hashFile(null as any)).rejects.toMatchObject({ code: 'INVALID_FILE' });
78+
});
79+
80+
it('should handle binary-like content (UTF-8 multibyte)', async () => {
81+
const file = createMockFile('emoji: 🎉🚀 and accents: ñ ü ö');
82+
const hash = await hashFile(file);
83+
expect(hash).toMatch(/^[a-f0-9]{64}$/);
84+
});
85+
});
86+
87+
describe('hashFileChunked', () => {
88+
it('should produce the same hash as hashFile for the same content', async () => {
89+
const body = 'hello world';
90+
const file = createMockFile(body);
91+
const hashDirect = await hashFile(file);
92+
const hashChunked = await hashFileChunked(createMockFile(body));
93+
expect(hashChunked).toBe(hashDirect);
94+
});
95+
96+
it('should produce correct hash with very small chunk size', async () => {
97+
// Force many chunks by using 1-byte chunk size
98+
const file = createMockFile('hello world');
99+
const hash = await hashFileChunked(file, 1);
100+
expect(hash).toBe(HELLO_WORLD_HASH);
101+
});
102+
103+
it('should produce correct hash when chunk size exceeds file size', async () => {
104+
const file = createMockFile('hello world');
105+
// Chunk size of 1MB for an 11-byte file → single chunk
106+
const hash = await hashFileChunked(file, 1024 * 1024);
107+
expect(hash).toBe(HELLO_WORLD_HASH);
108+
});
109+
110+
it('should fire progress callbacks', async () => {
111+
const body = 'abcdefghij'; // 10 bytes
112+
const file = createMockFile(body);
113+
const progressValues: number[] = [];
114+
115+
await hashFileChunked(file, 3, (pct) => progressValues.push(pct));
116+
117+
// With 10 bytes and 3-byte chunks: 3, 6, 9, 10 → ~30%, 60%, 90%, 100%
118+
expect(progressValues.length).toBeGreaterThan(0);
119+
expect(progressValues[progressValues.length - 1]).toBe(100);
120+
// Each value should be increasing
121+
for (let i = 1; i < progressValues.length; i++) {
122+
expect(progressValues[i]).toBeGreaterThanOrEqual(progressValues[i - 1]);
123+
}
124+
});
125+
126+
it('should handle empty file', async () => {
127+
const file = createMockFile('');
128+
const hash = await hashFileChunked(file);
129+
expect(hash).toBe(EMPTY_HASH);
130+
});
131+
132+
it('should throw for invalid chunk size', async () => {
133+
const file = createMockFile('test');
134+
await expect(hashFileChunked(file, 0)).rejects.toThrow(UploadError);
135+
await expect(hashFileChunked(file, -1)).rejects.toThrow(UploadError);
136+
});
137+
138+
it('should produce same hash for simulated large file (multiple chunks)', async () => {
139+
// Create a "large" string (1000 chars) and hash with 100-byte chunks
140+
const body = 'x'.repeat(1000);
141+
const file = createMockFile(body);
142+
const hashDirect = await hashFile(file);
143+
const hashChunked = await hashFileChunked(createMockFile(body), 100);
144+
expect(hashChunked).toBe(hashDirect);
145+
});
146+
});

0 commit comments

Comments
 (0)