Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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