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
5 changes: 5 additions & 0 deletions .changeset/circular-object-hashing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cleverbrush/deep': patch
---

Prevent circular object references from overflowing the stack during internal object hashing while preserving acyclic hash results.
6 changes: 6 additions & 0 deletions .changeset/close-rejected-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@cleverbrush/server': patch
---

Close WebSocket subscriptions when middleware rejects the request without
calling the next handler, preventing idle unauthorized connections.
9 changes: 9 additions & 0 deletions .changeset/preserve-column-default-changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cleverbrush/knex-schema': patch
---

Apply and generate column default changes without dropping, retyping or changing
the nullability of the existing column. Removing a default executes DROP DEFAULT;
setting one executes SET DEFAULT with safely quoted values (including question
marks and backslashes) and support for explicit SQL expressions. Generated down
migrations restore the original default instead of assuming a timestamp column.
5 changes: 5 additions & 0 deletions .changeset/subscription-empty-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cleverbrush/client': patch
---

Respect maxEvents: 0 in useSubscription by retaining only lastEvent and leaving the event history empty.
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ npm run build:docs-site
floors for **unit** coverage. New published packages need explicit floors; do not
lower existing floors to hide regressions. Refresh README badges explicitly with
`npm run coverage:badges` after a successful coverage run. Badges do not include
the dedicated database or S3 integration suites. In particular,
`scheduler-postgres` relies on the real-database suite, not its small unit suite.
the dedicated database or S3 integration suites. Query, ORM and PostgreSQL
scheduler unit tests use the real Knex compiler with a simulated driver boundary
to check SQL, bindings, row decoding and failure paths. These do not prove database
locking, concurrent claims, lease recovery or transactional behavior: the real
PostgreSQL suites remain mandatory even when unit coverage reaches 100%.

