Skip to content

feat: beta API redesign (v0.6.0)#179

Draft
abhay-ramesh wants to merge 9 commits intomainfrom
beta
Draft

feat: beta API redesign (v0.6.0)#179
abhay-ramesh wants to merge 9 commits intomainfrom
beta

Conversation

@abhay-ramesh
Copy link
Copy Markdown
Owner

@abhay-ramesh abhay-ramesh commented Mar 11, 2026

Overview

This draft PR tracks all API improvements landing in the beta branch before being merged to main as v0.6.0.

Do not merge directly — this is a tracking PR. When beta is stable and ready to graduate, this PR gets reviewed and merged.


Changes included

PR Change
#171 Event name consistency — onComplete, onStart, onProgress, onError on server + client
#172 Result field clarity — storagePath (store in DB), publicUrl, presignedUrl (don't store)
#173 uploadFiles() returns Promise<S3UploadedFile[]> instead of void
#174 Schema method aliases — .accept(), .maxSize(), .minSize()
#175 Provider-neutral builder — upload.* as primary, s3.* deprecated
#176 Immutable chain — .paths() and .expiresIn() return new instances instead of mutating
#177 useUpload explicit hook API — visible to linters, clearly a hook
#178 Provider-neutral type aliases — UploadRouter, LifecycleContext, Middleware, etc.

API transformation summary

Hooks

// Before
const { uploadFiles } = upload.imageUpload();

// After (explicit hook — linter friendly)
const { uploadFiles } = useUpload<AppRouter>('imageUpload');

Schema

// Before
s3.file().types(['image/jpeg']).maxFileSize('10MB')

// After
upload.file().accept(['image/jpeg']).maxSize('10MB')

Results

// Before — `url` expires, `key` is ambiguous
onUploadComplete: ({ key, url }) => db.save(url) // BUG: url expires in 1h!

// After — clear semantics
onComplete: ({ storagePath, publicUrl }) => db.save(storagePath)

Return values

// Before — fire and forget
await uploadFiles(files); // void

// After — awaitable results
const uploaded = await uploadFiles(files);
console.log(uploaded[0].storagePath);

Types

// Before — AWS-branded even on R2/MinIO
import type { S3Router, S3LifecycleContext } from 'pushduck/server';

// After — provider neutral
import type { UploadRouter, LifecycleContext } from 'pushduck/server';

Backward compatibility

Every old API still works. Old names emit console.warn() deprecation notices. Nothing is removed until 1.0.0.


Publishing beta to npm (when ready)

Run the Create Release workflow on the beta branch with type prerelease and tag beta:

npm install pushduck@beta

🤖 Generated with Claude Code

abhay-ramesh and others added 8 commits March 11, 2026 18:04
…#171)

## Problem

Server and client used different names for the same lifecycle events:

| Event | Server (before) | Client (before) |
|---|---|---|
| Upload starts | `onUploadStart` | `onStart` |
| Progress update | `onUploadProgress` | `onProgress` |
| Upload completes | `onUploadComplete` | `onSuccess` |
| Upload fails | `onUploadError` | `onError` |

This forced developers to memorize two vocabularies and made
copy-pasting between server/client configs error-prone.

## Changes

New unified API — same names everywhere:
- `.onStart()` — replaces `.onUploadStart()`
- `.onProgress()` — replaces `.onUploadProgress()`  
- `.onComplete()` — replaces `.onUploadComplete()` AND client
`onSuccess`
- `.onError()` — replaces `.onUploadError()`

Old names kept with `console.warn()` deprecation notices. All existing
code continues to work.

## Files changed
- `src/core/router/router-v2.ts` — S3Route class + S3RouteConfig +
invocation sites
- `src/core/schema.ts` — S3FileSchema lifecycle method delegates
- `src/types/index.ts` — UploadRouteConfig interface (added
`onComplete`)
- `src/hooks/use-upload-route.ts` — reads `onComplete` with fallback to
`onSuccess`
- `src/__tests__/expires-in.test.ts` — updated to use new field names

