Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions graphile/graphile-presigned-url-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# graphile-presigned-url-plugin

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<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>
<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>
</p>

Presigned URL upload plugin for PostGraphile v5.

## Features
Expand Down
12 changes: 12 additions & 0 deletions graphql/node-type-registry/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# node-type-registry

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<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>
<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>
</p>

Node type definitions for the Constructive blueprint system. Single source of truth for all Authz*, Data*, Relation*, View*, and Table* node types.

## Usage
Expand Down
13 changes: 12 additions & 1 deletion jobs/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
n# Jobs (Knative)
# Jobs (Knative)

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<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>
</p>

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

Expand Down
46 changes: 46 additions & 0 deletions packages/upload-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# @constructive-io/upload-client

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<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>
<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>
</p>

Client-side presigned URL upload utilities for Constructive.

## Usage

```typescript
import { uploadFile, hashFile } from '@constructive-io/upload-client';

// Full orchestrated upload
const result = await uploadFile({
file: selectedFile,
bucketKey: 'avatars',
execute: myGraphQLExecutor,
onProgress: (pct) => console.log(`${pct}%`),
});

// Or use atomic functions individually
const hash = await hashFile(myFile);
```

## API

### `uploadFile(options)`

Orchestrates the full presigned URL upload flow: hash → requestUploadUrl → PUT → confirmUpload.

### `hashFile(file)`

Computes SHA-256 hash using the Web Crypto API.

### `hashFileChunked(file, chunkSize?, onProgress?)`

Computes SHA-256 hash in chunks for large files.
146 changes: 146 additions & 0 deletions packages/upload-client/__tests__/hash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { hashFile, hashFileChunked } from '../src/hash';
import { UploadError } from '../src/types';
import type { FileInput } from '../src/types';

/**
* Create a mock FileInput from a string body.
*/
function createMockFile(
body: string,
name = 'test.txt',
type = 'text/plain',
): FileInput {
const encoder = new TextEncoder();
const data = encoder.encode(body);
return {
name,
size: data.byteLength,
type,
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
slice: (start = 0, end = data.byteLength) => {
const sliced = data.slice(start, end);
return new Blob([sliced]);
},
};
}

/**
* Known SHA-256 hash of an empty string.
* echo -n "" | sha256sum → e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
*/
const EMPTY_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';

/**
* Known SHA-256 of "hello world"
* echo -n "hello world" | sha256sum → b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
*/
const HELLO_WORLD_HASH = 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9';

describe('hashFile', () => {
it('should produce correct SHA-256 for known input', async () => {
const file = createMockFile('hello world');
const hash = await hashFile(file);
expect(hash).toBe(HELLO_WORLD_HASH);
});

it('should produce a 64-char lowercase hex string', async () => {
const file = createMockFile('test content');
const hash = await hashFile(file);
expect(hash).toMatch(/^[a-f0-9]{64}$/);
});

it('should produce deterministic output (same content = same hash)', async () => {
const file1 = createMockFile('identical content');
const file2 = createMockFile('identical content');
const hash1 = await hashFile(file1);
const hash2 = await hashFile(file2);
expect(hash1).toBe(hash2);
});

it('should produce different hashes for different content', async () => {
const file1 = createMockFile('content A');
const file2 = createMockFile('content B');
const hash1 = await hashFile(file1);
const hash2 = await hashFile(file2);
expect(hash1).not.toBe(hash2);
});

it('should handle empty file (zero bytes)', async () => {
const file = createMockFile('');
// size is 0, but the function should still hash it
const hash = await hashFile(file);
expect(hash).toBe(EMPTY_HASH);
});

it('should throw UploadError for null file', async () => {
await expect(hashFile(null as any)).rejects.toThrow(UploadError);
await expect(hashFile(null as any)).rejects.toMatchObject({ code: 'INVALID_FILE' });
});

it('should handle binary-like content (UTF-8 multibyte)', async () => {
const file = createMockFile('emoji: 🎉🚀 and accents: ñ ü ö');
const hash = await hashFile(file);
expect(hash).toMatch(/^[a-f0-9]{64}$/);
});
});

describe('hashFileChunked', () => {
it('should produce the same hash as hashFile for the same content', async () => {
const body = 'hello world';
const file = createMockFile(body);
const hashDirect = await hashFile(file);
const hashChunked = await hashFileChunked(createMockFile(body));
expect(hashChunked).toBe(hashDirect);
});

it('should produce correct hash with very small chunk size', async () => {
// Force many chunks by using 1-byte chunk size
const file = createMockFile('hello world');
const hash = await hashFileChunked(file, 1);
expect(hash).toBe(HELLO_WORLD_HASH);
});

it('should produce correct hash when chunk size exceeds file size', async () => {
const file = createMockFile('hello world');
// Chunk size of 1MB for an 11-byte file → single chunk
const hash = await hashFileChunked(file, 1024 * 1024);
expect(hash).toBe(HELLO_WORLD_HASH);
});

it('should fire progress callbacks', async () => {
const body = 'abcdefghij'; // 10 bytes
const file = createMockFile(body);
const progressValues: number[] = [];

await hashFileChunked(file, 3, (pct) => progressValues.push(pct));

// With 10 bytes and 3-byte chunks: 3, 6, 9, 10 → ~30%, 60%, 90%, 100%
expect(progressValues.length).toBeGreaterThan(0);
expect(progressValues[progressValues.length - 1]).toBe(100);
// Each value should be increasing
for (let i = 1; i < progressValues.length; i++) {
expect(progressValues[i]).toBeGreaterThanOrEqual(progressValues[i - 1]);
}
});

it('should handle empty file', async () => {
const file = createMockFile('');
const hash = await hashFileChunked(file);
expect(hash).toBe(EMPTY_HASH);
});

it('should throw for invalid chunk size', async () => {
const file = createMockFile('test');
await expect(hashFileChunked(file, 0)).rejects.toThrow(UploadError);
await expect(hashFileChunked(file, -1)).rejects.toThrow(UploadError);
});

it('should produce same hash for simulated large file (multiple chunks)', async () => {
// Create a "large" string (1000 chars) and hash with 100-byte chunks
const body = 'x'.repeat(1000);
const file = createMockFile(body);
const hashDirect = await hashFile(file);
const hashChunked = await hashFileChunked(createMockFile(body), 100);
expect(hashChunked).toBe(hashDirect);
});
});
Loading
Loading