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
2 changes: 1 addition & 1 deletion docs/idea/02-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Localised agent-facing text is a separate, locale-resolved projection, not a sec

| # | Artifact | Derived from | Notes |
|---|---|---|---|
| 1 | **HTTP route** | name + `input` | `POST /_x/action/publish-post`, body parsed by `input`, errors are `UltimateError` JSON |
| 1 | **HTTP route** | name + `input` | `POST /api/posts/publish` — verb first, pluralized resource after. Body parsed by `input`, errors are `UltimateError` JSON |
| 2 | **OpenAPI operation** | `input` + `output` + `mcp.description` | emitted into `x.manifest.json` and `openapi.json`; contract diff runs in `x verify` |
| 3 | **Typed client function** | `input` + `output` | `await publishPost({ postId, orgId })` in `app/`, no fetch, no codegen step to remember |
| 4 | **Job handle** | the whole declaration | `publishPost.job()` — the action's `input`, a namespaced job name, an `idempotencyKey` derived from the payload, and an `invoke` that lands in the same execution core with `surface: 'job'`. It is the shape a queue driver *registers*, not one you call: `.enqueue()` belongs to a declared `job` like `notifySubscribers` above; see [`04-jobs.md`](./04-jobs.md) |
Expand Down
9 changes: 7 additions & 2 deletions examples/dummy/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,13 @@ Feature slice: `apps/web/app/<feature>/{entity,repo,service,actions,mutator,live
- Entities live in `packages/db`; a feature's `entity.ts` owns only that feature's view schemas.
- One authz definition. `policy.ts` predicates are reused verbatim by HTTP, live queries, jobs,
MCP tools, and admin. Never re-check authz inside `handle`.
- `t` is two different things by file kind: the schema namespace (`@ultimat3/schema`) in schema/entity/action
files, the i18n translator (`useI18n()`) in components. Never both in one file.
- `t` is two different things by file kind: the schema namespace in declaration files, the i18n
translator (`useI18n()`) in components. Never both in one file. A declaration file imports the
schema `t` from the package it declares in — `@ultimat3/action` in `actions.ts`/`mutator.ts`,
`@ultimat3/query` in `live.ts`, `@ultimat3/jobs` in `jobs.ts`, `@ultimat3/mail` in `mail.ts`,
`@ultimat3/mcp` in the MCP package — never reaching past it to `@ultimat3/schema`. It is the same
object either way. A module that declares no primitive (a feature's `entity.ts` view schemas)
imports `@ultimat3/schema` directly, because that already is its one import.
- Money is `{ minor, currency }`. Arithmetic in `packages/core/src/billing.ts`; formatting only
in `<Money>` at the edge.
- Dates are UTC instants in the DB, rendered only through `<DateTime zone={member.tz}>`.
Expand Down
40 changes: 15 additions & 25 deletions examples/dummy/apps/web/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,41 @@
/**
* The API surface: every action, mutator and query Postly exposes, registered in one place.
* The API surface: every action, mutator and query Postly exposes, registered in one call.
* Nothing else lives here — no rendering, no logic, no request handling. From this list the
* framework projects HTTP routes, `openapi.json`, the typed client, job handles, MCP tools and
* test scaffolds.
*
* Registration takes whole modules, so the export name IS the primitive's name: there is no
* `defineApi` takes whole modules, so the export name IS the primitive's name: there is no
* second list of strings to keep in step with the declarations, and adding an action to a
* feature is one edit rather than two. Two features exporting one name collide at registration
* with `X_ACTION_DUPLICATE`; the `Api` intersection below would merge them silently, so the
* runtime check is what enforces uniqueness, not the type.
* with `X_ACTION_DUPLICATE`.
*
* Importing this module IS the boot — the calls below run on import, and nothing else registers
* Importing this module IS the boot — the call below runs on import, and nothing else registers
* anything. The only importer today is the framework's own module scan, which dynamic-imports
* every file under an app's surface directories; that is what backs `x manifest`, `x routes`,
* `x dev` and `x verify`. No long-running process imports it, because the entry that would —
* `apps/web/server.ts`, what `x build --target binary` compiles and what the image starts as
* `dist/server.js` — has not been written yet.
*/

import { registerActions } from '@ultimat3/action';
import { registerQueries } from '@ultimat3/query';
import { defineApi } from '@ultimat3/action';
import * as orgActions from '../app/orgs/actions';
// `defineService('orgs', ...)` / `defineService('posts', ...)` run on import — the same
// "importing IS the boot" rule as the registration below, so `ctx.orgs` and `ctx.posts` are
// installed wherever this module has run, including tests.
import '../app/orgs/service';
import * as postActions from '../app/posts/actions';
import * as postQueries from '../app/posts/live';
// A mutator IS an action, so it registers as one: the optimistic local twin rides on the same
// declaration instead of living in a parallel registry with a parallel authz path.
import * as postMutators from '../app/posts/mutator';
// `defineService('posts', ...)` runs on import — the same "importing IS the boot" rule as the
// registrations below, so `ctx.posts` is installed wherever this module has run, including
// tests. `orgs/service.ts` is NOT imported here yet: it still reaches `daysBetween`, which
// `@ultimat3/time` does not export — a pre-existing gap this task does not own, tracked
// separately from the posts feature this file already boots.
import '../app/posts/service';
import * as settingsActions from '../app/settings-actions';

registerActions(postActions);
registerActions(postMutators);
registerActions(orgActions);
registerActions(settingsActions);

registerQueries(postQueries);
export const api = defineApi({
actions: [postActions, orgActions, settingsActions],
mutators: [postMutators],
queries: [postQueries],
});

/** What the typed client is shaped from — imported as a TYPE only by `shared/client.ts`. */
export type Api = {
readonly actions: typeof postActions &
typeof postMutators &
typeof orgActions &
typeof settingsActions;
readonly queries: typeof postQueries;
};
export type Api = typeof api;
5 changes: 3 additions & 2 deletions examples/dummy/apps/web/app/digest/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
* usually wrong — the member's timezone. `localDate` is computed once by the job, in the member's
* zone, and travels as a string: nothing here reaches for a clock, so "yesterday" means yesterday
* where the reader is rather than where the worker runs.
*
* `t` comes from @ultimat3/mail, not @ultimat3/schema: a mail file imports one package.
*/

import { blocks, defineMail } from '@ultimat3/mail';
import { type Infer, t } from '@ultimat3/schema';
import { blocks, defineMail, type Infer, t } from '@ultimat3/mail';
import { MemberView } from '../orgs/entity';
import { PostSummary } from '../posts/entity';

Expand Down
3 changes: 3 additions & 0 deletions examples/dummy/apps/web/app/orgs/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
* The orgs feature's view schemas: what an org, a membership and a billing receipt look like on
* the wire. Money crosses the wire as minor units plus a currency — never as a formatted string,
* because a client that receives "€18,00" cannot do arithmetic with it.
*
* `t` comes from @ultimat3/schema here, not from a primitive package: this file declares no
* primitive, so schema *is* its one import. Every primitive package re-exports the same object.
*/

import {
Expand Down
14 changes: 9 additions & 5 deletions examples/dummy/apps/web/app/orgs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
* tool call.
*/

import { assertSeatsAvailable, quoteUpgrade } from '@postly/core';
import { assertSeatsAvailable, endOfBillingPeriod, quoteUpgrade } from '@postly/core';
import type { AppLocale, AppTheme, AppZone } from '@postly/domain';
import { type MemberId, type OrgId, type PlanCode, seatLimit } from '@postly/domain';
import { type Ctx, defineService } from '@ultimat3/core';
import { daysBetween, endOfBillingPeriod } from '@ultimat3/time';
import { daysBetween, instant } from '@ultimat3/time';
import type { InviteInput, MemberView, OrgView, UpgradeReceipt } from './entity';
import { OrgNotFound } from './errors';
import {
Expand Down Expand Up @@ -49,17 +49,21 @@ export const orgsService = defineService('orgs', (ctx: Ctx) => ({
/**
* Prorated in integer minor units, in the org's own currency. Nothing is formatted here — the
* receipt travels as `{ minor, currency }` and `<Money>` renders it once, at the edge.
*
* The period is a calendar month in `ctx.tz`, read once: three separate `ctx.now()` calls could
* straddle local midnight and quote a cycle the org was never on.
*/
async upgrade(plan: PlanCode): Promise<UpgradeReceipt> {
const org = await this.byId(ctx.actor.orgId);
const periodEnd = endOfBillingPeriod(ctx.now());
const at = instant(ctx.now());
const periodEnd = endOfBillingPeriod(at, ctx.tz);

const quote = quoteUpgrade({
from: org.planCode,
to: plan,
currency: org.billingCurrency,
daysRemaining: daysBetween(ctx.now(), periodEnd),
daysInCycle: daysBetween(endOfBillingPeriod(ctx.now(), -1), periodEnd),
daysRemaining: daysBetween(at, periodEnd, ctx.tz),
daysInCycle: daysBetween(endOfBillingPeriod(at, ctx.tz, -1), periodEnd, ctx.tz),
});

await ctx.billing.charge(quote.charge, { orgId: org.id, reason: `upgrade:${plan}` });
Expand Down
3 changes: 3 additions & 0 deletions examples/dummy/apps/web/app/posts/entity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/**
* The posts feature's view schemas. The table lives in `@postly/db` because admin and the worker
* need it too; what a *post looks like on the wire* is this feature's business, so it lives here.
*
* `t` comes from @ultimat3/schema here, not from a primitive package: this file declares no
* primitive, so schema *is* its one import. Every primitive package re-exports the same object.
*/

import type { Post } from '@postly/db';
Expand Down
5 changes: 3 additions & 2 deletions examples/dummy/apps/web/app/posts/mail.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/**
* Mail the posts feature sends. A template is a declaration: the runtime renders it in the
* recipient's locale, in the `worker` role, with no DOM and no request in scope.
*
* `t` comes from @ultimat3/mail, not @ultimat3/schema: a mail file imports one package.
*/

import { blocks, defineMail } from '@ultimat3/mail';
import { type Infer, t } from '@ultimat3/schema';
import { blocks, defineMail, type Infer, t } from '@ultimat3/mail';
import { MemberView } from '../orgs/entity';
import { PostView } from './entity';

Expand Down
5 changes: 3 additions & 2 deletions examples/dummy/apps/web/app/posts/prompts/summarize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
* The prompt itself is a versioned artifact. Bumping `version` changes its content hash, which
* keys the semantic cache and appears in traces, so a summary can always be attributed to the
* exact text that produced it.
*
* `t` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package.
*/

import { tag } from '@postly/db';
import { action } from '@ultimat3/action';
import { action, t } from '@ultimat3/action';
import { definePrompt } from '@ultimat3/ai';
import { can } from '@ultimat3/policy';
import { t } from '@ultimat3/schema';
import { summarizeTemplate } from './summarize-template';

/** Editing the markdown requires bumping this version — it keys the cache and the traces. */
Expand Down
6 changes: 3 additions & 3 deletions examples/dummy/apps/web/shared/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
* client's own inputs.
*/

import { createClient } from '@ultimat3/action';
import { rpc } from '@ultimat3/action';
import { EnvMissingError } from '@ultimat3/core';
import type { Api } from '../api';

/**
* Resolved on access, never at import: `createClient` reads `baseUrl` only when a method is taken
* Resolved on access, never at import: `rpc` reads `baseUrl` only when a method is taken
* off the client, so a missing `APP_URL` fails the call that needed an origin. Throwing at import
* would take down every test that merely renders a component. An empty string is worse than
* either — it makes relative requests that appear to work in a browser and fail to parse
Expand All @@ -35,7 +35,7 @@ function appUrl(): string {
return url;
}

export const client = createClient<Api['actions']>({
export const client = rpc<Api['actions']>({
get baseUrl() {
return appUrl();
},
Expand Down
2 changes: 1 addition & 1 deletion examples/dummy/packages/core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Needs data? Take it as an argument. The caller's `repo.ts` does the loading.

| File | Owns |
|---|---|
| `src/billing.ts` | plan upgrade quotes + seat limits, minor-unit arithmetic |
| `src/billing.ts` | plan upgrade quotes + seat limits, minor-unit arithmetic, the billing period |
| `src/digest-schedule.ts` | next 09:00 local per zone, DST-correct; local calendar date |
| `src/membership.ts` | the authz predicates policies wrap |
| `src/errors.ts` | `X_BILLING_SEATS_EXCEEDED`, `X_BILLING_NOT_AN_UPGRADE` |
Expand Down
41 changes: 40 additions & 1 deletion examples/dummy/packages/core/src/billing.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** unit — no DB, no I/O. Every assertion is in minor units; nothing here formats money. */

import { expect, test } from 'bun:test';
import { assertSeatsAvailable, quoteUpgrade, seatsRemaining } from './billing';
import { addMs, daysBetween, fromIso, toIso } from '@ultimat3/time';
import { assertSeatsAvailable, endOfBillingPeriod, quoteUpgrade, seatsRemaining } from './billing';
import type { CoreError } from './errors';

const codeOf = (run: () => unknown): string | undefined => {
Expand Down Expand Up @@ -88,3 +89,41 @@ test('the seat limit is checked before the invite, and names the fix', () => {
expect(codeOf(() => assertSeatsAvailable('free', 2))).toBeUndefined();
expect(codeOf(() => assertSeatsAvailable('free', 3))).toBe('X_BILLING_SEATS_EXCEEDED');
});

test('the period ends with the calendar month that contains the instant', () => {
const midJanuary = fromIso('2026-01-10T12:00:00Z');
expect(toIso(endOfBillingPeriod(midJanuary, 'UTC'))).toBe('2026-01-31T23:59:59.999Z');
expect(toIso(endOfBillingPeriod(midJanuary, 'UTC', -1))).toBe('2025-12-31T23:59:59.999Z');
});

test('month length is read from the calendar, never assumed to be 30', () => {
const endOf = (iso: string): string => toIso(endOfBillingPeriod(fromIso(iso), 'UTC'));
expect(endOf('2026-01-15T00:00:00Z')).toBe('2026-01-31T23:59:59.999Z'); // 31 days
expect(endOf('2026-04-15T00:00:00Z')).toBe('2026-04-30T23:59:59.999Z'); // 30 days
expect(endOf('2026-02-15T00:00:00Z')).toBe('2026-02-28T23:59:59.999Z'); // 28
expect(endOf('2024-02-15T00:00:00Z')).toBe('2024-02-29T23:59:59.999Z'); // 29, leap
});

test('it lands on the last instant of the period, not the first of the next', () => {
const end = endOfBillingPeriod(fromIso('2026-01-10T12:00:00Z'), 'UTC');
expect(toIso(addMs(end, 1))).toBe('2026-02-01T00:00:00.000Z');
});

test('the period boundary is local: New York ends January five hours after UTC does', () => {
const midJanuary = fromIso('2026-01-10T12:00:00Z');
expect(toIso(endOfBillingPeriod(midJanuary, 'America/New_York'))).toBe(
'2026-02-01T04:59:59.999Z',
);
});

test('the cycle length a quote prorates against is the real month, DST included', () => {
const cycleAt = (iso: string, zone: string): number => {
const at = fromIso(iso);
return daysBetween(endOfBillingPeriod(at, zone, -1), endOfBillingPeriod(at, zone), zone);
};

expect(cycleAt('2026-02-15T00:00:00Z', 'UTC')).toBe(28);
expect(cycleAt('2026-01-15T00:00:00Z', 'UTC')).toBe(31);
// March in New York contains a 23-hour day; it is still 31 days long.
expect(cycleAt('2026-03-15T00:00:00Z', 'America/New_York')).toBe(31);
});
23 changes: 21 additions & 2 deletions examples/dummy/packages/core/src/billing.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/**
* Plan upgrade arithmetic. Everything here is integer minor units with an attached currency;
* nothing in this file formats, and nothing converts between currencies.
* Plan upgrade arithmetic, and the billing period it prorates against. Everything here is integer
* minor units with an attached currency; nothing in this file formats, and nothing converts
* between currencies.
*/

import { type BillingCurrency, isUpgrade, type PlanCode, priceOf, seatLimit } from '@postly/domain';
import { isNegative, type Money, multiply, subtract, zero } from '@ultimat3/money';
import { addMs, fromZoned, type Instant, type TimeZone, toZoned } from '@ultimat3/time';
import { NotAnUpgrade, SeatsExceeded } from './errors';

export type UpgradeQuoteInput = {
Expand Down Expand Up @@ -49,6 +51,23 @@ export const quoteUpgrade = (input: UpgradeQuoteInput): UpgradeQuote => {
};
};

/**
* A billing period is one calendar month in the org's zone, and it ends at the last millisecond of
* its last local day — not at the first of the next, so `daysRemaining` never borrows a day from
* the following cycle. `periodOffset` walks whole periods: `-1` is the period before `at`.
*
* Built from local calendar fields and converted once, because a month is 28..31 days and a day is
* 23, 24 or 25 hours — neither is a constant to multiply by.
*/
export const endOfBillingPeriod = (at: Instant, zone: TimeZone, periodOffset = 0): Instant => {
const local = toZoned(at, zone);
const nextPeriodStart = fromZoned(
{ year: local.year, month: local.month + periodOffset + 1, day: 1, hour: 0, minute: 0 },
zone,
);
return addMs(nextPeriodStart, -1);
};

/** Called before an invite is written, not after — the seat limit is a plan promise, not advice. */
export const assertSeatsAvailable = (plan: PlanCode, currentMembers: number): void => {
const limit = seatLimit(plan);
Expand Down
2 changes: 1 addition & 1 deletion examples/dummy/packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** The public surface of @postly/core. Explicit — never `export *`. */

export type { UpgradeQuote, UpgradeQuoteInput } from './billing';
export { assertSeatsAvailable, quoteUpgrade, seatsRemaining } from './billing';
export { assertSeatsAvailable, endOfBillingPeriod, quoteUpgrade, seatsRemaining } from './billing';
export { localDateIn, nextDigestAt, scheduleByZone } from './digest-schedule';
export { CoreError, NotAnUpgrade, SeatsExceeded } from './errors';
export type { Actor, ActorLike, OwnedRecord } from './membership';
Expand Down
4 changes: 3 additions & 1 deletion examples/dummy/packages/i18n/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
## Conventions

- Namespace by feature: `site.*`, `app.*`, `posts.*`, `orgs.*`, `plans.*`, `digest.*`, `errors.*`.
- Plurals are `{ one, other }` objects; call sites pass `{ count }` and never branch themselves.
- Plurals are underscore suffixes on the leaf — `likes_one`, `likes_other` — never a nested
`{ one, other }` branch, which the runtime never probes. Call sites pass `t('app.post.likes',
{ count })` and never branch themselves.
- Interpolation slots are `{name}` and are typed — a missing slot fails typecheck.

## Gotchas
Expand Down
3 changes: 2 additions & 1 deletion examples/dummy/packages/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ errors.* user-facing text for the error codes a user can actually cause
## Plurals and interpolation

```json
"likes": { "one": "{count} like", "other": "{count} likes" }
"likes_one": "{count} like",
"likes_other": "{count} likes"
```

Call it as `t('app.post.likes', { count })`. The category is chosen by `Intl.PluralRules` for the
Expand Down
Loading
Loading