The package smoke test packs every published workspace, installs the tarballs
and peer dependencies in a disposable consumer, checks every export and TypeScript
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](./LICENSE)
[![Standard Schema v1](https://img.shields.io/badge/Standard%20Schema-v1-blue)](https://standardschema.dev/)
<!-- coverage-badge-start -->
![Unit coverage](https://img.shields.io/badge/unit_coverage-82.2%25-green)
![Unit coverage](https://img.shields.io/badge/unit_coverage-92.2%25-brightgreen)
<!-- coverage-badge-end -->

Cleverbrush is a schema-first TypeScript framework monorepo. It provides the
Expand Down
80 changes: 40 additions & 40 deletions coverage-thresholds.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@
"lines": 92
},
"client": {
"statements": 83,
"branches": 79,
"functions": 89,
"lines": 84
"statements": 93,
"branches": 88,
"functions": 96,
"lines": 95
},
"deep": {
"statements": 78,
"branches": 88,
"functions": 89,
"lines": 77
"statements": 98,
"branches": 95,
"functions": 100,
"lines": 98
},
"di": {
"statements": 94,
Expand All @@ -42,10 +42,10 @@
"lines": 100
},
"knex-schema": {
"statements": 63,
"branches": 59,
"functions": 66,
"lines": 64
"statements": 80,
"branches": 75,
"functions": 80,
"lines": 83
},
"log": {
"statements": 98,
Expand All @@ -54,22 +54,22 @@
"lines": 98
},
"mapper": {
"statements": 93,
"branches": 83,
"functions": 93,
"lines": 97
"statements": 97,
"branches": 90,
"functions": 100,
"lines": 98
},
"orm": {
"statements": 80,
"branches": 61,
"functions": 86,
"lines": 83
"statements": 90,
"branches": 80,
"functions": 97,
"lines": 94
},
"orm-cli": {
"statements": 59,
"branches": 60,
"functions": 60,
"lines": 59
"statements": 90,
"branches": 85,
"functions": 100,
"lines": 98
},
"otel": {
"statements": 88,
Expand All @@ -78,22 +78,22 @@
"lines": 90
},
"react-form": {
"statements": 95,
"branches": 89,
"functions": 94,
"lines": 96
"statements": 99,
"branches": 95,
"functions": 100,
"lines": 99
},
"scheduler": {
"statements": 83,
"branches": 82,
"functions": 89,
"lines": 85
"statements": 95,
"branches": 90,
"functions": 97,
"lines": 97
},
"scheduler-postgres": {
"statements": 11,
"branches": 21,
"functions": 2,
"lines": 11
"statements": 90,
"branches": 85,
"functions": 100,
"lines": 100
},
"schema": {
"statements": 97,
Expand All @@ -108,10 +108,10 @@
"lines": 97
},
"server": {
"statements": 81,
"branches": 78,
"functions": 85,
"lines": 82
"statements": 90,
"branches": 85,
"functions": 94,
"lines": 92
},
"server-openapi": {
"statements": 96,
Expand Down
4 changes: 2 additions & 2 deletions libs/client/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @cleverbrush/client
<!-- coverage-badge-start -->
![Unit coverage](https://img.shields.io/badge/unit_coverage-84%25-green)
![Unit coverage](https://img.shields.io/badge/unit_coverage-94.6%25-brightgreen)
<!-- coverage-badge-end -->

Typed HTTP client for `@cleverbrush/server` API contracts — zero codegen, full type safety. Optional React + TanStack Query integration via `@cleverbrush/client/react`.
Expand Down Expand Up @@ -699,7 +699,7 @@ function LiveFeed() {
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | `boolean` | `true` | Toggle the subscription on/off |
| `maxEvents` | `number` | unlimited | Maximum events to keep in the `events` array |
| `maxEvents` | `number` | unlimited | Maximum events to keep in the `events` array; `0` retains no history |

## React Integration (`@cleverbrush/client/react`)

Expand Down
111 changes: 111 additions & 0 deletions libs/client/src/client.transports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest';
import { createClient } from './client.js';
import { ApiError, NetworkError } from './errors.js';

function setup(meta: Record<string, unknown> = {}, options = {}) {
const fetch = vi.fn();
const ep = {
introspect: () => ({
method: 'POST',
basePath: '/items',
pathTemplate: '',
...meta
})
};
const client = createClient({ items: { action: ep } } as any, {
fetch,
...options
}) as any;
return { fetch, call: client.items.action };
}
describe('HTTP response and upload transports', () => {
it('encodes mixed multipart files and removes caller content-type boundaries', async () => {
const { fetch, call } = setup(
{ fileUpload: {} },
{ headers: { 'Content-Type': 'application/json' } }
);
fetch.mockImplementation(
async () =>
new Response('{}', {
headers: { 'content-type': 'application/json' }
})
);
const blob = new Blob(['one'], { type: 'text/plain' });
await call({
body: { title: 'report', absent: undefined },
files: {
attachment: [
blob,
{
buffer: new Uint8Array([2]),
filename: 'two.txt',
mimeType: 'text/plain'
},
undefined
]
},
headers: { 'content-type': 'bad' },
retry: false,
timeout: 100,
optimisticUpdate: {},
offlineQueue: true
});
const init = fetch.mock.calls[0][1];
expect(new Headers(init.headers).has('content-type')).toBe(false);
expect(init.body.get('title')).toBe('report');
expect(init.body.has('absent')).toBe(false);
expect(init.body.getAll('attachment')).toHaveLength(2);
expect(init.body.getAll('attachment')[1].name).toBe('two.txt');
await call();
expect(fetch.mock.calls[1][1].body).toBeInstanceOf(FormData);
});
it('normalizes non-Error failures and runs error hooks for existing web errors', async () => {
const hook = vi.fn(e => e);
const { fetch, call } = setup({}, { hooks: { beforeError: [hook] } });
fetch.mockRejectedValueOnce('network string');
await expect(call()).rejects.toThrow('network string');
const error = new NetworkError('already wrapped');
fetch.mockRejectedValueOnce(error);
await expect(call()).rejects.toBe(error);
expect(hook).toHaveBeenCalledWith(error);
});
it('returns text successes and supports file downloads and unauthorized failures', async () => {
const unauthorized = vi.fn();
const { fetch, call } = setup({}, { onUnauthorized: unauthorized });
fetch.mockResolvedValueOnce(new Response('plain text'));
expect(await call()).toBe('plain text');
fetch.mockResolvedValueOnce(new Response('file bytes'));
expect(await (await call.file()).text()).toBe('file bytes');
fetch.mockResolvedValueOnce(new Response(null, { status: 401 }));
await expect(call.file()).rejects.toBeInstanceOf(ApiError);
expect(unauthorized).toHaveBeenCalledOnce();
});
it('streams incomplete chunks, supports empty bodies and translates failures', async () => {
const unauthorized = vi.fn();
const beforeError = vi.fn(e => e);
const { fetch, call } = setup(
{},
{
onUnauthorized: unauthorized,
hooks: { beforeError: [beforeError] }
}
);
fetch.mockResolvedValueOnce(new Response('one\ntwo\nlast'));
const lines = [];
for await (const line of call.stream()) lines.push(line);
expect(lines).toEqual(['one', 'two', 'last']);
fetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
const empty = [];
for await (const line of call.stream()) empty.push(line);
expect(empty).toEqual([]);
fetch.mockResolvedValueOnce(new Response(null, { status: 401 }));
await expect(
call.stream()[Symbol.asyncIterator]().next()
).rejects.toBeInstanceOf(ApiError);
expect(unauthorized).toHaveBeenCalledOnce();
fetch.mockRejectedValueOnce(new TypeError('offline'));
await expect(
call.stream()[Symbol.asyncIterator]().next()
).rejects.toBeInstanceOf(NetworkError);
});
});
Loading
Loading