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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

### Fixed

- **A typed client is not a thenable.** `rpc` and `queryClient` are `Proxy` objects that answer any string property with a call method, `then` included — so `await client`, `Promise.resolve(client)` and returning one from an `async` function each read `then`, got a function back, and were resolved by calling it: a POST to an action named `then`, or a GET of `/_x/query/then`, with the await settling on that response instead of the client. Both proxies now answer `undefined` for `then` — the one name the language reserves at this seam — and both test files pin `await client === client` with a fetch that counts its calls. The symbol assertion that stood there covered `Symbol.toPrimitive` and `Symbol.iterator` and could not have caught a plain string key.

- **The digest's window is calendar arithmetic at both ends, and its slots come from the occurrence.** Two defects in `examples/dummy`'s nightly digest, both of them a day of milliseconds standing in for a day. The delivery loaded `slotAt - 86_400_000`, while consecutive 09:00 slots are 23 hours apart on spring-forward and 25 on autumn-back: in Madrid on 2026-03-29 the window opened an hour *before* the previous digest closed and mailed that hour's posts twice, and an autumn transition left an hour that reached no digest at all. It now opens at `previousDigestAt(slotAt, zone)` — new in `@postly/core`, the mirror of `nextDigestAt` and the same `fromZoned` arithmetic — and the `postly.digestPreview` MCP tool, which promises "the same window", was computing the old one. The fan-out's slots were derived from `ctx.now()`, which runs on every attempt while only the enqueues already stored replay: an attempt taken after a zone's 09:00 had passed rolled that zone into tomorrow, so groups the first attempt had not reached were enqueued as a *different* digest — a next-day `slotAt` and `localDate` under a step name carrying no date to catch it. The base is now midnight UTC of `input.runDate`, the occurrence the task fired for, identical on every attempt.

- **Four call sites in the reference app that a review found before a user did.** `signedAvatarUrl` read one page of `disk().list()` and picked the newest key in it — S3 truncates at 1000 — so a member who re-uploaded past a page boundary kept rendering whichever avatar landed on page one; it now follows the cursor to the end, folding a page at a time rather than collecting every key to choose one. `toggleDigestOptIn`'s `custom()` resolver returned the whole optimistic row whenever the server was opted in, reverting a theme, a locale or a role changed on another device while the toggle sat in the offline queue; it now starts from the server row and resolves the one field it is about. `app/feed/page.tsx` fetched `feedActivity` inside an `async` component wrapped in a `<Suspense>` shim that ignored its `fallback` — nothing splits a page into holes yet, so the shell waited for the count anyway and the skeleton never rendered; the read moved to the route's `load()`, where every other read in the app lives, and the shim is deleted rather than left as a boundary that boundaries nothing. And because a query declares no output schema, a `Date` column arrives from the client as the ISO string `JSON.stringify` wrote: `site/blog/[slug]/page.tsx` called `.toISOString()` on it while rendering JSON-LD. The blog routes and the post page rehydrate at their loader through `shared/wire.ts`; the client's own header and the wiki now say what the wire hands back.

- **The reference app's pages pass the inputs their reads require and render the rows they were given.** Four call-site classes in `examples/dummy`, all of them the app calling a real API wrongly rather than the API being wrong. **Missing required input:** `app/posts/[id]/page.tsx` read `postById({ postId })` without the `orgId` `postRead` decides on — the org now comes off the actor, because `/posts/{id}` carries no tenant and a foreign id must read as absent; `app/feed/page.tsx` rendered `<LikeButton>` with no `orgId`, which `postLike` needs in the mutator's input so an offline queue replayed hours later still reaches the rule. **Wrong row shape:** the blog index fed `{ slug, updatedAt }` rows to `toCardPost`, which needs a title, an excerpt and a byline — there is now a `publicPosts` read behind it, `publicPostSlugs` going back to being only what its name says, the prerender enumeration; and `site/blog/[slug]/page.tsx` read `updatedAt` off a `PostView` that excludes it, so the JSON-LD's `dateModified` is gone (`ld.Article` defaults it to `datePublished`) and `datePublished` is an ISO string rather than a `Date`. **UUID in a name slot:** `app/posts/mail.ts` and `app/digest/mail.ts` interpolated `member.orgId` into `{org}` — a UUID where the reader expects their organisation's name — so both payloads now carry the org's name, top-level, which is the only level `renderMail` interpolates a subject from. **Hardcoded strings:** `'Postly'` in two JSON-LD nodes and two breadcrumbs is `t('common.appName')`, and `priceCurrency: 'USD'` on the landing and pricing pages is resolved from the URL by one shared rule both `meta` and the page body read, so structured data cannot quote a currency the visitor was never shown. Alongside them, in the same expressions: `ld` takes an array, `canonical` is its own key and not a field on `alternates`, `robots` is a `RobotsDirectives` object and not the string `'noindex'`, `ld.SoftwareApplication` requires `operatingSystem`, `ld.Product` takes one offer (so a pricing page with three plans emits three products), an offer's `price` is a decimal string, and `defineRoute` has no `feed:` key — the blog index declared three feed formats and emitted none. The app's typecheck baseline drops from 184 errors to 165.

