Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
dcbb32b
fix(sf-297): settle query latch on any server QUERY_RESP, isolate thr…
ivkan Jun 7, 2026
60d5407
test(sf-297): latch settlement + throwing-subscriber behavioral coverage
ivkan Jun 7, 2026
b3f4812
feat(sf-297): add client.queryOnce one-shot read with explicit offlin…
ivkan Jun 7, 2026
e665cd6
test(sf-297): queryOnce server-only + offline + allowLocal coverage
ivkan Jun 7, 2026
fe3b56d
feat(sf-297): add additive {settled} meta arg to QueryHandle.subscribe
ivkan Jun 7, 2026
8b08796
test(sf-297): {settled} local-vs-server frame + single-arg back-compat
ivkan Jun 7, 2026
a9609ac
feat(sf-297): migrate MCP topgun_query to queryOnce with explicit off…
ivkan Jun 7, 2026
7420c91
test(sf-297): topgun_query server-data + offline-not-settled coverage
ivkan Jun 7, 2026
290f7fb
test(sf-297): lock in useQuery single-arg subscribe back-compat
ivkan Jun 7, 2026
6ae33f3
docs(sf-297): document queryOnce, subscribe {settled}, and MCP offlin…
ivkan Jun 7, 2026
62ea740
docs(sf-297): correct stale cursor-pagination strings in MCP topgun_q…
ivkan Jun 7, 2026
1fa6d13
feat(sf-298a): ordered sort wire protocol — Vec<SortField> end-to-end
ivkan Jun 7, 2026
0364f64
feat(sf-298a): serialize sort as ordered array on TS wire + fixture u…
ivkan Jun 7, 2026
169fc34
fix(sf-297): drop dead cursor param + add honest truncation signal to…
ivkan Jun 7, 2026
85376b1
fix(sf-298s): drain LimitProcessor inbox to prevent executor infinite…
ivkan Jun 7, 2026
fef9250
feat(sf-298s): add SimCluster::query + DAG behavioral sim tests
ivkan Jun 7, 2026
9675f18
fix(sf-298s): clear clippy errors + drop dead ctx scaffolding in SimC…
ivkan Jun 7, 2026
b8aa648
feat(sf-298b): route ALL QuerySub to DagQuery in classify.rs
ivkan Jun 8, 2026
62b3e54
feat(sf-298b): re-target handle_query_subscribe to DAG single-node path
ivkan Jun 8, 2026
1b5919b
test(sf-298b): add fault-injection sim test for structured query routing
ivkan Jun 8, 2026
ac087eb
docs(sf-298b): strip spec/AC refs from code comments per CLAUDE.md
ivkan Jun 8, 2026
45ee571
feat(sf-298c): add shared transport-neutral cursor module (G1+G2)
ivkan Jun 8, 2026
d66020b
refactor(sf-298c): absorb http_sync cursor into shared query module (G3)
ivkan Jun 8, 2026
d412dd5
fix(sf-298c): satisfy clippy must_use/panics-doc + fmt on cursor module
ivkan Jun 8, 2026
431ff94
feat(sf-298e): add ProcessorType::Cursor variant to dag/types.rs (G1)
ivkan Jun 8, 2026
3a86fe8
feat(sf-298e): add CursorProcessor + CursorProcessorSupplier to dag/p…
ivkan Jun 8, 2026
00f0696
feat(sf-298e): emit Cursor vertex in convert_query (Scan→Filter→Curso…
ivkan Jun 8, 2026
d2fcc26
feat(sf-298e): wire CursorProcessorSupplier + pagination tests in dag…
ivkan Jun 8, 2026
8b53284
fix(sf-298e): satisfy clippy + fmt on dag Cursor stage
ivkan Jun 8, 2026
40373ec
test(sf-298e): isolated converter tests for Cursor vertex insertion
ivkan Jun 8, 2026
d6e306e
refactor(sf-298d): remove query_backend field+param from QueryService…
ivkan Jun 8, 2026
6aa3ebe
refactor(sf-298d): remove PredicateBackend from all 4 prod/assembly s…
ivkan Jun 8, 2026
2894f48
refactor(sf-298d): fix all ~11 cascaded test-site constructions in qu…
ivkan Jun 8, 2026
9d8c0d8
perf(sf-298d): perf-gate + bench fix — chosen Option B (demote), 37,4…
ivkan Jun 8, 2026
56326a5
refactor(sf-298f): extract run_dag_local + untagged ScanProcessor output
ivkan Jun 9, 2026
74726ec
fix(sf-298f): route WS queries through canonical DAG path; remove dea…
ivkan Jun 9, 2026
de654d0
chore: clear pre-existing lint error + prettier drift (cli, admin-das…
ivkan Jun 9, 2026
ac47dfd
fix(ci): forceExit mcp-server jest to stop the 20m unit-test hang
ivkan Jun 9, 2026
b455cba
ci: raise Build·Test·Lint timeout 20->35m (recursive ts-jest is slow …
ivkan Jun 9, 2026
b36dc8d
ci: --forceExit recursive jest — real fix for post-run open-handle hangs
ivkan Jun 9, 2026
3acecd3
ci: per-package marker + hard timeout to pinpoint the unit-test staller
ivkan Jun 9, 2026
c5145a8
ci: TEMP diagnostic — client jest --detectOpenHandles (revert after)
ivkan Jun 9, 2026
9f8410c
ci: TEMP diagnostic v2 — detectOpenHandles on TopGunClient.test.ts, l…
ivkan Jun 9, 2026
4daa133
ci: TEMP diagnostic v3 — full client detectOpenHandles, raw non-pino …
ivkan Jun 9, 2026
23ad5e0
fix(cli): honor TOPGUN_SERVER_BINARY override; make dev binary-missin…
ivkan Jun 9, 2026
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
19 changes: 15 additions & 4 deletions .github/workflows/node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ jobs:
build-test-lint:
name: Build · Test · Lint
runs-on: ubuntu-latest
timeout-minutes: 20
# 25m headroom over the real ~15m run. The previous 20m cancellations were NOT
# slowness — several packages (client, mcp-server) finish their tests but leave
# open handles (WebSocket reconnect timers, spawned topgun-server children), so
# jest never exits and the recursive run stalls until the cap. The real fix is
# --forceExit on the Unit tests step below. Proper per-test teardown + not running
# server-dependent tests in this Node-only job is tracked in TODO-448.
timeout-minutes: 25
steps:
- uses: actions/checkout@v4

Expand All @@ -75,9 +81,14 @@ jobs:
run: pnpm -r build

- name: Unit tests
# --runInBand keeps Jest sequential so per-package fixtures don't
# contend on ports/files. Matches CLAUDE.md guidance.
run: pnpm -r exec jest --runInBand --passWithNoTests
# pnpm -r runs jest in every workspace package, sequentially (--runInBand,
# so per-package fixtures don't contend on ports/files — CLAUDE.md guidance).
# --forceExit ends jest past benign open handles. Per-package marker + hard
# SIGKILL timeout: the marker pinpoints a stalling package in the log, and
# the timeout converts any hang into a fast failure instead of burning the
# whole job budget — a permanent safety net. See TODO-448.
run: |
pnpm -r exec sh -c 'echo ">>>> JEST ENTER: $(pwd)"; timeout -s KILL 360 jest --runInBand --forceExit --passWithNoTests'

- name: Lint (eslint)
run: pnpm lint
Expand Down
30 changes: 14 additions & 16 deletions apps/admin-dashboard/src/__tests__/auth-bypass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,25 @@ function mockFetch(impl: FetchImpl) {

describe('getAuthStatus', () => {
it('returns authRequired:false when server responds with authRequired:false', async () => {
mockFetch(async () =>
new Response(JSON.stringify({ authRequired: false }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
mockFetch(
async () =>
new Response(JSON.stringify({ authRequired: false }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);

const result = await getAuthStatus();
expect(result.authRequired).toBe(false);
});

it('returns authRequired:true when server responds with authRequired:true', async () => {
mockFetch(async () =>
new Response(JSON.stringify({ authRequired: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
mockFetch(
async () =>
new Response(JSON.stringify({ authRequired: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);

const result = await getAuthStatus();
Expand All @@ -84,18 +86,14 @@ describe('getAuthStatus', () => {
});

it('defaults to authRequired:true on HTTP 500 (fail-safe)', async () => {
mockFetch(async () =>
new Response('Internal Server Error', { status: 500 })
);
mockFetch(async () => new Response('Internal Server Error', { status: 500 }));

const result = await getAuthStatus();
expect(result.authRequired).toBe(true);
});

it('defaults to authRequired:true on HTTP 404 (fail-safe)', async () => {
mockFetch(async () =>
new Response('Not Found', { status: 404 })
);
mockFetch(async () => new Response('Not Found', { status: 404 }));

const result = await getAuthStatus();
expect(result.authRequired).toBe(true);
Expand Down
99 changes: 99 additions & 0 deletions apps/docs-astro/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,105 @@ if (hasMore && nextCursor) {

`QueryHandle<T>` methods: `subscribe(cb)`, `onDelta(listener)`, `consumeChanges()`, `getLastChange()`, `getPendingChanges()`, `clearChanges()`, `resetChangeTracker()`, `getFilter()`, `getMapName()`, `getPaginationInfo()`, `onPaginationChange(listener)`, `updatePaginationInfo(info)`, `syncState` (getter), `onSyncStateChange(listener)`.

#### Knowing when a result is authoritative — `subscribe` `{ settled }`

A live query fires immediately with whatever is in the local cache, then fires again once the server answers. Most UIs don't care about the difference — but if you need to tell "the server confirmed this" apart from "this is just my optimistic local view" (for example, to hide a spinner only once data is real), `subscribe` passes an optional second argument:

```typescript
const unsubscribe = handle.subscribe((results, meta) => {
// meta.settled is false on the first local/optimistic frame,
// and true once an authoritative server response has arrived
// (including an empty result set — settled true with zero rows
// means the server genuinely has no matching records).
if (meta?.settled) {
hideSpinner();
}
render(results);
});
```

`SubscribeMeta` is `{ settled: boolean }`. The second argument is **optional and additive** — existing `(results) => void` callbacks keep working unchanged. Exported types: `SubscribeCallback`, `SubscribeMeta`.

### `queryOnce(mapName, filter, opts?)`

Fetch a result set **once** and get back authoritative server data — no live subscription, no stale local guesses. Use this when you need a definitive answer at a single point in time (an export, a server-validated check, an AI/agent read) rather than a feed that keeps updating.

```typescript
queryOnce<K extends keyof TSchema & string>(
mapName: K,
filter: QueryFilter,
opts?: QueryOnceOptions,
): Promise<QueryResultItem<TSchema[K]>[]>;
queryOnce<T = any>(
mapName: string,
filter: QueryFilter,
opts?: QueryOnceOptions,
): Promise<QueryResultItem<T>[]>;
```

```typescript
interface QueryOnceOptions {
timeoutMs?: number; // default: DEFAULT_QUERY_ONCE_TIMEOUT_MS (5000)
allowLocal?: boolean; // default: false
}
```

A normal resolve **always** returns settled, authoritative server data. An empty array means the server genuinely has no matching rows — never "we couldn't reach the server". `queryOnce` never silently hands you stale local data.

```typescript
const todos = await client.queryOnce<Todo>('todos', {
where: { completed: false },
sort: { createdAt: 'desc' },
});
// todos is the server's authoritative answer ([] = server has no matching rows)
```

**Offline / timeout policy.** If the client is offline or the settle wait times out (5000 ms by default), the behavior depends on `allowLocal`:

- **Default (`allowLocal` unset/false):** rejects with `QueryOnceUnsettledError`. There is no authoritative data, and `queryOnce` refuses to invent one.

```typescript
import { QueryOnceUnsettledError } from '@topgunbuild/client';

try {
const rows = await client.queryOnce<Todo>('todos', { where: { completed: false } });
} catch (err) {
if (err instanceof QueryOnceUnsettledError) {
// err.code === 'QUERY_ONCE_UNSETTLED'
// err.reason is 'offline' or 'timeout'
// No authoritative server data — do NOT treat as empty.
}
}
```

- **`{ allowLocal: true }`:** still does not resolve with local data on the happy path — instead it throws a typed `QueryOnceLocalError` carrying the non-settled local snapshot on `.localData`. This keeps the distinction explicit: a normal resolve is **always** settled server data, while a caught `QueryOnceLocalError` is **always** non-settled local data. You opt in by catching it.

```typescript
import { QueryOnceLocalError } from '@topgunbuild/client';

try {
const rows = await client.queryOnce<Todo>(
'todos',
{ where: { completed: false } },
{ allowLocal: true },
);
// rows: settled server data
} catch (err) {
if (err instanceof QueryOnceLocalError) {
// err.code === 'QUERY_ONCE_LOCAL_FALLBACK'
// err.reason is 'offline' or 'timeout'
// err.localData: QueryResultItem<Todo>[] — the non-settled local snapshot
}
}
```

The default timeout is exported as `DEFAULT_QUERY_ONCE_TIMEOUT_MS` (`5000`). Override per call with `opts.timeoutMs`.

#### One-shot (`queryOnce`) vs live (`query` / `useQuery`)

- **Reach for `queryOnce`** when you need a single authoritative snapshot and want to know for certain whether the server answered: data exports, validation/decision logic, AI-agent reads, server-confirmed checks. You handle offline/timeout explicitly via the typed errors above.
- **Reach for the live `query` handle (or React `useQuery`)** when you want a continuously-updating view that should render instantly from the local cache and reconcile as the server responds — lists, feeds, dashboards, anything the user watches. Use the `subscribe` `{ settled }` flag if a particular frame needs to distinguish local-optimistic from server-confirmed.

### `topic(name)`

```typescript
Expand Down
99 changes: 99 additions & 0 deletions apps/docs-astro/src/content/docs/reference/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,105 @@ if (hasMore && nextCursor) {

`QueryHandle<T>` methods: `subscribe(cb)`, `onDelta(listener)`, `consumeChanges()`, `getLastChange()`, `getPendingChanges()`, `clearChanges()`, `resetChangeTracker()`, `getFilter()`, `getMapName()`, `getPaginationInfo()`, `onPaginationChange(listener)`, `updatePaginationInfo(info)`, `syncState` (getter), `onSyncStateChange(listener)`.

#### Knowing when a result is authoritative — `subscribe` `{ settled }`

A live query fires immediately with whatever is in the local cache, then fires again once the server answers. Most UIs don't care about the difference — but if you need to tell "the server confirmed this" apart from "this is just my optimistic local view" (for example, to hide a spinner only once data is real), `subscribe` passes an optional second argument:

```typescript
const unsubscribe = handle.subscribe((results, meta) => {
// meta.settled is false on the first local/optimistic frame,
// and true once an authoritative server response has arrived
// (including an empty result set — settled true with zero rows
// means the server genuinely has no matching records).
if (meta?.settled) {
hideSpinner();
}
render(results);
});
```

`SubscribeMeta` is `{ settled: boolean }`. The second argument is **optional and additive** — existing `(results) => void` callbacks keep working unchanged. Exported types: `SubscribeCallback`, `SubscribeMeta`.

### `queryOnce(mapName, filter, opts?)`

Fetch a result set **once** and get back authoritative server data — no live subscription, no stale local guesses. Use this when you need a definitive answer at a single point in time (an export, a server-validated check, an AI/agent read) rather than a feed that keeps updating.

```typescript
queryOnce<K extends keyof TSchema & string>(
mapName: K,
filter: QueryFilter,
opts?: QueryOnceOptions,
): Promise<QueryResultItem<TSchema[K]>[]>;
queryOnce<T = any>(
mapName: string,
filter: QueryFilter,
opts?: QueryOnceOptions,
): Promise<QueryResultItem<T>[]>;
```

```typescript
interface QueryOnceOptions {
timeoutMs?: number; // default: DEFAULT_QUERY_ONCE_TIMEOUT_MS (5000)
allowLocal?: boolean; // default: false
}
```

A normal resolve **always** returns settled, authoritative server data. An empty array means the server genuinely has no matching rows — never "we couldn't reach the server". `queryOnce` never silently hands you stale local data.

```typescript
const todos = await client.queryOnce<Todo>('todos', {
where: { completed: false },
sort: { createdAt: 'desc' },
});
// todos is the server's authoritative answer ([] = server has no matching rows)
```

**Offline / timeout policy.** If the client is offline or the settle wait times out (5000 ms by default), the behavior depends on `allowLocal`:

- **Default (`allowLocal` unset/false):** rejects with `QueryOnceUnsettledError`. There is no authoritative data, and `queryOnce` refuses to invent one.

```typescript
import { QueryOnceUnsettledError } from '@topgunbuild/client';

try {
const rows = await client.queryOnce<Todo>('todos', { where: { completed: false } });
} catch (err) {
if (err instanceof QueryOnceUnsettledError) {
// err.code === 'QUERY_ONCE_UNSETTLED'
// err.reason is 'offline' or 'timeout'
// No authoritative server data — do NOT treat as empty.
}
}
```

- **`{ allowLocal: true }`:** still does not resolve with local data on the happy path — instead it throws a typed `QueryOnceLocalError` carrying the non-settled local snapshot on `.localData`. This keeps the distinction explicit: a normal resolve is **always** settled server data, while a caught `QueryOnceLocalError` is **always** non-settled local data. You opt in by catching it.

```typescript
import { QueryOnceLocalError } from '@topgunbuild/client';

try {
const rows = await client.queryOnce<Todo>(
'todos',
{ where: { completed: false } },
{ allowLocal: true },
);
// rows: settled server data
} catch (err) {
if (err instanceof QueryOnceLocalError) {
// err.code === 'QUERY_ONCE_LOCAL_FALLBACK'
// err.reason is 'offline' or 'timeout'
// err.localData: QueryResultItem<Todo>[] — the non-settled local snapshot
}
}
```

The default timeout is exported as `DEFAULT_QUERY_ONCE_TIMEOUT_MS` (`5000`). Override per call with `opts.timeoutMs`.

#### One-shot (`queryOnce`) vs live (`query` / `useQuery`)

- **Reach for `queryOnce`** when you need a single authoritative snapshot and want to know for certain whether the server answered: data exports, validation/decision logic, AI-agent reads, server-confirmed checks. You handle offline/timeout explicitly via the typed errors above.
- **Reach for the live `query` handle (or React `useQuery`)** when you want a continuously-updating view that should render instantly from the local cache and reconcile as the server responds — lists, feeds, dashboards, anything the user watches. Use the `subscribe` `{ settled }` flag if a particular frame needs to distinguish local-optimistic from server-confirmed.

### `topic(name)`

```typescript
Expand Down
12 changes: 9 additions & 3 deletions apps/docs-astro/src/content/docs/reference/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,23 @@ List every map the server knows about.

### topgun_query

Read records from a map with filters, sorting, and cursor pagination.
Read records from a map with filters and sorting. Returns **authoritative server data** — the assistant gets the live, server-confirmed answer, not a stale local guess.

- **Parameters:** `{ map, filter?, sort?, limit?, cursor?, fields? }`
- **Response:** `[{ _key, ...fields }]` plus optional `nextCursor` for pagination
- **Parameters:** `{ map, filter?, sort?, limit?, fields? }`
- **Response:** `[{ _key, ...fields }]` (a plain settled array)
- **Security:** rejects maps outside `allowedMaps`; clamps `limit` to `maxLimit`

```text
> Show me unfinished tasks created this week
[runs topgun_query with where: { done: false, createdAt: { gt: ... } }]
```

A settled result with no matching records renders as `No results found in map '<map>'...` — that means the server genuinely has no such rows, not that the query failed.

**Offline / not-settled behavior.** `topgun_query` never silently returns stale local data and never conflates "offline" with "empty". If the server cannot be reached, or the query does not settle within the default 5000 ms window, the tool returns an error (`isError: true`) explaining that no authoritative data was returned — so the assistant knows to retry rather than reporting an empty list. The message distinguishes the two causes (server unreachable / client offline, vs. timed out waiting for the server).

**No cursor pagination, but truncation is never silent.** `topgun_query` returns a plain settled array; the previous `nextCursor` / `hasMore` continuation hint and `cursor` parameter have been removed (continuation cursors are an anti-pattern for an LLM caller). It does **not** drop the one signal that matters: when more rows match than the returned `limit`, the response appends a `More rows match than were returned…` note so the assistant knows the view was capped and can narrow `filter` / `sort` (or raise `limit` up to `maxLimit`) — rather than reporting a truncated list as the whole answer.

### topgun_mutate

Create, update, or remove a record.
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ describe('topgun dev', () => {
encoding: 'utf8',
cwd: tempDir,
stdio: 'pipe',
// Force the "binary not found" path deterministically: an explicit
// (nonexistent) override means dev() skips autodetect and never spawns
// a real server. Without this, in environments where @topgunbuild/server
// resolves (e.g. CI monorepo node_modules), dev() would spawn a
// long-running server and execSync would hang (orphaning topgun-server
// and stalling the whole recursive jest run). Timeout is belt-and-
// suspenders. See TODO-448.
env: { ...process.env, TOPGUN_SERVER_BINARY: '/nonexistent/topgun-server' },
timeout: 30000,
killSignal: 'SIGKILL',
});
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException & {
Expand Down
Loading
Loading