## Test plan
- [x] All 156 existing tests pass
- [x] Type check clean
- [x] Old methods (`onUploadComplete` etc.) still work, just log a
deprecation warning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…l) (#172)

## Problem

`S3UploadedFile` had `url` and `key` with no indication of which to
store in a database:
- `url` — could be a presigned URL (expires in ~1h) or a public URL
depending on config
- `key` — the permanent S3 key, but nothing told users this was the safe
one to store

This caused a common bug: users store `url` in their DB, links break
after an hour.

## Changes

Three explicit fields with clear JSDoc:

| Field | Meaning | Store in DB? |
|---|---|---|
| `storagePath` | S3 key/path (e.g. `uploads/user/photo.jpg`) | ✅ Yes —
never expires |
| `publicUrl` | Public URL when bucket has public access | ✅ Yes — never
expires |
| `presignedUrl` | Temporary download URL | ❌ No — expires in ~1h |

Old `key` and `url` fields kept with `@deprecated` JSDoc — same values,
no behavior change.

Applied to: `S3UploadedFile`, `S3LifecycleContext`,
`CompletionResponse`.

## Test plan
- [x] All 156 tests pass
- [x] Type check clean
- [x] Old `key`/`url` fields still present and populated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Problem

`uploadFiles()` returned `void` — to get upload results you had to
either:
- Pass an `onSuccess` callback (callback hell)
- Watch the `files` state array (works for UI but awkward for logic)

```ts
// Before — forced callback pattern
const { uploadFiles } = useUploadRoute('avatars', {
  onSuccess: (results) => {
    db.save({ path: results[0].key }); // can't await this naturally
  }
});
await uploadFiles(files); // returns nothing
```

## Change

`uploadFiles()` now returns `Promise<S3UploadedFile[]>`:

```ts
// After — direct await
const { uploadFiles } = useUploadRoute('avatars');
const results = await uploadFiles(files);
await db.save({ path: results[0].key });
```

## Backward compatibility
- Existing `await uploadFiles(files)` with void usage still works (just
ignores the return)
- `onSuccess` callback still fires as before
- Returns `[]` on error (error is still reported via `errors` state and
`onError` callback)

## Test plan
- [x] All 156 tests pass
- [x] Type check clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Problem

Three inconsistencies in the chainable schema API:

1. `.types(['image/jpeg'])` and `.extensions(['.pdf'])` are separate
methods with no unified shorthand — but the HTML `<input accept>`
attribute handles both in one attribute
2. `.maxFileSize()` is verbose; `.min()` is short — asymmetric naming
3. Object form uses `allowedTypes` but chain uses `.types()` — different
keys for same concept

## Changes

### `.accept(types)` — replaces `.types()` and `.extensions()`
Mirrors `<input accept>`. Works with MIME types, wildcards, and
extensions:
```ts
upload.file().accept('image/*')
upload.file().accept(['image/jpeg', 'application/pdf'])
upload.file().accept(['.pdf', '.doc'])
upload.file().accept(['image/*', '.pdf'])  // mixed

// Also works in object form
upload.file({ accept: ['image/*', '.pdf'] })
```

### `.maxSize(size)` — replaces `.maxFileSize()`
```ts
upload.file().maxSize('10MB')   // was: .maxFileSize('10MB')
upload.image().maxSize('5MB')
```

### `.minSize(size)` — replaces `.min()`
```ts
upload.file().minSize('1KB')    // was: .min('1KB')
```

## Backward compatibility
All old methods kept with `console.warn()` deprecation notices:
- `.types()` → `.accept()`
- `.extensions()` → `.accept(['.ext'])`
- `.maxFileSize()` → `.maxSize()`
- `.max()` → `.maxSize()` (was already deprecated, warning message
updated)
- `.min()` → `.minSize()`

## Test plan
- [x] All 156 tests pass
- [x] Type check clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Problem

`createUploadConfig().build()` returned `{ s3 }`. When using Cloudflare
R2, MinIO, or DigitalOcean Spaces:

```ts
const { s3 } = createUploadConfig()
  .provider('cloudflare-r2', { ... })
  .build();

s3.file()  // Why is R2 called s3?
```

This contradicts the library's "provider-agnostic" positioning and
confuses developers not using AWS.

## Change

`build()` now returns `{ upload, s3 }`:

```ts
const { upload } = createUploadConfig()
  .provider('cloudflare-r2', { ... })
  .build();

upload.file().maxSize('10MB')  // Clear — works with any provider
```

`s3` is kept as a deprecated getter on the return object:
- Existing `const { s3 } = ...build()` code continues to work
- Logs a `console.warn()` pointing to `upload` as the new name

## No internal changes
The `upload` object is the exact same thing as `s3` was — purely a
renaming of the destructured property.

## Test plan
- [x] All 156 tests pass
- [x] Type check clean
- [x] `const { s3 }` still works with deprecation warning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…176)