- **The reference app's posts repository runs on the entity API that exists.** `examples/dummy`'s `apps/web/app/posts/repo.ts` was written against `.join()`, `.with()`, `.returning()`, `.onConflictDoNothing()` and `.returningInserted()` — five builder methods `@ultimat3/entity`'s `Table`/`ReadBuilder` has never had — so every function in it threw at the builder before a driver ever saw a statement, and the file carried 18 of the app's typecheck errors. Rewritten on the real surface: `authorName` is `preload('author')`, one extra `where id in (…)` for a whole page rather than a join that does not exist; an idempotent like is `upsertAll([row], { onConflict: ['postId', 'memberId'], onMatch: 'nothing' })`, whose empty result IS "that like was already there"; an unlike is `deleteWhere`, the only write path a composite primary key has; the recount is `count()` + `update(id, patch, { orgId })`; and the feed's projection is `select()`. `andWhere('publishedAt', '>=', since)` named an operator that does not exist and is now `'gte'`. `publishPost`'s policy row loader is scoped to the org its own input names instead of reading `posts` unscoped — `postPublish` denies a null row exactly as it denies a row from another org, so the scope decides nothing the rule did not already decide, and `X_TENANCY_UNSCOPED` no longer refuses the read before the guard runs. The app's typecheck baseline drops from 202 errors to 184 and five pinned-red tests turn green, `contract · every post action passes the contract an action owes` among them. One gap is left in the file, and it is now the only one: the public blog resolves `/blog/{slug}` with no tenant anywhere in the URL — which is why `post_slug_unique` is global — so `publishedBySlug` and `publishedSlugs` read a tenant-columned entity with no org predicate, and a deliberately cross-tenant read has no escape hatch.

- **One SQL scanner under the splitter, the read-only guards and the destructive rail.** `stripSqlNoise()` blanked spans by a sequence of replacements, comments first — so the `--` in `select '--'; delete from posts` read as a line comment and the `delete` was erased before `inspectStatement()` ever saw it, letting a mutating fragment through `readOnly(client, { seal: false })`. It now walks the same left-to-right lexer `statementsOf()` uses (new internal `sql-scan.ts`), where a `--` inside a literal is data because the literal is scanned first. Two more from the same lexer: a `$tag$` immediately after an identifier is no longer read as a dollar-quote opener — `$` is legal in a name after the first character, so `select foo$tag$; select 2;` is two statements and used to go out as one send — and `hasDestructiveMarker()` now finds `-- destructive: true` only as a **top-level line comment**, never inside a `/* … */` or a dollar-quoted body where a regex over the raw file matched it and bought an unmarked destructive migration past `x verify`.

- **A migration snapshot is the newest migration's, or there is none.** `declaredSchema()` returned the newest snapshot it could find, so a later migration written without one silently answered with an obsolete schema: `0001` records `posts`, `0002` adds a column by hand, and drift reported the column the database correctly holds as `unexpected-column`, fixed by generating a migration that already exists. It now returns `SchemaDescription | undefined` — the newest migration's snapshot, or nothing. `checkDrift()` reports that as an `unknown-schema` difference instead of `ok: true`, and `x db gen` refuses with the new `X_MIGRATION_SNAPSHOT_MISSING` rather than diffing against the empty schema, which would have emitted `create table` for every table the database already holds.
Expand Down Expand Up @@ -86,6 +96,21 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

### Added

- **`queryClient` — the map-wide typed read client, the mirror of `rpc`.** `@ultimat3/query` shipped
`.client({ baseUrl })`, which needs the query object — so the one surface that most needs a typed
read could not have one: a `site/` route importing a feature's `live.ts` to reach it is
`X_BOUNDARY_VIOLATION`, and the reference app had all seven of its read call sites taken off the
**action** client instead, where none of those names exist. `queryClient<Api['queries']>({ baseUrl })`
binds every registered read off the `Api` **type** alone, exactly as `rpc<Api['actions']>` binds
every write. Both spellings proxy to `queryClientMethodFor`, so a read has one URL however it is
addressed.

```ts
// apps/web/shared/client.ts — two registries, two clients, one origin
export const client = rpc<Api['actions']>({ baseUrl }); // writes
export const queries = queryClient<Api['queries']>({ baseUrl }); // reads
```

- **`backfill()` — one pass over a table, declared as a `job`.** `@ultimat3/jobs` gains a factory, not a ninth primitive: a backfill is durable work with an input schema, a retry policy, an idempotency key and a queue, so `backfill({ name, source, batch, handle })` *returns* a `JobHandle` and inherits `.enqueue()`, the worker's cancellation, the dead-letter path, `x jobs show` and its manifest row with no line of its own. Same rule `llm()` follows: a new capability arrives as a factory over an existing primitive.

```ts
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion examples/dummy/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,16 @@ Feature slice: `apps/web/app/<feature>/{entity,repo,service,actions,mutator,live

- `local()` in a mutator must be replayable: no `Date.now()`, no `Math.random()`, no I/O.
- A live query must be bounded (`orderBy` + `limit`) or `x verify` rejects it.
- The digest job schedules per member, not per org — timezone is a member column.
- The digest schedules per (org, zone), never per org alone — timezone is a member column, so one
org spanning two zones is two deliveries at two instants. The unit is a group and not a member
because the post window is org-scoped and bounded by the group's own slot: reading it once per
member is the same query N times.
- Plan prices are per-currency rows in the plan catalog; never convert currencies at runtime.
- A service reaches the app through `shared/services.ts` and nowhere else. `CtxServices` carries a
string index signature, so `ctx.whatever` compiles as `unknown` and an undeclared service is a
runtime `TypeError` rather than a build error — `ctx.storage.ensureBucket()` shipped that way
until it was deleted for `app/orgs/avatar.ts`, which calls `@ultimat3/storage`'s real surface.
`ctx.auth` and `ctx.billing` are still undeclared and still unimplemented; they are the two
remaining instances, not a pattern to copy.
- Uploads are `grantUpload` wrapped in an app `action` — the app owns the policy, the framework
owns the key and the signature. Nothing here builds an object key by hand.
19 changes: 10 additions & 9 deletions examples/dummy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,22 @@ x db seed dev # 2 orgs, 5 members across 4 timezones, 2 currenci
| `entity` | [`apps/web/app/posts/entity.ts`](apps/web/app/posts/entity.ts) | the feature's view schema (`PostView`) over the shared table |
| `policy` | [`apps/web/app/posts/policy.ts`](apps/web/app/posts/policy.ts) | `post:publish` = owns-or-org-admin, one definition, five surfaces |
| `action` | [`apps/web/app/posts/actions.ts`](apps/web/app/posts/actions.ts) | `createPost`, `publishPost`, and `summarize` — an `llm()` model call, which is an action factory rather than a ninth primitive |
| `action` | [`apps/web/app/orgs/actions.ts`](apps/web/app/orgs/actions.ts) | `inviteMember`, `upgradePlan` (minor-unit arithmetic) |
| `action` | [`apps/web/app/orgs/actions.ts`](apps/web/app/orgs/actions.ts) | `inviteMember`, `upgradePlan` (minor-unit arithmetic), `grantAvatarUpload` — a presigned PUT whose key and signature are `@ultimat3/storage`'s ([`avatar.ts`](apps/web/app/orgs/avatar.ts)) |
| `action` | [`apps/web/app/settings/actions.ts`](apps/web/app/settings/actions.ts) | `savePreferences` — a one-action slice still gets an `actions.ts` |
| `mutator` | [`apps/web/app/posts/mutator.ts`](apps/web/app/posts/mutator.ts) | `likePost` — optimistic local twin, offline queue |
| `query` | [`apps/web/app/posts/live.ts`](apps/web/app/posts/live.ts) | `liveFeed` (`live: true`, `persist: true`) + non-live `postBySlug` |
| `mutator` | [`apps/web/app/posts/mutator.ts`](apps/web/app/posts/mutator.ts) | `likePost` — optimistic local twin, offline queue, `conflict: 'server-wins'` |
| `mutator` | [`apps/web/app/settings/mutator.ts`](apps/web/app/settings/mutator.ts) | `setTheme` (`'last-write-wins'`) and `toggleDigestOptIn` (`custom`, sticky unsubscribe) — the other two conflict strategies |
| `query` | [`apps/web/app/posts/live.ts`](apps/web/app/posts/live.ts) | `liveFeed` (`live: true`) + non-live `postBySlug` |
| `job` | [`apps/web/app/orgs/jobs.ts`](apps/web/app/orgs/jobs.ts) | `onboardOrg` — durable steps + `step.sleep('3d')` |
| `job` | [`apps/web/app/posts/jobs.ts`](apps/web/app/posts/jobs.ts) | `notifySubscribers` — fanout, per-tenant concurrency |
| `job` | [`apps/web/app/digest/jobs.ts`](apps/web/app/digest/jobs.ts) | `sendDigest` — 09:00 **local per member**, DST-correct |
| `job` | [`apps/web/app/posts/jobs.ts`](apps/web/app/posts/jobs.ts) | `notifySubscribers` — fanout, `concurrency: 1` (one in flight across the fleet) |
| `job` | [`apps/web/app/digest/jobs.ts`](apps/web/app/digest/jobs.ts) | `sendDigest` — 09:00 **local per member**, DST-correct, delivered one (org, zone) group at a time |
| `task` | [`apps/web/api/tasks.ts`](apps/web/api/tasks.ts) | `nightlyDigest` cron with an explicit `tz` |
| `route` | [`apps/web/site/page.tsx`](apps/web/site/page.tsx) | `static`, `hydrate: 'never'`, 0kb JS |
| `route` | [`apps/web/site/pricing/page.tsx`](apps/web/site/pricing/page.tsx) | `isr`, money formatted at the edge |
| `route` | [`apps/web/site/blog/[slug]/page.tsx`](apps/web/site/blog/%5Bslug%5D/page.tsx) | `isr` + `prerender()` + `ld.Article` |
| `route` | [`apps/web/site/blog/page.tsx`](apps/web/site/blog/page.tsx) | `isr` list + RSS/Atom/JSON feed from one declaration |
| `route` | [`apps/web/app/posts/new/page.tsx`](apps/web/app/posts/new/page.tsx) | `stream` + `hydrate: 'never'` — a native form posting to an action |
| `route` | [`apps/web/site/blog/page.tsx`](apps/web/site/blog/page.tsx) | `isr` list — no `feed:` key on `defineRoute`; a feed would be its own URL behind an `api/` route, not a flag on the HTML page (no feed route exists yet) |
| `route` | [`apps/web/app/posts/new/page.tsx`](apps/web/app/posts/new/page.tsx) | `ssr` + `hydrate: 'never'` — a native `<form>` posting to an action, works before hydration and with JS disabled |
| `route` | [`apps/web/app/posts/[id]/page.tsx`](apps/web/app/posts/%5Bid%5D/page.tsx) | `ssr`, fresh per request |
| `route` | [`apps/web/app/feed/page.tsx`](apps/web/app/feed/page.tsx) | `stream`, `useLive(liveFeed)`, usable offline |
| `route` | [`apps/web/app/feed/page.tsx`](apps/web/app/feed/page.tsx) | `stream`, `useLiveFeed()` over the socket + a `<Suspense>`-streamed activity badge, usable offline |
| `route` | [`apps/web/app/settings/page.tsx`](apps/web/app/settings/page.tsx) | `spa`, locale + timezone + theme pickers |

## Why `packages/`
Expand Down Expand Up @@ -78,7 +79,7 @@ apps/web/app/<feature>/{entity,repo,service,actions,mutator,live,jobs,policy,ui}
| **Timezones** | [`packages/core/src/digest-schedule.ts`](packages/core/src/digest-schedule.ts) | member `tz` drives every `<DateTime>`; digest fires 09:00 local, DST-correct across the March/November transitions |
| **Money** | [`packages/core/src/billing.ts`](packages/core/src/billing.ts) | integer minor units, USD + EUR, arithmetic never leaves minor units, `Intl` only at the edge |
| **Offline** | [`apps/web/app/posts/mutator.ts`](apps/web/app/posts/mutator.ts) | `likePost` queues offline and reconciles; feed reads from the persisted store; [`site/offline/page.tsx`](apps/web/site/offline/page.tsx) is the required fallback |
| **Realtime** | [`apps/web/app/feed/page.tsx`](apps/web/app/feed/page.tsx) | tier 3 — `useLive(liveFeed)` is a Solid signal, patched per row |
| **Realtime** | [`apps/web/app/feed/page.tsx`](apps/web/app/feed/page.tsx) | tier 3 — `useLiveFeed()` (bound once with `liveHookFor`) is a Solid signal, patched per row |
| **AI-first** | [`packages/mcp/src/tools.ts`](packages/mcp/src/tools.ts) | every exposed action is an MCP tool with the *same* policy; admin ships its own MCP surface |
| **Admin** | [`apps/admin/src/index.ts`](apps/admin/src/index.ts) | the whole dashboard, 20 lines of `defineAdmin` |
| **Prompts** | [`apps/web/app/posts/prompts`](apps/web/app/posts/prompts) | versioned `.md` artifact + typed slots + a scored eval |
Expand Down
7 changes: 4 additions & 3 deletions examples/dummy/apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Empty on purpose. A desktop build is a shell around the same app, added without

## Tauri

```bash
x app add desktop --shell tauri
```
Not built yet. As of 2026-08 the CLI has no `x app add` and no `x build --target desktop` — both are
milestone 12 on the roadmap ([`docs/idea/16-app-targets.md`](../../../../docs/idea/16-app-targets.md)),
which is why this directory stays empty rather than holding a scaffold nothing generates. The
shape is already decided, so it is written down here instead of guessed at again when it ships:

Wraps `apps/web`'s `app/` surface in a Tauri window:

Expand Down
Loading