## Problem

`.paths()` and `.expiresIn()` mutated `this.config` in place, while
`.middleware()` correctly created a new `S3Route`. This inconsistency
could silently corrupt a shared base route:

```ts
const base = s3.file().maxSize('10MB').middleware(auth);

const docsRoute   = base.paths({ prefix: 'docs' });    // mutated base!
const imagesRoute = base.paths({ prefix: 'images' });  // base was already corrupted
```

## Fix

Both methods now clone the config into a new `S3Route` instance —
matching the behavior of `.middleware()`, `.refine()`, and
`.transform()`:

```ts
const base = upload.file().maxSize('10MB').middleware(auth);

const docsRoute   = base.paths({ prefix: 'docs' });    // new instance ✓
const imagesRoute = base.paths({ prefix: 'images' });  // base untouched ✓
```

## Impact
This is technically a behavior change, but only visible if code was
relying on mutation (which was always a bug). The immutable pattern
matches every other method on the chain and the mental model of
Zod-style builders.

## Test plan
- [x] All 156 tests pass (including the existing `expiresIn` config
preservation tests)
- [x] Type check clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary

- Add `useUpload<TRouter>(routeName, config?)` as the preferred hook
name
- The existing `upload.routeName()` proxy pattern is invisible to
linters and looks like a regular method call rather than a React hook
- Export `useUpload` from both `pushduck` and `pushduck/client`
- `useUploadRoute` kept unchanged for full backward compatibility

## Before / After

**Before** (proxy pattern — linter can't see it's a hook):
```ts
const upload = createUploadClient<AppRouter>({ endpoint: '/api/upload' });
const { uploadFiles, files } = upload.imageUpload(); // looks like a method call
```

**After** (explicit hook):
```ts
import { useUpload } from 'pushduck/client';
const { uploadFiles, files } = useUpload<AppRouter>('imageUpload'); // clearly a hook
```

## Test plan
- [x] All 156 existing tests pass
- [x] Type-check passes with no errors
- [x] Both overload signatures (generic and string) work correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary

- Add short, provider-neutral type aliases alongside the existing
`S3`-prefixed types
- Users on Cloudflare R2, MinIO, or other providers shouldn't need `S3*`
names everywhere
- All old names remain 100% supported — no deprecation, purely additive

## New aliases

| Old (still works) | New alias |
|---|---|
| `S3Router` | `UploadRouter` |
| `S3UploadedFile` | `UploadedFile` |
| `S3RouteUploadResult` | `UploadResult` |
| `RouterRouteNames` | `RouteNames` |
| `S3FileConstraints` | `FileConstraints` |
| `S3LifecycleContext` | `LifecycleContext` |
| `S3LifecycleHook` | `LifecycleHook` |
| `S3Middleware` | `Middleware` |
| `S3MiddlewareContext` | `MiddlewareContext` |
| `S3RouteContext` | `RouteContext` |
| `S3RouterDefinition` | `RouterDefinition` |

## Before / After

**Before** (reads as AWS-specific):
```ts
import type { S3Router, S3LifecycleContext } from 'pushduck/server';
```

**After** (provider-neutral):
```ts
import type { UploadRouter, LifecycleContext } from 'pushduck/server';
```

## Test plan
- [x] All 156 tests pass
- [x] Type-check passes with no errors
- [x] Both old and new names resolve to identical types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Mar 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pushduck Ready Ready Preview, Comment Apr 4, 2026 5:45pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 11, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c6f47dd-9a2c-4e8c-98c8-ce904bb1e2c9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch beta

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@abhay-ramesh abhay-ramesh changed the title feat: beta API redesign (v0.5.0) feat: beta API redesign (v0.6.0) Apr 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant