From c826272d9135e25cea3661ba32862d33af3f9fac Mon Sep 17 00:00:00 2001 From: sebi Date: Fri, 9 Oct 2026 09:28:13 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20the=20directory=20form=20is=20?= =?UTF-8?q?the=20one=20layout=20=E2=80=94=20X=5FLAYOUT=5FSIBLING=5FMODULE;?= =?UTF-8?q?=20both=20tracked=20apps=20migrated=20(#710=20row=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x verify's boundaries step refuses a module X.ts beside a directory X/. examples/dummy's and the demo's flat actions/jobs/live/mutator files are split one primitive per file into actions/, live/, queries/, jobs/ and tasks/, registered module by module; docs say the directory form is the one layout. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- README.md | 4 +- docs/architecture/12-generated-app.md | 6 +- docs/architecture/15-adding-a-feature.md | 2 +- .../.claude/agents/web-dev.md | 2 +- .../.claude/commands/planx.md | 2 +- dummy/social-media-clone/apps/web/README.md | 5 +- .../social-media-clone/apps/web/api/tasks.ts | 11 +- .../apps/web/app/friends/actions.ts | 102 ------- .../web/app/friends/actions/block-user.ts | 23 ++ .../web/app/friends/actions/request-friend.ts | 25 ++ .../web/app/friends/actions/respond-friend.ts | 39 +++ .../web/app/friends/actions/unblock-user.ts | 36 +++ .../apps/web/app/friends/views.ts | 32 +++ .../apps/web/app/messages/action.test.ts | 2 +- .../{action.ts => actions/send-message.ts} | 6 +- .../apps/web/app/messages/live.test.ts | 2 +- .../messages/{live.ts => live/live-thread.ts} | 6 +- .../apps/web/app/messages/topics.ts | 2 +- .../mark-notifications-read.ts} | 4 +- .../web/app/notifications/mutator.test.ts | 2 +- .../apps/web/app/notifications/page.tsx | 2 +- .../{live.ts => queries/inbox.ts} | 8 +- .../apps/web/app/notifications/topics.ts | 2 +- .../apps/web/app/tasks/errors.ts | 2 +- .../apps/web/app/tasks/instant.ts | 11 + .../apps/web/app/tasks/jobs.test.ts | 3 +- .../apps/web/app/tasks/jobs/reset-demo.ts | 44 +++ .../{jobs.ts => jobs/sweep-orphan-media.ts} | 50 +--- .../apps/web/app/tasks/schedule.test.ts | 3 +- .../web/app/tasks/tasks/hourly-demo-reset.ts | 24 ++ .../hourly-media-sweep.ts} | 19 +- examples/dummy/CLAUDE.md | 17 +- examples/dummy/README.md | 50 ++-- examples/dummy/apps/web/api/index.ts | 183 ++++++++---- .../dummy/apps/web/app/auth/actions.test.ts | 2 +- .../{actions.ts => actions/end-session.ts} | 2 +- .../{actions.ts => actions/contact-sales.ts} | 4 +- .../{jobs.ts => jobs/send-sales-receipt.ts} | 2 +- .../apps/web/app/digest/jobs.job.test.ts | 3 +- .../web/app/digest/jobs/deliver-digest.ts | 90 ++++++ .../digest/{jobs.ts => jobs/send-digest.ts} | 106 ++----- .../digest/tasks/nightly-digest.ts} | 25 +- examples/dummy/apps/web/app/feed/live.ts | 2 +- examples/dummy/apps/web/app/layout.tsx | 4 +- .../dummy/apps/web/app/mcp/jobs.job.test.ts | 2 +- .../purge-mcp-confirmations.ts} | 2 +- .../apps/web/app/mcp/tasks/hourly-purge.ts | 18 ++ examples/dummy/apps/web/app/models.test.ts | 3 +- .../web/app/orgs/actions.contract.test.ts | 2 +- examples/dummy/apps/web/app/orgs/actions.ts | 89 ------ .../app/orgs/actions/grant-avatar-upload.ts | 27 ++ .../web/app/orgs/actions/invite-member.ts | 32 +++ .../web/app/orgs/actions/member-avatar.ts | 22 ++ .../apps/web/app/orgs/actions/upgrade-plan.ts | 26 ++ .../dummy/apps/web/app/orgs/jobs.job.test.ts | 7 +- .../app/orgs/{jobs.ts => jobs/onboard-org.ts} | 40 +-- .../apps/web/app/orgs/jobs/send-invite.ts | 35 +++ examples/dummy/apps/web/app/orgs/service.ts | 2 +- .../dummy/apps/web/app/posts/[id]/page.tsx | 2 +- .../web/app/posts/actions.contract.test.ts | 6 +- .../dummy/apps/web/app/posts/actions.test.ts | 6 +- examples/dummy/apps/web/app/posts/actions.ts | 266 ------------------ .../web/app/posts/actions/create-comment.ts | 42 +++ .../apps/web/app/posts/actions/create-post.ts | 27 ++ .../app/posts/actions/keep-draft-review.ts | 27 ++ .../apps/web/app/posts/actions/like-post.ts | 35 +++ .../move-post-status.ts} | 52 ++-- .../web/app/posts/actions/publish-post.ts | 49 ++++ .../app/posts/actions/request-draft-review.ts | 32 +++ .../app/posts/actions/request-posts-export.ts | 32 +++ .../web/app/posts/actions/review-draft.ts | 48 ++++ .../web/app/posts/actions/summarize-posts.ts | 41 +++ .../apps/web/app/posts/actions/summarize.ts | 40 +++ .../dummy/apps/web/app/posts/channel-ref.ts | 4 +- .../dummy/apps/web/app/posts/jobs.job.test.ts | 5 +- .../{notifiers.ts => jobs/comment-posted.ts} | 4 +- .../apps/web/app/posts/jobs/export-posts.ts | 46 +++ .../{jobs.ts => jobs/notify-subscribers.ts} | 58 +--- .../web/app/posts/jobs/review-draft-later.ts | 25 ++ .../dummy/apps/web/app/posts/like-mutation.ts | 4 +- .../apps/web/app/posts/live.live.test.ts | 4 +- .../apps/web/app/posts/live.page.test.ts | 2 +- examples/dummy/apps/web/app/posts/live.ts | 218 -------------- .../apps/web/app/posts/live/live-feed.ts | 47 ++++ examples/dummy/apps/web/app/posts/mail.ts | 2 +- .../web/app/posts/mcp-drive.contract.test.ts | 3 +- .../dummy/apps/web/app/posts/mutator.test.ts | 3 +- .../apps/web/app/posts/notifiers.job.test.ts | 4 +- .../web/app/posts/post-by-id.contract.test.ts | 2 +- .../web/app/posts/prompts/review-draft.ts | 2 +- .../apps/web/app/posts/prompts/summarize.ts | 4 +- .../app/posts/public-slugs.contract.test.ts | 2 +- .../web/app/posts/queries/feed-activity.ts | 24 ++ .../apps/web/app/posts/queries/org-posts.ts | 22 ++ .../apps/web/app/posts/queries/post-by-id.ts | 22 ++ .../web/app/posts/queries/post-by-slug.ts | 42 +++ .../apps/web/app/posts/queries/post-record.ts | 59 ++++ .../apps/web/app/posts/queries/post-review.ts | 43 +++ .../app/posts/queries/public-post-slugs.ts | 22 ++ .../apps/web/app/posts/queries/public-post.ts | 46 +++ .../web/app/posts/queries/public-posts.ts | 46 +++ examples/dummy/apps/web/app/posts/repo.ts | 8 +- .../apps/web/app/posts/review.job.test.ts | 4 +- .../dummy/apps/web/app/runs/actions.test.ts | 14 +- examples/dummy/apps/web/app/runs/actions.ts | 94 ------- .../web/app/runs/actions/answer-prompt.ts | 37 +++ .../apps/web/app/runs/actions/cancel-run.ts | 25 ++ .../apps/web/app/runs/actions/connect-site.ts | 24 ++ .../web/app/runs/actions/issue-run-key.ts | 24 ++ .../web/app/runs/actions/revoke-run-key.ts | 23 ++ .../apps/web/app/runs/actions/start-run.ts | 28 ++ examples/dummy/apps/web/app/runs/jobs.test.ts | 2 +- .../runs/{jobs.ts => jobs/sync-connection.ts} | 16 +- .../dummy/apps/web/app/runs/live.live.test.ts | 2 +- examples/dummy/apps/web/app/runs/live.test.ts | 3 +- .../runs/{live.ts => live/live-run-events.ts} | 42 +-- .../web/app/runs/queries/run-connections.ts | 33 +++ .../apps/web/app/runs/runs.contract.test.ts | 4 +- .../dummy/apps/web/app/runs/runs.job.test.ts | 2 +- examples/dummy/apps/web/app/runs/runs.test.ts | 5 +- examples/dummy/apps/web/app/runs/service.ts | 2 +- .../web/app/settings/actions.contract.test.ts | 2 +- .../dummy/apps/web/app/settings/actions.ts | 63 ----- .../app/settings/actions/save-preferences.ts | 44 +++ .../web/app/settings/actions/set-theme.ts | 48 ++++ .../app/settings/actions/subscribe-push.ts | 29 ++ .../settings/actions/toggle-digest-opt-in.ts | 40 +++ .../app/settings/actions/unsubscribe-push.ts | 24 ++ .../apps/web/app/settings/mutator.test.ts | 3 +- .../dummy/apps/web/app/settings/mutator.ts | 76 ----- .../dummy/apps/web/app/webhooks/actions.ts | 37 --- .../webhooks/actions/add-webhook-endpoint.ts | 23 ++ .../actions/remove-webhook-endpoint.ts | 23 ++ .../apps/web/app/webhooks/endpoints.test.ts | 3 +- .../apps/web/app/webhooks/jobs.job.test.ts | 6 +- .../post-published-webhook.ts} | 6 +- .../dummy/apps/web/app/webhooks/service.ts | 2 +- examples/dummy/apps/web/shared/services.ts | 4 +- examples/dummy/apps/web/site/pricing/page.tsx | 2 +- framework.manifest.json | 7 +- packages/ai/README.md | 2 +- packages/cli/src/app-layout.test.ts | 39 +++ packages/cli/src/app-layout.ts | 35 +++ packages/cli/src/boundary-findings.test.ts | 15 +- packages/cli/src/boundary-findings.ts | 2 + packages/cli/src/error-codes.ts | 2 + packages/cli/src/mcp-errors.ts | 2 + scripts/primitive-factories-used.test.ts | 2 +- scripts/set-cookie-literals.test.ts | 4 +- scripts/wiki-fences-backlog.ts | 4 +- wiki/Actions.md | 9 +- wiki/Agents.md | 2 +- wiki/Client-Data.md | 8 +- wiki/Error-Codes.md | 1 + wiki/Policies-And-Authz.md | 2 +- wiki/Project-Layout.md | 8 +- 157 files changed, 2240 insertions(+), 1485 deletions(-) delete mode 100644 dummy/social-media-clone/apps/web/app/friends/actions.ts create mode 100644 dummy/social-media-clone/apps/web/app/friends/actions/block-user.ts create mode 100644 dummy/social-media-clone/apps/web/app/friends/actions/request-friend.ts create mode 100644 dummy/social-media-clone/apps/web/app/friends/actions/respond-friend.ts create mode 100644 dummy/social-media-clone/apps/web/app/friends/actions/unblock-user.ts create mode 100644 dummy/social-media-clone/apps/web/app/friends/views.ts rename dummy/social-media-clone/apps/web/app/messages/{action.ts => actions/send-message.ts} (95%) rename dummy/social-media-clone/apps/web/app/messages/{live.ts => live/live-thread.ts} (95%) rename dummy/social-media-clone/apps/web/app/notifications/{mutator.ts => actions/mark-notifications-read.ts} (97%) rename dummy/social-media-clone/apps/web/app/notifications/{live.ts => queries/inbox.ts} (74%) create mode 100644 dummy/social-media-clone/apps/web/app/tasks/instant.ts create mode 100644 dummy/social-media-clone/apps/web/app/tasks/jobs/reset-demo.ts rename dummy/social-media-clone/apps/web/app/tasks/{jobs.ts => jobs/sweep-orphan-media.ts} (50%) create mode 100644 dummy/social-media-clone/apps/web/app/tasks/tasks/hourly-demo-reset.ts rename dummy/social-media-clone/apps/web/app/tasks/{schedule.ts => tasks/hourly-media-sweep.ts} (69%) rename examples/dummy/apps/web/app/auth/{actions.ts => actions/end-session.ts} (97%) rename examples/dummy/apps/web/app/contact/{actions.ts => actions/contact-sales.ts} (96%) rename examples/dummy/apps/web/app/contact/{jobs.ts => jobs/send-sales-receipt.ts} (98%) create mode 100644 examples/dummy/apps/web/app/digest/jobs/deliver-digest.ts rename examples/dummy/apps/web/app/digest/{jobs.ts => jobs/send-digest.ts} (53%) rename examples/dummy/apps/web/{api/tasks.ts => app/digest/tasks/nightly-digest.ts} (56%) rename examples/dummy/apps/web/app/mcp/{jobs.ts => jobs/purge-mcp-confirmations.ts} (94%) create mode 100644 examples/dummy/apps/web/app/mcp/tasks/hourly-purge.ts delete mode 100644 examples/dummy/apps/web/app/orgs/actions.ts create mode 100644 examples/dummy/apps/web/app/orgs/actions/grant-avatar-upload.ts create mode 100644 examples/dummy/apps/web/app/orgs/actions/invite-member.ts create mode 100644 examples/dummy/apps/web/app/orgs/actions/member-avatar.ts create mode 100644 examples/dummy/apps/web/app/orgs/actions/upgrade-plan.ts rename examples/dummy/apps/web/app/orgs/{jobs.ts => jobs/onboard-org.ts} (53%) create mode 100644 examples/dummy/apps/web/app/orgs/jobs/send-invite.ts delete mode 100644 examples/dummy/apps/web/app/posts/actions.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/create-comment.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/create-post.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/keep-draft-review.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/like-post.ts rename examples/dummy/apps/web/app/posts/{mutator.ts => actions/move-post-status.ts} (55%) create mode 100644 examples/dummy/apps/web/app/posts/actions/publish-post.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/request-draft-review.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/request-posts-export.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/review-draft.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/summarize-posts.ts create mode 100644 examples/dummy/apps/web/app/posts/actions/summarize.ts rename examples/dummy/apps/web/app/posts/{notifiers.ts => jobs/comment-posted.ts} (96%) create mode 100644 examples/dummy/apps/web/app/posts/jobs/export-posts.ts rename examples/dummy/apps/web/app/posts/{jobs.ts => jobs/notify-subscribers.ts} (62%) create mode 100644 examples/dummy/apps/web/app/posts/jobs/review-draft-later.ts delete mode 100644 examples/dummy/apps/web/app/posts/live.ts create mode 100644 examples/dummy/apps/web/app/posts/live/live-feed.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/feed-activity.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/org-posts.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/post-by-id.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/post-by-slug.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/post-record.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/post-review.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/public-post-slugs.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/public-post.ts create mode 100644 examples/dummy/apps/web/app/posts/queries/public-posts.ts delete mode 100644 examples/dummy/apps/web/app/runs/actions.ts create mode 100644 examples/dummy/apps/web/app/runs/actions/answer-prompt.ts create mode 100644 examples/dummy/apps/web/app/runs/actions/cancel-run.ts create mode 100644 examples/dummy/apps/web/app/runs/actions/connect-site.ts create mode 100644 examples/dummy/apps/web/app/runs/actions/issue-run-key.ts create mode 100644 examples/dummy/apps/web/app/runs/actions/revoke-run-key.ts create mode 100644 examples/dummy/apps/web/app/runs/actions/start-run.ts rename examples/dummy/apps/web/app/runs/{jobs.ts => jobs/sync-connection.ts} (96%) rename examples/dummy/apps/web/app/runs/{live.ts => live/live-run-events.ts} (50%) create mode 100644 examples/dummy/apps/web/app/runs/queries/run-connections.ts delete mode 100644 examples/dummy/apps/web/app/settings/actions.ts create mode 100644 examples/dummy/apps/web/app/settings/actions/save-preferences.ts create mode 100644 examples/dummy/apps/web/app/settings/actions/set-theme.ts create mode 100644 examples/dummy/apps/web/app/settings/actions/subscribe-push.ts create mode 100644 examples/dummy/apps/web/app/settings/actions/toggle-digest-opt-in.ts create mode 100644 examples/dummy/apps/web/app/settings/actions/unsubscribe-push.ts delete mode 100644 examples/dummy/apps/web/app/settings/mutator.ts delete mode 100644 examples/dummy/apps/web/app/webhooks/actions.ts create mode 100644 examples/dummy/apps/web/app/webhooks/actions/add-webhook-endpoint.ts create mode 100644 examples/dummy/apps/web/app/webhooks/actions/remove-webhook-endpoint.ts rename examples/dummy/apps/web/app/webhooks/{jobs.ts => jobs/post-published-webhook.ts} (95%) create mode 100644 packages/cli/src/app-layout.test.ts create mode 100644 packages/cli/src/app-layout.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c159d66..7cace0356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ## [Unreleased] -Nothing yet. +### Added + +Tier 5 — cli. Both tracked apps. + +- **The generator's directory form is the one layout (#710, owner decision 10).** One primitive per file, in its kind's directory — `/actions/.ts`, `live/`, `queries/`, `jobs/`, `tasks/` — what `x g` writes. `x verify`'s `boundaries` step now refuses a module `X.ts` beside a directory `X/` in an app's surfaces (`X_LAYOUT_SIBLING_MODULE`): `./actions` resolves to the file and never the directory, so the two homes disagree silently. Both tracked apps migrated: `examples/dummy`'s 56 primitives and the demo's 11 each in their own file (`api/tasks.ts`'s two tasks moved into their slices' `tasks/`), registered module by module in `api/index.ts`. `docs/architecture/12-generated-app.md`, `wiki/Project-Layout.md` and `wiki/Actions.md` say so. An app that keeps `actions.ts` with no `actions/` beside it is not refused. ## 27.2.3 - 2026-10-09 diff --git a/README.md b/README.md index 20f343340..05936858a 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ An agent's scarcest resource is context, and most of it goes on infrastructure t |---|---| | everything the author owns, 218 files | **9,712** code lines | | production only, tests excluded | **7,230** | -| a fully-projected endpoint — input schema, output schema, policy, OpenAPI operation, MCP tool, typed client | **~16 code lines**, from 4 actions in [`app/friends/actions.ts`](dummy/social-media-clone/apps/web/app/friends/actions.ts) | +| a fully-projected endpoint — input schema, output schema, policy, OpenAPI operation, MCP tool, typed client | **~16 code lines**, from 4 actions in [`app/friends/actions/`](dummy/social-media-clone/apps/web/app/friends/actions/) | | one action, 51 code lines | **179 lines** of committed generated interface — OpenAPI operation, two JSON Schemas, the manifest row — plus five handles it never declares | Re-derive it — `node_modules`, `.x/`, `dist/` and `coverage/` are gitignored, so `git ls-files` never enters them: @@ -353,7 +353,7 @@ The 1,767 generated Lucide glyph files are excluded on purpose — leaving them | that the demo passes its own gate | it is **pinned red on 2 steps**, `As of 2026-09-23` — `boundaries` (`X_BOUNDARY_SITE_TO_APP` ×3, the static feed importing the authed post service) and `budgets` (`X_BUDGET_UNMEASURED`, no `.x/build-stats.json` has ever existed there). It was 3: `drift` came off when the migration reconciling its foreign keys landed. [`examples/dummy`](examples/dummy/README.md) is pinned on 1 step — `budgets`. The pins and their reasons are [`scripts/lib/gated-apps.ts`](scripts/lib/gated-apps.ts); `bun run scripts/reference-app-gate.ts` re-derives them | | that low lines means high leverage | partly it means **few features**. Roughly half of [`DOMAIN.md`](dummy/social-media-clone/DOMAIN.md) is a plan, not a build: `likes` and `comments` are entities with migrations and no write path | | that the framework wrote the auth | it did not. 13 non-test files hand-write argon2id parameters, `__Host-` cookie prefixes, session token hashing and a captcha, and **`@ultimat3/auth` is imported nowhere in that app**. `@ultimat3/storage` likewise, despite a media feature. The largest thing the framework could have projected and did not | -| that live messaging works | the thread's live query ([`app/messages/live.ts`](dummy/social-media-clone/apps/web/app/messages/live.ts)) and its channel ([`app/messages/topics.ts`](dummy/social-media-clone/apps/web/app/messages/topics.ts)) are declared and tested against a real hub, and **no island subscribes to either** — there is no `useChannel` or `useQuery` in the app — and the deployed stack runs no `sync` role, `As of 2026-09-23` | +| that live messaging works | the thread's live query ([`app/messages/live/`](dummy/social-media-clone/apps/web/app/messages/live/)) and its channel ([`app/messages/topics.ts`](dummy/social-media-clone/apps/web/app/messages/topics.ts)) are declared and tested against a real hub, and **no island subscribes to either** — there is no `useChannel` or `useQuery` in the app — and the deployed stack runs no `sync` role, `As of 2026-09-23` | | that the typed client is proven | it is projected and unused. There is **no `.client()` call in either tracked app**; the demo's forms post HTML | **The larger win is not the lines** — it is that a bug is found once, here, where the fix reaches every app at once. The sweeps in [`CHANGELOG.md`](CHANGELOG.md) closed defects of exactly that kind: diff --git a/docs/architecture/12-generated-app.md b/docs/architecture/12-generated-app.md index 1ee4c2419..ae64b5941 100644 --- a/docs/architecture/12-generated-app.md +++ b/docs/architecture/12-generated-app.md @@ -120,10 +120,10 @@ apps/web/app//page.tsx the route, at the plural URL | `queries/` | one `query` per file, not subscribable | writes | | `jobs/` | one `job` per file | inline slow work in an action | | `tasks/` | one `task` per file — a cron trigger that enqueues a job | doing the work itself | -| `ui.tsx`, `ui/` | Solid components | fetching, business logic, its own authz | +| `ui.tsx` or `ui/` | Solid components | fetching, business logic, its own authz | | `admin/resource.ts` | list columns, title key, page size | a second authz path | -`x g` writes one primitive per file: `actions/-post.ts`, `live/.ts`, `queries/.ts`, `jobs/-post.ts`, `tasks/.ts`. A primitive is registered by `defineApi()` and found by the module scan, never by its filename, so a feature may equally keep several declarations in one file — `examples/dummy` keeps one `actions.ts` per feature — and nothing enforces either layout ([Project layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface) names the filenames that ARE enforced). The flat files are the ones there is exactly one of per feature. +`x g` writes one primitive per file: `actions/-post.ts`, `live/.ts`, `queries/.ts`, `jobs/-post.ts`, `tasks/.ts`, each named for its export in kebab case. **That directory form is the one layout** (`As of 2026-10`, owner decision 10 of #648): both tracked apps use it, and a module `X.ts` beside a directory `X/` is refused — `X_LAYOUT_SIBLING_MODULE`, in the `boundaries` step — because `./actions` resolves to the file and never the directory. A factory's kind decides its directory: a mutator, a `transition()` and an `llm()`/`agent()`/`hive()` call are actions; a `backfill()` keeps `backfills/`, and every other job factory is a job. A primitive is registered by `defineApi()` and found by the module scan, never by its filename ([Project layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface) names the filenames that ARE enforced). The flat files are the ones there is exactly one of per feature. The folder is the name passed to `x g resource`; the route is its plural, because a collection URL is plural — `x g resource post` writes the slice to `apps/web/app/post/` and the page to `apps/web/app/posts/page.tsx`. @@ -152,7 +152,7 @@ this file a route?" mechanically decidable from the filename alone. **`api/` holds the action and query projections, and no route file.** An action, mutator, query, job or task reaches HTTP through `defineApi()`, not through a filename: those modules keep whatever -name their feature gives them (`app/posts/actions.ts`, `app/digest/jobs.ts`, …) and +name their feature gives them (`app/posts/actions/create-post.ts`, `app/digest/jobs/send-digest.ts`, …) and `apps/web/api/index.ts` collects them into one `defineApi()` call, which projects the HTTP routes, `openapi.json`, the typed client and the job handles. diff --git a/docs/architecture/15-adding-a-feature.md b/docs/architecture/15-adding-a-feature.md index ae97f2958..9481593a2 100644 --- a/docs/architecture/15-adding-a-feature.md +++ b/docs/architecture/15-adding-a-feature.md @@ -43,7 +43,7 @@ x g resource post --admin Generates schema, entity, repo, service, policy, actions, live query, job stub, components, the form island, the `posts` page, i18n keys, admin screens, MCP entries, and the tests beside each. The page is reachable as written: its `load` reads the list through the slice's query as the request's actor, `AsyncRegion` renders ready, empty and refused, and the form island posts to `createPost`. `x g` ends by printing what the new table owes (`x db gen`, `x db migrate`) and the build that weighs the page against its budget. Everything below is *editing* generated files — not creating them. -`x g` writes one primitive per file: `actions/-post.ts`, `live/.ts`, `queries/.ts`, `jobs/-post.ts`, `tasks/.ts`. A primitive is registered by `defineApi()` and found by the module scan, never by its filename, so a feature may equally keep several declarations in one file — `examples/dummy` keeps one `actions.ts` per feature — and nothing enforces either layout ([Project layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface) names the filenames that ARE enforced). `x g resource ` takes no `--feature`: the name is the slice (`X_CLI_BAD_FLAG`). +`x g` writes one primitive per file: `actions/-post.ts`, `live/.ts`, `queries/.ts`, `jobs/-post.ts`, `tasks/.ts`. That directory form is the one layout: a second primitive is a second file, and a module `X.ts` beside a directory `X/` is `X_LAYOUT_SIBLING_MODULE` ([Project layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface) names the filenames that ARE enforced). `x g resource ` takes no `--feature`: the name is the slice (`X_CLI_BAD_FLAG`). ## 2. Entity + invariants diff --git a/dummy/social-media-clone/.claude/agents/web-dev.md b/dummy/social-media-clone/.claude/agents/web-dev.md index 3c695aa98..36f87de91 100644 --- a/dummy/social-media-clone/.claude/agents/web-dev.md +++ b/dummy/social-media-clone/.claude/agents/web-dev.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Bash, Grep, Glob You own `apps/web/site/**`, `apps/web/app/**/page.tsx`, `apps/web/app/**/ui/**`, `*.module.scss`, `apps/web/shared/tokens.scss` and `packages/i18n/catalogs/**`. You do **not** own `policy.ts`, -`actions.ts`, `live.ts`, `jobs.ts`, `repo.ts` or `packages/db` — stop and report if you need them. +`actions/`, `live/`, `queries/`, `jobs/`, `tasks/`, `repo.ts` or `packages/db` — stop and report if you need them. **The surface decides the rules.** diff --git a/dummy/social-media-clone/.claude/commands/planx.md b/dummy/social-media-clone/.claude/commands/planx.md index 141477083..449f0e5b2 100644 --- a/dummy/social-media-clone/.claude/commands/planx.md +++ b/dummy/social-media-clone/.claude/commands/planx.md @@ -39,7 +39,7 @@ One or two sentences: what, and why. ## Context - Only the facts the executor needs. -- Pattern to follow: `apps/web/app/posts/actions.ts:33` — copy this shape. +- Pattern to follow: `apps/web/app/friends/actions/request-friend.ts` — copy this shape. ## Slices (execute in order) 1. [`01-.md`](01-.md) — one line, and the exclusive file set it owns. diff --git a/dummy/social-media-clone/apps/web/README.md b/dummy/social-media-clone/apps/web/README.md index a5ac53e02..49bba7df6 100644 --- a/dummy/social-media-clone/apps/web/README.md +++ b/dummy/social-media-clone/apps/web/README.md @@ -16,8 +16,9 @@ applies migrations and exits. **The directory is the URL.** `app/posts/[id]/page.tsx` is `/posts/:id`. The filename never contributes — `page.tsx` on `site/`/`app/`, `route.ts` on `api/`, and nothing else is a route. -A feature slice is one directory holding `entity.ts`, `policy.ts`, `actions.ts`, `mutator.ts`, -`live.ts`, `jobs.ts`, `service.ts`, `repo.ts`, `errors.ts` and `ui/`. Each file has one job, and +A feature slice is one directory holding `entity.ts`, `policy.ts`, `service.ts`, `repo.ts`, +`errors.ts`, `ui/`, and one primitive per file under `actions/`, `live/`, `queries/`, `jobs/` and +`tasks/` — the layout `x g` writes, and the only one (`X_LAYOUT_SIBLING_MODULE`). Each file has one job, and only `repo.ts` writes SQL. There is no `lib/`, `utils/` or `helpers/` — those names mean the code has no owner. diff --git a/dummy/social-media-clone/apps/web/api/tasks.ts b/dummy/social-media-clone/apps/web/api/tasks.ts index 159ed5ba5..baa9dd6cf 100644 --- a/dummy/social-media-clone/apps/web/api/tasks.ts +++ b/dummy/social-media-clone/apps/web/api/tasks.ts @@ -4,7 +4,12 @@ // `defineApi`'s own ordering, because a task descriptor lists the jobs it enqueues by name. import { defineApi } from '@ultimat3/action'; -import * as taskJobs from '../app/tasks/jobs'; -import * as taskSchedule from '../app/tasks/schedule'; +import * as resetDemo from '../app/tasks/jobs/reset-demo'; +import * as sweepOrphanMedia from '../app/tasks/jobs/sweep-orphan-media'; +import * as hourlyDemoReset from '../app/tasks/tasks/hourly-demo-reset'; +import * as hourlyMediaSweep from '../app/tasks/tasks/hourly-media-sweep'; -export const scheduledApi = defineApi({ jobs: taskJobs, tasks: taskSchedule }); +export const scheduledApi = defineApi({ + jobs: [sweepOrphanMedia, resetDemo], + tasks: [hourlyMediaSweep, hourlyDemoReset], +}); diff --git a/dummy/social-media-clone/apps/web/app/friends/actions.ts b/dummy/social-media-clone/apps/web/app/friends/actions.ts deleted file mode 100644 index 6e44fb781..000000000 --- a/dummy/social-media-clone/apps/web/app/friends/actions.ts +++ /dev/null @@ -1,102 +0,0 @@ -// The social graph's four commands. Declarations only — every body delegates to `./service`, so the -// same logic runs whether the caller is HTTP, the typed client, a job, an MCP tool or admin. -// -// `t` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package. Each -// `row:` loader is the async half authz is not allowed to have — it runs once per invocation, and -// the predicate that reads its result stays synchronous. - -import { FRIENDSHIP_STATUSES } from '@social-media-clone/domain'; -import { action, t } from '@ultimat3/action'; -import type { BlockRow, FriendshipRow, PersonRow } from './policy'; -import { blockCreate, blockDelete, friendRequest, friendRespond } from './policy'; -import { blockEdge, friendshipEdge, personById } from './repo'; -import { blockPerson, requestFriendship, respondToFriendship, unblockPerson } from './service'; - -/** The row shape every friendship command answers with. Derived from the entity's own columns. */ -const FriendshipView = t.object({ - requesterId: t.uuid, - addresseeId: t.uuid, - status: t.enumerated(...FRIENDSHIP_STATUSES), - respondedAt: t.nullable(t.date), - createdAt: t.date, -}); - -const BlockView = t.object({ blockerId: t.uuid, blockedId: t.uuid, createdAt: t.date }); - -/** - * The loader narrows to exactly what the rule reads. Handing the whole `User` row to a policy would - * put a bio and an email inside an authz decision, and a denial `reason` is allowed to name a - * permission but never row data. - */ -const personRow = async ({ input }: { input: { userId: string } }): Promise => { - const person = await personById(input.userId); - return person === null ? null : { id: person.id }; -}; - -export const requestFriend = action({ - input: t.object({ userId: t.uuid }), - output: FriendshipView, - policy: friendRequest, - // `null` here means "no such person", which is a denial and not a 404 later: the rule decides - // once, and a surface that passed no row cannot slip past it. - row: personRow, - idempotent: true, - mcp: { expose: true, description: 'Ask another user to be friends' }, - handle: ({ input, ctx }) => requestFriendship(ctx.actor.id, input.userId, ctx.now()), -}); - -export const respondFriend = action({ - // `decision` is a string, not a boolean: this action is reachable from a plain HTML form (the - // screen has no client JS), and a form sends "true", not `true`. A string the schema already - // constrains beats a boolean that only a JSON client can spell. - input: t.object({ requesterId: t.uuid, decision: t.enumerated('accept', 'decline') }), - output: FriendshipView, - policy: friendRespond, - /** - * Loaded by `addresseeId = the caller`, which is what makes "only the addressee may answer" - * structural as well as declared: a request addressed to somebody else resolves to `null` here - * and `friendRespond` denies on it, so the rule never has to trust the input's word for it. - */ - row: async ({ input, ctx }): Promise => { - const row = await friendshipEdge(input.requesterId, ctx.actor.id); - return row === null - ? null - : { requesterId: row.requesterId, addresseeId: row.addresseeId, status: row.status }; - }, - idempotent: true, - mcp: { expose: true, description: 'Accept or decline a friend request addressed to the caller' }, - handle: ({ input, ctx }) => - respondToFriendship(ctx.actor.id, input.requesterId, input.decision === 'accept', ctx.now()), -}); - -export const blockUser = action({ - input: t.object({ userId: t.uuid }), - output: BlockView, - policy: blockCreate, - row: personRow, - idempotent: true, - mcp: { expose: true, description: 'Block a user and decline any friendship between them' }, - handle: ({ input, ctx }) => blockPerson(ctx.actor.id, input.userId, ctx.now()), -}); - -/** - * The mirror of `blockUser`. The UI decides whether to render an "Unblock" control from - * `blockDelete` with this same row, and one decision both renders the button and answers the call. - * - * The output is the PAIR, not whether a row went: the action is idempotent, so a second submit — - * a double click, a retry, a queued MCP call — has to answer the same document as the first. - */ -export const unblockUser = action({ - input: t.object({ userId: t.uuid }), - output: t.object({ blockerId: t.uuid, blockedId: t.uuid }), - policy: blockDelete, - row: async ({ input, ctx }): Promise => { - const row = await blockEdge(ctx.actor.id, input.userId); - return row === null ? null : { blockerId: row.blockerId, blockedId: row.blockedId }; - }, - mcp: { expose: true, description: 'Lift a block this user placed' }, - handle: async ({ input, ctx }) => { - await unblockPerson(ctx.actor.id, input.userId); - return { blockerId: ctx.actor.id, blockedId: input.userId }; - }, -}); diff --git a/dummy/social-media-clone/apps/web/app/friends/actions/block-user.ts b/dummy/social-media-clone/apps/web/app/friends/actions/block-user.ts new file mode 100644 index 000000000..27b787c53 --- /dev/null +++ b/dummy/social-media-clone/apps/web/app/friends/actions/block-user.ts @@ -0,0 +1,23 @@ +// `blockUser`, the friends slice's action — one primitive per file, the layout `x g` writes. +// +// The social graph's four commands. Declarations only — every body delegates to `./service`, so the +// same logic runs whether the caller is HTTP, the typed client, a job, an MCP tool or admin. +// +// `t` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package. Each +// `row:` loader is the async half authz is not allowed to have — it runs once per invocation, and +// the predicate that reads its result stays synchronous. + +import { action, t } from '@ultimat3/action'; +import { blockCreate } from '../policy'; +import { blockPerson } from '../service'; +import { BlockView, personRow } from '../views'; + +export const blockUser = action({ + input: t.object({ userId: t.uuid }), + output: BlockView, + policy: blockCreate, + row: personRow, + idempotent: true, + mcp: { expose: true, description: 'Block a user and decline any friendship between them' }, + handle: ({ input, ctx }) => blockPerson(ctx.actor.id, input.userId, ctx.now()), +}); diff --git a/dummy/social-media-clone/apps/web/app/friends/actions/request-friend.ts b/dummy/social-media-clone/apps/web/app/friends/actions/request-friend.ts new file mode 100644 index 000000000..337ecee05 --- /dev/null +++ b/dummy/social-media-clone/apps/web/app/friends/actions/request-friend.ts @@ -0,0 +1,25 @@ +// `requestFriend`, the friends slice's action — one primitive per file, the layout `x g` writes. +// +// The social graph's four commands. Declarations only — every body delegates to `./service`, so the +// same logic runs whether the caller is HTTP, the typed client, a job, an MCP tool or admin. +// +// `t` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package. Each +// `row:` loader is the async half authz is not allowed to have — it runs once per invocation, and +// the predicate that reads its result stays synchronous. + +import { action, t } from '@ultimat3/action'; +import { friendRequest } from '../policy'; +import { requestFriendship } from '../service'; +import { FriendshipView, personRow } from '../views'; + +export const requestFriend = action({ + input: t.object({ userId: t.uuid }), + output: FriendshipView, + policy: friendRequest, + // `null` here means "no such person", which is a denial and not a 404 later: the rule decides + // once, and a surface that passed no row cannot slip past it. + row: personRow, + idempotent: true, + mcp: { expose: true, description: 'Ask another user to be friends' }, + handle: ({ input, ctx }) => requestFriendship(ctx.actor.id, input.userId, ctx.now()), +}); diff --git a/dummy/social-media-clone/apps/web/app/friends/actions/respond-friend.ts b/dummy/social-media-clone/apps/web/app/friends/actions/respond-friend.ts new file mode 100644 index 000000000..4f21b82c1 --- /dev/null +++ b/dummy/social-media-clone/apps/web/app/friends/actions/respond-friend.ts @@ -0,0 +1,39 @@ +// `respondFriend`, the friends slice's action — one primitive per file, the layout `x g` writes. +// +// The social graph's four commands. Declarations only — every body delegates to `./service`, so the +// same logic runs whether the caller is HTTP, the typed client, a job, an MCP tool or admin. +// +// `t` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package. Each +// `row:` loader is the async half authz is not allowed to have — it runs once per invocation, and +// the predicate that reads its result stays synchronous. + +import { action, t } from '@ultimat3/action'; +import type { FriendshipRow } from '../policy'; +import { friendRespond } from '../policy'; +import { friendshipEdge } from '../repo'; +import { respondToFriendship } from '../service'; +import { FriendshipView } from '../views'; + +export const respondFriend = action({ + // `decision` is a string, not a boolean: this action is reachable from a plain HTML form (the + // screen has no client JS), and a form sends "true", not `true`. A string the schema already + // constrains beats a boolean that only a JSON client can spell. + input: t.object({ requesterId: t.uuid, decision: t.enumerated('accept', 'decline') }), + output: FriendshipView, + policy: friendRespond, + /** + * Loaded by `addresseeId = the caller`, which is what makes "only the addressee may answer" + * structural as well as declared: a request addressed to somebody else resolves to `null` here + * and `friendRespond` denies on it, so the rule never has to trust the input's word for it. + */ + row: async ({ input, ctx }): Promise => { + const row = await friendshipEdge(input.requesterId, ctx.actor.id); + return row === null + ? null + : { requesterId: row.requesterId, addresseeId: row.addresseeId, status: row.status }; + }, + idempotent: true, + mcp: { expose: true, description: 'Accept or decline a friend request addressed to the caller' }, + handle: ({ input, ctx }) => + respondToFriendship(ctx.actor.id, input.requesterId, input.decision === 'accept', ctx.now()), +}); diff --git a/dummy/social-media-clone/apps/web/app/friends/actions/unblock-user.ts b/dummy/social-media-clone/apps/web/app/friends/actions/unblock-user.ts new file mode 100644 index 000000000..cb0297be6 --- /dev/null +++ b/dummy/social-media-clone/apps/web/app/friends/actions/unblock-user.ts @@ -0,0 +1,36 @@ +// `unblockUser`, the friends slice's action — one primitive per file, the layout `x g` writes. +// +// The social graph's four commands. Declarations only — every body delegates to `./service`, so the +// same logic runs whether the caller is HTTP, the typed client, a job, an MCP tool or admin. +// +// `t` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package. Each +// `row:` loader is the async half authz is not allowed to have — it runs once per invocation, and +// the predicate that reads its result stays synchronous. + +import { action, t } from '@ultimat3/action'; +import type { BlockRow } from '../policy'; +import { blockDelete } from '../policy'; +import { blockEdge } from '../repo'; +import { unblockPerson } from '../service'; + +/** + * The mirror of `blockUser`. The UI decides whether to render an "Unblock" control from + * `blockDelete` with this same row, and one decision both renders the button and answers the call. + * + * The output is the PAIR, not whether a row went: the action is idempotent, so a second submit — + * a double click, a retry, a queued MCP call — has to answer the same document as the first. + */ +export const unblockUser = action({ + input: t.object({ userId: t.uuid }), + output: t.object({ blockerId: t.uuid, blockedId: t.uuid }), + policy: blockDelete, + row: async ({ input, ctx }): Promise => { + const row = await blockEdge(ctx.actor.id, input.userId); + return row === null ? null : { blockerId: row.blockerId, blockedId: row.blockedId }; + }, + mcp: { expose: true, description: 'Lift a block this user placed' }, + handle: async ({ input, ctx }) => { + await unblockPerson(ctx.actor.id, input.userId); + return { blockerId: ctx.actor.id, blockedId: input.userId }; + }, +}); diff --git a/dummy/social-media-clone/apps/web/app/friends/views.ts b/dummy/social-media-clone/apps/web/app/friends/views.ts new file mode 100644 index 000000000..10ec29f88 --- /dev/null +++ b/dummy/social-media-clone/apps/web/app/friends/views.ts @@ -0,0 +1,32 @@ +// The friends slice's action outputs, shared by its actions: what a friendship and a block +// answer as, and the person row both name. + +import { FRIENDSHIP_STATUSES } from '@social-media-clone/domain'; +import { t } from '@ultimat3/action'; +import type { PersonRow } from './policy'; +import { personById } from './repo'; + +/** The row shape every friendship command answers with. Derived from the entity's own columns. */ +export const FriendshipView = t.object({ + requesterId: t.uuid, + addresseeId: t.uuid, + status: t.enumerated(...FRIENDSHIP_STATUSES), + respondedAt: t.nullable(t.date), + createdAt: t.date, +}); + +export const BlockView = t.object({ blockerId: t.uuid, blockedId: t.uuid, createdAt: t.date }); + +/** + * The loader narrows to exactly what the rule reads. Handing the whole `User` row to a policy would + * put a bio and an email inside an authz decision, and a denial `reason` is allowed to name a + * permission but never row data. + */ +export const personRow = async ({ + input, +}: { + input: { userId: string }; +}): Promise => { + const person = await personById(input.userId); + return person === null ? null : { id: person.id }; +}; diff --git a/dummy/social-media-clone/apps/web/app/messages/action.test.ts b/dummy/social-media-clone/apps/web/app/messages/action.test.ts index 022eed4c6..f9b0ef015 100644 --- a/dummy/social-media-clone/apps/web/app/messages/action.test.ts +++ b/dummy/social-media-clone/apps/web/app/messages/action.test.ts @@ -9,7 +9,7 @@ import { db } from '@social-media-clone/db'; import { ctxOf, isUltimateError, userActor } from '@ultimat3/core'; import type { Actor } from '@ultimat3/policy'; import { expect, unitTest } from '@ultimat3/testing'; -import { sendMessage } from './action'; +import { sendMessage } from './actions/send-message'; import * as repo from './repo'; const ADA = '00000000-0000-4000-8000-0000000000a1'; diff --git a/dummy/social-media-clone/apps/web/app/messages/action.ts b/dummy/social-media-clone/apps/web/app/messages/actions/send-message.ts similarity index 95% rename from dummy/social-media-clone/apps/web/app/messages/action.ts rename to dummy/social-media-clone/apps/web/app/messages/actions/send-message.ts index f4e589317..a62ea4045 100644 --- a/dummy/social-media-clone/apps/web/app/messages/action.ts +++ b/dummy/social-media-clone/apps/web/app/messages/actions/send-message.ts @@ -7,9 +7,9 @@ import { BODY_MAX } from '@social-media-clone/domain'; import { action, t } from '@ultimat3/action'; import { actorOf } from '@ultimat3/core'; -import * as notifications from '../notifications/repo'; -import { messageSend } from './policy'; -import * as repo from './repo'; +import * as notifications from '../../notifications/repo'; +import { messageSend } from '../policy'; +import * as repo from '../repo'; export const sendMessage = action({ input: t.object({ diff --git a/dummy/social-media-clone/apps/web/app/messages/live.test.ts b/dummy/social-media-clone/apps/web/app/messages/live.test.ts index 4a3cd9492..a52ebdfae 100644 --- a/dummy/social-media-clone/apps/web/app/messages/live.test.ts +++ b/dummy/social-media-clone/apps/web/app/messages/live.test.ts @@ -8,7 +8,7 @@ import { ctxOf, userActor } from '@ultimat3/core'; import type { Actor } from '@ultimat3/policy'; import { expect, liveTest, unitTest } from '@ultimat3/testing'; -import { liveThread } from './live'; +import { liveThread } from './live/live-thread'; import { THREAD_PAGE } from './repo'; const ROOM = '00000000-0000-4000-8000-0000000000f3'; diff --git a/dummy/social-media-clone/apps/web/app/messages/live.ts b/dummy/social-media-clone/apps/web/app/messages/live/live-thread.ts similarity index 95% rename from dummy/social-media-clone/apps/web/app/messages/live.ts rename to dummy/social-media-clone/apps/web/app/messages/live/live-thread.ts index bc52dea79..27bad358c 100644 --- a/dummy/social-media-clone/apps/web/app/messages/live.ts +++ b/dummy/social-media-clone/apps/web/app/messages/live/live-thread.ts @@ -13,9 +13,9 @@ // `t` comes from @ultimat3/query, not @ultimat3/schema: a query file imports one package. import { from, query, t } from '@ultimat3/query'; -import { threadRead } from './policy'; -import type { Message } from './repo'; -import * as repo from './repo'; +import { threadRead } from '../policy'; +import type { Message } from '../repo'; +import * as repo from '../repo'; export const liveThread = query({ input: t.object({ conversationId: t.uuid }), diff --git a/dummy/social-media-clone/apps/web/app/messages/topics.ts b/dummy/social-media-clone/apps/web/app/messages/topics.ts index 7a4972077..86c164c8f 100644 --- a/dummy/social-media-clone/apps/web/app/messages/topics.ts +++ b/dummy/social-media-clone/apps/web/app/messages/topics.ts @@ -7,7 +7,7 @@ import { schema } from '@social-media-clone/db'; import { channel } from '@ultimat3/realtime'; -import { liveThread } from './live'; +import { liveThread } from './live/live-thread'; import { threadRead } from './policy'; import * as repo from './repo'; diff --git a/dummy/social-media-clone/apps/web/app/notifications/mutator.ts b/dummy/social-media-clone/apps/web/app/notifications/actions/mark-notifications-read.ts similarity index 97% rename from dummy/social-media-clone/apps/web/app/notifications/mutator.ts rename to dummy/social-media-clone/apps/web/app/notifications/actions/mark-notifications-read.ts index b39d0de1d..95230139c 100644 --- a/dummy/social-media-clone/apps/web/app/notifications/mutator.ts +++ b/dummy/social-media-clone/apps/web/app/notifications/actions/mark-notifications-read.ts @@ -6,8 +6,8 @@ import { mutator, t } from '@ultimat3/action'; import { actorOf } from '@ultimat3/core'; -import { notificationMarkRead } from './policy'; -import * as repo from './repo'; +import { notificationMarkRead } from '../policy'; +import * as repo from '../repo'; /** * The local twin's row shape, keyed by the entity's table so the optimistic row and the server row diff --git a/dummy/social-media-clone/apps/web/app/notifications/mutator.test.ts b/dummy/social-media-clone/apps/web/app/notifications/mutator.test.ts index 009028a0a..586d8381b 100644 --- a/dummy/social-media-clone/apps/web/app/notifications/mutator.test.ts +++ b/dummy/social-media-clone/apps/web/app/notifications/mutator.test.ts @@ -10,7 +10,7 @@ import type { LocalTable, LocalTx } from '@ultimat3/action'; import { ctxOf, isUltimateError, userActor } from '@ultimat3/core'; import type { Actor } from '@ultimat3/policy'; import { expect, unitTest } from '@ultimat3/testing'; -import { markNotificationsRead } from './mutator'; +import { markNotificationsRead } from './actions/mark-notifications-read'; import * as repo from './repo'; import { unreadFor } from './service'; diff --git a/dummy/social-media-clone/apps/web/app/notifications/page.tsx b/dummy/social-media-clone/apps/web/app/notifications/page.tsx index 3c569f339..3c7bd1cd5 100644 --- a/dummy/social-media-clone/apps/web/app/notifications/page.tsx +++ b/dummy/social-media-clone/apps/web/app/notifications/page.tsx @@ -3,7 +3,7 @@ // `useMutation(markNotificationsRead)` and the badge is rendered once, on the server. // // The count is DERIVED (`readAt is null`), never stored and never decremented, which is the same -// property that makes the offline twin in `mutator.ts` safe to replay. +// property that makes the offline twin in `actions/mark-notifications-read.ts` safe to replay. import { actorOf, useContext } from '@ultimat3/core'; import { t } from '@ultimat3/i18n'; diff --git a/dummy/social-media-clone/apps/web/app/notifications/live.ts b/dummy/social-media-clone/apps/web/app/notifications/queries/inbox.ts similarity index 74% rename from dummy/social-media-clone/apps/web/app/notifications/live.ts rename to dummy/social-media-clone/apps/web/app/notifications/queries/inbox.ts index 85f164571..baa8cbc7a 100644 --- a/dummy/social-media-clone/apps/web/app/notifications/live.ts +++ b/dummy/social-media-clone/apps/web/app/notifications/queries/inbox.ts @@ -1,13 +1,13 @@ // The inbox, as a declared read — what the inbox channel's client re-reads after a `replay-gap`. // Same page `inboxFor` renders, `(createdAt desc, id)` so the order is total, and the bound is a -// constant for the reason `messages/live.ts` gives: a client-chosen bound is a client-chosen budget. +// constant for the reason `messages/live/` gives: a client-chosen bound is a client-chosen budget. // // `t` comes from @ultimat3/query, not @ultimat3/schema: a query file imports one package. import { from, query, t } from '@ultimat3/query'; -import { notificationRead } from './policy'; -import type { Notification } from './repo'; -import * as repo from './repo'; +import { notificationRead } from '../policy'; +import type { Notification } from '../repo'; +import * as repo from '../repo'; export const inbox = query({ input: t.object({ userId: t.uuid }), diff --git a/dummy/social-media-clone/apps/web/app/notifications/topics.ts b/dummy/social-media-clone/apps/web/app/notifications/topics.ts index 9c145e0e5..38828882a 100644 --- a/dummy/social-media-clone/apps/web/app/notifications/topics.ts +++ b/dummy/social-media-clone/apps/web/app/notifications/topics.ts @@ -7,7 +7,7 @@ import { schema } from '@social-media-clone/db'; import { can } from '@ultimat3/policy'; import { channel } from '@ultimat3/realtime'; -import { inbox } from './live'; +import { inbox } from './queries/inbox'; /** * Your own inbox and nobody else's. An admin is not exempted: `admin:read` is a moderation grant diff --git a/dummy/social-media-clone/apps/web/app/tasks/errors.ts b/dummy/social-media-clone/apps/web/app/tasks/errors.ts index bd2cfb929..806d76465 100644 --- a/dummy/social-media-clone/apps/web/app/tasks/errors.ts +++ b/dummy/social-media-clone/apps/web/app/tasks/errors.ts @@ -32,7 +32,7 @@ export class DemoResetUnsafeError extends UltimateError { super({ code: 'X_DEMO_RESET_UNSAFE', cause: `this database does not hold the demo's seeded accounts (missing ${init.missing.join(', ')}), so it is not the demo's — and resetDemo deletes every post, comment, upload, message and notification before re-seeding`, - fix: 'delete hourlyDemoReset from apps/web/app/tasks/schedule.ts if DATABASE_URL points at a database whose rows matter; if this IS the demo, seed it with `bun run packages/db/src/seed.ts` and the next occurrence runs', + fix: 'delete hourlyDemoReset from apps/web/app/tasks/tasks/hourly-demo-reset.ts if DATABASE_URL points at a database whose rows matter; if this IS the demo, seed it with `bun run packages/db/src/seed.ts` and the next occurrence runs', meta: { missing: init.missing }, }); } diff --git a/dummy/social-media-clone/apps/web/app/tasks/instant.ts b/dummy/social-media-clone/apps/web/app/tasks/instant.ts new file mode 100644 index 000000000..af6e47dc6 --- /dev/null +++ b/dummy/social-media-clone/apps/web/app/tasks/instant.ts @@ -0,0 +1,11 @@ +import { t } from '@ultimat3/jobs'; + +/** + * An instant on the wire is an ISO-8601 string, not a `Date`. The queue round-trips input through + * JSON, so a `Date` input would reach `idempotencyKeyFor` as a `Date` on the enqueue side and as a + * string on the replay side — two spellings of one key, which is no key at all. + */ +export const instant = t.string; + +/** An occurrence's instant as the ISO-8601 string `instant` reads. */ +export const iso = (ms: number): string => new Date(ms).toISOString(); diff --git a/dummy/social-media-clone/apps/web/app/tasks/jobs.test.ts b/dummy/social-media-clone/apps/web/app/tasks/jobs.test.ts index 8e24b3ee4..5d9eb90b0 100644 --- a/dummy/social-media-clone/apps/web/app/tasks/jobs.test.ts +++ b/dummy/social-media-clone/apps/web/app/tasks/jobs.test.ts @@ -6,7 +6,8 @@ import { afterAll, beforeEach, expect, test } from 'bun:test'; import { db, driver, seedDemo } from '@social-media-clone/db'; import { seedId } from '@ultimat3/entity'; import { type RunJobs, testJobs } from '@ultimat3/testing'; -import { resetDemo, sweepOrphanMedia } from './jobs'; +import { resetDemo } from './jobs/reset-demo'; +import { sweepOrphanMedia } from './jobs/sweep-orphan-media'; import { mediaById, missingDemoMarkers, pendingMediaBefore } from './repo'; /** The seed's deliberate orphan, and the attached row that must survive every sweep. */ diff --git a/dummy/social-media-clone/apps/web/app/tasks/jobs/reset-demo.ts b/dummy/social-media-clone/apps/web/app/tasks/jobs/reset-demo.ts new file mode 100644 index 000000000..7e25884c2 --- /dev/null +++ b/dummy/social-media-clone/apps/web/app/tasks/jobs/reset-demo.ts @@ -0,0 +1,44 @@ +// `resetDemo`, the tasks slice's job — one primitive per file, the layout `x g` writes. +// +// The two jobs the schedule enqueues. Work lives HERE and never in a `task`: a task runs on the +// scheduler, which is single-instance and unretried, so anything that can fail belongs on the queue. +// +// Both keys derive from `input` alone. A key that read the clock would make every retry of the same +// occurrence a brand-new job, which is the one bug `idempotencyKey` exists to delete. + +import { logger } from '@ultimat3/core'; +import { job, t } from '@ultimat3/jobs'; +import { DemoResetUnsafeError } from '../errors'; +import { instant } from '../instant'; +import { missingDemoMarkers, restoreSeededGraph } from '../repo'; + +/** + * Restore the public demo. `occurrence` is in the input for one reason: it is what makes the key + * distinguish one hour's reset from the next while a retry of THIS hour deduplicates. + */ +export const resetDemo = job({ + input: t.object({ occurrence: instant }), + idempotencyKey: (input) => `demo-reset:${input.occurrence}`, + /** Same reason as the sweep above: this app has no tenant column, and a reset owns every row. */ + tenant: 'none', + // One attempt more than a transient blip needs, and no more: a reset that keeps failing should + // dead-letter loudly rather than delete the demo four more times. + retry: { attempts: 3, backoff: 'exponential', delay: '30s' }, + async run({ input }) { + // Checked in the job, not in the task: a task only enqueues, and the guard has to hold for a + // manual `resetDemo.enqueue(...)` and a backfill exactly as it does for the cron. + // + // The store is asked, not the environment. `DATABASE_URL is set` was the old test, and it + // stopped meaning "not the demo" the day the demo got a database of its own + // (packages/db/src/client.ts) — it would have dead-lettered this job on every occurrence. + const missing = await missingDemoMarkers(); + if (missing.length > 0) throw new DemoResetUnsafeError({ missing }); + + const purged = await restoreSeededGraph(); + logger.info('tasks.demo.reset', { + occurrence: input.occurrence, + purged: Object.fromEntries(purged.map((entry) => [entry.table, entry.removed])), + }); + return { purged }; + }, +}); diff --git a/dummy/social-media-clone/apps/web/app/tasks/jobs.ts b/dummy/social-media-clone/apps/web/app/tasks/jobs/sweep-orphan-media.ts similarity index 50% rename from dummy/social-media-clone/apps/web/app/tasks/jobs.ts rename to dummy/social-media-clone/apps/web/app/tasks/jobs/sweep-orphan-media.ts index 12324fa2f..5a7043b9a 100644 --- a/dummy/social-media-clone/apps/web/app/tasks/jobs.ts +++ b/dummy/social-media-clone/apps/web/app/tasks/jobs/sweep-orphan-media.ts @@ -1,3 +1,5 @@ +// `sweepOrphanMedia`, the tasks slice's job — one primitive per file, the layout `x g` writes. +// // The two jobs the schedule enqueues. Work lives HERE and never in a `task`: a task runs on the // scheduler, which is single-instance and unretried, so anything that can fail belongs on the queue. // @@ -6,21 +8,8 @@ import { logger } from '@ultimat3/core'; import { job, t } from '@ultimat3/jobs'; -import { DemoResetUnsafeError } from './errors'; -import { - markOrphan, - missingDemoMarkers, - pendingMediaBefore, - restoreSeededGraph, - SWEEP_PAGE, -} from './repo'; - -/** - * An instant on the wire is an ISO-8601 string, not a `Date`. The queue round-trips input through - * JSON, so a `Date` input would reach `idempotencyKeyFor` as a `Date` on the enqueue side and as a - * string on the replay side — two spellings of one key, which is no key at all. - */ -const instant = t.string; +import { instant } from '../instant'; +import { markOrphan, pendingMediaBefore, SWEEP_PAGE } from '../repo'; /** * The cutoff is INPUT, computed by the task from the occurrence it is firing for — never read from @@ -52,34 +41,3 @@ export const sweepOrphanMedia = job({ return { collected: stale.length, keys: stale.map((row) => row.key) }; }, }); - -/** - * Restore the public demo. `occurrence` is in the input for one reason: it is what makes the key - * distinguish one hour's reset from the next while a retry of THIS hour deduplicates. - */ -export const resetDemo = job({ - input: t.object({ occurrence: instant }), - idempotencyKey: (input) => `demo-reset:${input.occurrence}`, - /** Same reason as the sweep above: this app has no tenant column, and a reset owns every row. */ - tenant: 'none', - // One attempt more than a transient blip needs, and no more: a reset that keeps failing should - // dead-letter loudly rather than delete the demo four more times. - retry: { attempts: 3, backoff: 'exponential', delay: '30s' }, - async run({ input }) { - // Checked in the job, not in the task: a task only enqueues, and the guard has to hold for a - // manual `resetDemo.enqueue(...)` and a backfill exactly as it does for the cron. - // - // The store is asked, not the environment. `DATABASE_URL is set` was the old test, and it - // stopped meaning "not the demo" the day the demo got a database of its own - // (packages/db/src/client.ts) — it would have dead-lettered this job on every occurrence. - const missing = await missingDemoMarkers(); - if (missing.length > 0) throw new DemoResetUnsafeError({ missing }); - - const purged = await restoreSeededGraph(); - logger.info('tasks.demo.reset', { - occurrence: input.occurrence, - purged: Object.fromEntries(purged.map((entry) => [entry.table, entry.removed])), - }); - return { purged }; - }, -}); diff --git a/dummy/social-media-clone/apps/web/app/tasks/schedule.test.ts b/dummy/social-media-clone/apps/web/app/tasks/schedule.test.ts index 13b2fc5f1..ff703539b 100644 --- a/dummy/social-media-clone/apps/web/app/tasks/schedule.test.ts +++ b/dummy/social-media-clone/apps/web/app/tasks/schedule.test.ts @@ -7,7 +7,8 @@ import { expect, test } from 'bun:test'; import { task } from '@ultimat3/jobs'; import { scheduledApi } from '../../api/tasks'; -import { hourlyDemoReset, hourlyMediaSweep } from './schedule'; +import { hourlyDemoReset } from './tasks/hourly-demo-reset'; +import { hourlyMediaSweep } from './tasks/hourly-media-sweep'; test('a task declared without a tz is rejected — an unzoned cron is a bug waiting for March', () => { // @ts-expect-error — `tz` is REQUIRED by TaskDefinition. This line failing to error is itself diff --git a/dummy/social-media-clone/apps/web/app/tasks/tasks/hourly-demo-reset.ts b/dummy/social-media-clone/apps/web/app/tasks/tasks/hourly-demo-reset.ts new file mode 100644 index 000000000..e34206fdb --- /dev/null +++ b/dummy/social-media-clone/apps/web/app/tasks/tasks/hourly-demo-reset.ts @@ -0,0 +1,24 @@ +// `hourlyDemoReset`, the tasks slice's task — one primitive per file, the layout `x g` writes. +// +// The app's cron. A task ONLY enqueues — the payload is built here, the work happens on the queue. +// +// `tz: 'UTC'` on both, said out loud: there is no ambient zone to inherit, and an unzoned +// `0 * * * *` runs twice or zero times on a DST switch day, and the scheduler's occurrence key +// would then dedupe two different hours onto one. UTC has no transitions, which is the property these two want — neither is a thing a +// reader reads at a local hour. + +import { task } from '@ultimat3/jobs'; +import { iso } from '../instant'; +import { resetDemo } from '../jobs/reset-demo'; + +/** + * Hourly demo reset. `catchUp: 'skip'` (the default, said out loud): after an outage the demo wants + * ONE restore at the next occurrence, not one per hour the process was down — every missed run + * would do the identical work, so replaying them is pure deletion. + */ +export const hourlyDemoReset = task({ + cron: '30 * * * *', + tz: 'UTC', + catchUp: 'skip', + enqueue: (occurrenceMs) => [[resetDemo, { occurrence: iso(occurrenceMs) }]], +}); diff --git a/dummy/social-media-clone/apps/web/app/tasks/schedule.ts b/dummy/social-media-clone/apps/web/app/tasks/tasks/hourly-media-sweep.ts similarity index 69% rename from dummy/social-media-clone/apps/web/app/tasks/schedule.ts rename to dummy/social-media-clone/apps/web/app/tasks/tasks/hourly-media-sweep.ts index 5867723b5..8329c769e 100644 --- a/dummy/social-media-clone/apps/web/app/tasks/schedule.ts +++ b/dummy/social-media-clone/apps/web/app/tasks/tasks/hourly-media-sweep.ts @@ -1,3 +1,5 @@ +// `hourlyMediaSweep`, the tasks slice's task — one primitive per file, the layout `x g` writes. +// // The app's cron. A task ONLY enqueues — the payload is built here, the work happens on the queue. // // `tz: 'UTC'` on both, said out loud: there is no ambient zone to inherit, and an unzoned @@ -6,7 +8,8 @@ // reader reads at a local hour. import { task } from '@ultimat3/jobs'; -import { resetDemo, sweepOrphanMedia } from './jobs'; +import { iso } from '../instant'; +import { sweepOrphanMedia } from '../jobs/sweep-orphan-media'; /** * How long an upload may sit unclaimed before it counts as abandoned. An hour: long enough that a @@ -15,8 +18,6 @@ import { resetDemo, sweepOrphanMedia } from './jobs'; */ const CLAIM_GRACE_MS = 60 * 60 * 1_000; -const iso = (ms: number): string => new Date(ms).toISOString(); - /** * Hourly orphan sweep. * @@ -29,15 +30,3 @@ export const hourlyMediaSweep = task({ tz: 'UTC', enqueue: (occurrenceMs) => [[sweepOrphanMedia, { before: iso(occurrenceMs - CLAIM_GRACE_MS) }]], }); - -/** - * Hourly demo reset. `catchUp: 'skip'` (the default, said out loud): after an outage the demo wants - * ONE restore at the next occurrence, not one per hour the process was down — every missed run - * would do the identical work, so replaying them is pure deletion. - */ -export const hourlyDemoReset = task({ - cron: '30 * * * *', - tz: 'UTC', - catchUp: 'skip', - enqueue: (occurrenceMs) => [[resetDemo, { occurrence: iso(occurrenceMs) }]], -}); diff --git a/examples/dummy/CLAUDE.md b/examples/dummy/CLAUDE.md index c29ed57fd..33d2a4fd3 100644 --- a/examples/dummy/CLAUDE.md +++ b/examples/dummy/CLAUDE.md @@ -51,7 +51,7 @@ the module graph, which is what makes the framework's boot scan load it once for other stylesheet in the app emits top-level CSS: every module is its own Sass compilation, so a second emitter would duplicate the `:root` block. A document that carries none is `X_STYLES_GLOBAL_MISSING` from the gate's `budgets` step. **There is no `/signin` or `/signup`** -(sign-OUT is the `endSession` action, `app/auth/actions.ts`: it sets the demo cookie to +(sign-OUT is the `endSession` action, `app/auth/actions/end-session.ts`: it sets the demo cookie to `signed-out` and redirects, and the layout's native form posts to it) — this file claimed all three were mounted "by the wrapped Better Auth integration" until 2026-08, which contradicted its own gotcha twelve lines down: `app/auth/login.ts`'s two route descriptors are declared, tested and **not served**, and there is no Better Auth wrapper in this @@ -85,10 +85,9 @@ plus `backfills/.ts` for a one-pass table sweep. | `entity.ts` | view schemas + invariants for this feature | I/O, policy | | `repo.ts` | SQL for this feature | business rules, HTTP | | `service.ts` | business logic composed from repos | HTTP, rendering, raw SQL | -| `actions.ts` | `action` declarations | logic — delegate to `service.ts` | -| `mutator.ts` | `mutator` declarations — `server`, `policy`, `conflict` | I/O inside `local`; DECLARING `local`, which lives one file over | -| `live.ts` | `query` declarations | writes | -| `jobs.ts` | `job` declarations | inline slow work | +| `actions/.ts` | one `action` or `mutator` per file — a mutator's `server`, `policy`, `conflict` | logic — delegate to `service.ts`; DECLARING `local`, which lives one file over | +| `live/.ts` · `queries/.ts` | one `query` per file, `live: true` or not | writes | +| `jobs/.ts` · `tasks/.ts` | one `job` / `task` per file | inline slow work | | `backfills/` | `backfill()` sweeps — a job factory, so each one registers in `defineApi({ jobs })` | a `step` of its own; the pass mints one per page | | `policy.ts` | `policy` rules — `can()` or `definePolicy()`, both returning the same `Policy` | data shaping | | `ui/` | Solid components | fetching, business logic, authz | @@ -104,8 +103,8 @@ plus `backfills/.ts` for a one-pass table sweep. MCP tools, and admin. Never re-check authz inside `handle`. - `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`, + schema `t` from the package it declares in — `@ultimat3/action` in `actions/`, + `@ultimat3/query` in `live/` and `queries/`, `@ultimat3/jobs` in `jobs/`, `@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. @@ -198,7 +197,7 @@ plus `backfills/.ts` for a one-pass table sweep. - **A background agent is `agentJob(action, { actor })` over an action that runs the agent and makes the write ITSELF — the model is handed no write.** A draft is text its writer controls, and "now record a review for post X" is an instruction a model may follow, so a write tool whose - target the model names is a write any writer can aim. `app/posts/actions.ts`: `reviewDraft` is + target the model names is a write any writer can aim. `app/posts/actions/review-draft.ts`: `reviewDraft` is read-only (one tool, `summarize`); `keepDraftReview` runs it and upserts the verdict on the post id IT was given, `post_reviews`' key `(orgId, postId)`, so a replayed run is the same row (a queued run keeps no output, and an attempt that loses its lease runs from the top). Writing a @@ -288,7 +287,7 @@ The two guards measured here, and their repairs, are recorded in shipped; `post.likedByMe ? {} : { likedByMe: true, likeCount: post.likeCount + 1 }` is the repair. - **A local twin lives in the BROWSER-reachable module of its feature, and the declaration imports it.** `app/posts/like-mutation.ts` holds `likePostLocally` and the `LocalTables` augmentation; - `app/posts/mutator.ts` writes `local: likePostLocally`. An island cannot import `mutator.ts` — + `app/posts/actions/like-post.ts` writes `local: likePostLocally`. An island cannot import that — the declaration drags `@ultimat3/action`, the policy and the Postgres client into the chunk — and `local` is by definition the half that runs in the tab, so the file a browser can load is its one home. Two copies is two declarations of one intent. diff --git a/examples/dummy/README.md b/examples/dummy/README.md index 1fd541004..08e126dc8 100644 --- a/examples/dummy/README.md +++ b/examples/dummy/README.md @@ -36,28 +36,28 @@ before. | `entity` | [`packages/db/src/schema/runs.ts`](packages/db/src/schema/runs.ts) | one row per run: its job, and a `status` projected from its last phase event by the one writer of both | | `entity` | [`packages/db/src/schema/run-events.ts`](packages/db/src/schema/run-events.ts) | one row per phase of a run; `seq` starts at 1 and the CHECK says so; `usage` is a `json()` column a schema validates | | `policy` | [`apps/web/app/posts/policy.ts`](apps/web/app/posts/policy.ts) | `post:publish` = owns-or-org-admin, one definition, five surfaces. Both authoring forms, once each: `can()` where only an agent reads the denial, `definePolicy()` on `post:like` where a person does — same `Policy` object, and `deny:` is a message key so the refusal goes through `t()` | -| `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), `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` · `subscribePush` / `unsubscribePush` — `pushSubscribe()` / `pushUnsubscribe()` from `@ultimat3/pwa`: this browser's Web Push subscription, stored against the member the request ran as | -| `action` | [`apps/web/app/runs/actions.ts`](apps/web/app/runs/actions.ts) | `startRun`, `answerPrompt`, `cancelRun` — each an MCP tool by one line, and served to machine callers under `/v1` by the bearer mount in [`api/index.ts`](apps/web/api/index.ts); `issueRunKey` mints the key | +| `action` | [`apps/web/app/posts/actions/`](apps/web/app/posts/actions/) | `createPost`, `publishPost`, and `summarize` — an `llm()` model call, which is an action factory rather than a ninth primitive | +| `action` | [`apps/web/app/orgs/actions/`](apps/web/app/orgs/actions/) | `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/`](apps/web/app/settings/actions/) | `savePreferences` — every action its own file, the layout `x g` writes · `subscribePush` / `unsubscribePush` — `pushSubscribe()` / `pushUnsubscribe()` from `@ultimat3/pwa`: this browser's Web Push subscription, stored against the member the request ran as | +| `action` | [`apps/web/app/runs/actions/`](apps/web/app/runs/actions/) | `startRun`, `answerPrompt`, `cancelRun` — each an MCP tool by one line, and served to machine callers under `/v1` by the bearer mount in [`api/index.ts`](apps/web/api/index.ts); `issueRunKey` mints the key | | `policy` | [`apps/web/app/runs/policy.ts`](apps/web/app/runs/policy.ts) | rules that read `actor.orgId`, so a member, an agent and an API key pass the same predicate; `row === null` denies an answer or a cancel for a run nobody loaded | -| `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` | -| `query` | [`apps/web/app/runs/live.ts`](apps/web/app/runs/live.ts) | `liveRunEvents` — a run's events in `seq` order, pushed as the job writes them | -| `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, `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 | +| `mutator` | [`apps/web/app/posts/actions/like-post.ts`](apps/web/app/posts/actions/like-post.ts) | `likePost` — optimistic local twin, offline queue, `conflict: 'server-wins'` | +| `mutator` | [`apps/web/app/settings/actions/`](apps/web/app/settings/actions/) | `setTheme` (`'last-write-wins'`) and `toggleDigestOptIn` (`custom`, sticky unsubscribe) — the other two conflict strategies | +| `query` | [`apps/web/app/posts/live/live-feed.ts`](apps/web/app/posts/live/live-feed.ts) | `liveFeed` (`live: true`) + non-live `postBySlug` ([`queries/`](apps/web/app/posts/queries/): a read that is not subscribable is not in `live/`) | +| `query` | [`apps/web/app/runs/live/live-run-events.ts`](apps/web/app/runs/live/live-run-events.ts) | `liveRunEvents` — a run's events in `seq` order, pushed as the job writes them | +| `job` | [`apps/web/app/orgs/jobs/onboard-org.ts`](apps/web/app/orgs/jobs/onboard-org.ts) | `onboardOrg` — durable steps + `step.sleep('3d')` | +| `job` | [`apps/web/app/posts/jobs/notify-subscribers.ts`](apps/web/app/posts/jobs/notify-subscribers.ts) | `notifySubscribers` — fanout, `concurrency: 1` (one in flight across the fleet) | +| `job` | [`apps/web/app/digest/jobs/send-digest.ts`](apps/web/app/digest/jobs/send-digest.ts) | `sendDigest` — 09:00 **local per member**, DST-correct, delivered one (org, zone) group at a time | | `job` | [`apps/web/app/posts/backfills/post-excerpts.ts`](apps/web/app/posts/backfills/post-excerpts.ts) | `postExcerpts` — `backfill()` is a **job factory**, not a ninth primitive: one pass over the rows that are behind, every page in its own `step.run`, the checkpoint a cursor and never the page, the handler idempotent because `handle` is at-least-once | -| `job` | [`apps/web/app/runs/jobs.ts`](apps/web/app/runs/jobs.ts) | `syncConnection` — `scrape()` is a **job factory**: a recorded site (`fixtureBrowser`), `egress` looked up in the worker, one run per connection (`concurrency: { key, whenBusy: 'fail' }` → `X_JOB_KEY_BUSY`), a sealed stored session, a prompt a person answers mid-run over the event bus, and `onSettled` recording how each run ended and what it used | -| `action` | [`apps/web/app/posts/actions.ts`](apps/web/app/posts/actions.ts) | `summarizePosts` — `hive()`, a fan-out as one action: one `summarize` member per post, in the order asked, a foreign post failing alone · `reviewDraft` — `agent()`, a tool loop as one action, read-only, its one tool the `summarize` action under the same actor · `keepDraftReview` runs it and upserts the verdict on the post id the CODE was given — never a write the model can aim · `reviewDraftLater` — `agentJob()` over `keepDraftReview`: `actor` re-reads the member who asked on every attempt (a served worker is nobody), and the kept row is the answer because a queued run keeps no output · `requestPostsExport` enqueues the export below | -| `mutator` | [`apps/web/app/posts/mutator.ts`](apps/web/app/posts/mutator.ts) | `movePostStatus` — `transition()` over `posts.status`'s declared machine: one compare-and-set statement, `draft` ⇄ `scheduled`, `published` reachable only through `publishPost`; a `row:` loader so scheduling is owns-or-org-admin, as publishing is | -| `job` | [`apps/web/app/posts/jobs.ts`](apps/web/app/posts/jobs.ts) | `exportPosts` — `exportRows()`: an org's posts to CSV parts plus a manifest on the app's disk, a page at a time | -| `job` | [`apps/web/app/posts/notifiers.ts`](apps/web/app/posts/notifiers.ts) | `commentPosted` — `notifier()`: the post's author is mailed about a comment, and pushed to every browser she subscribed (`pushChannel({ pusher: webPush() })`, each in its own locale, tagged by post), once per comment, through the delivery ledger | -| `job` | [`apps/web/app/webhooks/jobs.ts`](apps/web/app/webhooks/jobs.ts) | `postPublishedWebhook` — `webhook()`: one signed `post.published` delivery per live endpoint of the publishing org (`ctx.webhooks.announcePublished`, from `publishPost`), the org on the input so `tenant` and both seams name it, every attempt on [`ledger.ts`](apps/web/app/webhooks/ledger.ts) over `webhook_deliveries`, and an endpoint switched off after ten failures in a row · `addWebhookEndpoint` registers one (its URL screened by the delivery screen's rules) and answers its secret once; `removeWebhookEndpoint` revokes it | -| `job` | [`apps/web/app/mcp/jobs.ts`](apps/web/app/mcp/jobs.ts) | `purgeMcpConfirmations` — `purge()`: a month of MCP confirmations kept, the rest deleted hourly (`hourlyPurge` in `api/tasks.ts`) | +| `job` | [`apps/web/app/runs/jobs/sync-connection.ts`](apps/web/app/runs/jobs/sync-connection.ts) | `syncConnection` — `scrape()` is a **job factory**: a recorded site (`fixtureBrowser`), `egress` looked up in the worker, one run per connection (`concurrency: { key, whenBusy: 'fail' }` → `X_JOB_KEY_BUSY`), a sealed stored session, a prompt a person answers mid-run over the event bus, and `onSettled` recording how each run ended and what it used | +| `action` | [`apps/web/app/posts/actions/`](apps/web/app/posts/actions/) | `summarizePosts` — `hive()`, a fan-out as one action: one `summarize` member per post, in the order asked, a foreign post failing alone · `reviewDraft` — `agent()`, a tool loop as one action, read-only, its one tool the `summarize` action under the same actor · `keepDraftReview` runs it and upserts the verdict on the post id the CODE was given — never a write the model can aim · `reviewDraftLater` — `agentJob()` over `keepDraftReview`: `actor` re-reads the member who asked on every attempt (a served worker is nobody), and the kept row is the answer because a queued run keeps no output · `requestPostsExport` enqueues the export below | +| `mutator` | [`apps/web/app/posts/actions/move-post-status.ts`](apps/web/app/posts/actions/move-post-status.ts) | `movePostStatus` — `transition()` over `posts.status`'s declared machine: one compare-and-set statement, `draft` ⇄ `scheduled`, `published` reachable only through `publishPost`; a `row:` loader so scheduling is owns-or-org-admin, as publishing is | +| `job` | [`apps/web/app/posts/jobs/export-posts.ts`](apps/web/app/posts/jobs/export-posts.ts) | `exportPosts` — `exportRows()`: an org's posts to CSV parts plus a manifest on the app's disk, a page at a time | +| `job` | [`apps/web/app/posts/jobs/comment-posted.ts`](apps/web/app/posts/jobs/comment-posted.ts) | `commentPosted` — `notifier()`: the post's author is mailed about a comment, and pushed to every browser she subscribed (`pushChannel({ pusher: webPush() })`, each in its own locale, tagged by post), once per comment, through the delivery ledger | +| `job` | [`apps/web/app/webhooks/jobs/post-published-webhook.ts`](apps/web/app/webhooks/jobs/post-published-webhook.ts) | `postPublishedWebhook` — `webhook()`: one signed `post.published` delivery per live endpoint of the publishing org (`ctx.webhooks.announcePublished`, from `publishPost`), the org on the input so `tenant` and both seams name it, every attempt on [`ledger.ts`](apps/web/app/webhooks/ledger.ts) over `webhook_deliveries`, and an endpoint switched off after ten failures in a row · `addWebhookEndpoint` registers one (its URL screened by the delivery screen's rules) and answers its secret once; `removeWebhookEndpoint` revokes it | +| `job` | [`apps/web/app/mcp/jobs/purge-mcp-confirmations.ts`](apps/web/app/mcp/jobs/purge-mcp-confirmations.ts) | `purgeMcpConfirmations` — `purge()`: a month of MCP confirmations kept, the rest deleted hourly (`hourlyPurge` in `app/mcp/tasks/hourly-purge.ts`) | | `action` | [`packages/mcp/src/confirmations.ts`](packages/mcp/src/confirmations.ts) | `confirmAgentPublish` — `mcpConfirmations()`: an agent's `publishPost` waits for a person to view and approve that exact call | -| `task` | [`apps/web/api/tasks.ts`](apps/web/api/tasks.ts) | `nightlyDigest` cron with an explicit `tz` | +| `task` | [`apps/web/app/digest/tasks/nightly-digest.ts`](apps/web/app/digest/tasks/nightly-digest.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` | @@ -93,13 +93,13 @@ apps/web/site/ static/isr, 0kb JS, SEO-critical — cannot import app/ apps/web/app/ auth'd, stream/spa, realtime — imports api/ types only apps/web/api/ actions + tasks, no rendering apps/web/shared/ tokens, policies, entity types — leaf, importable by both -apps/web/app//{entity,repo,service,actions,mutator,live,jobs,policy,ui}.ts +apps/web/app//{entity,repo,service,policy}.ts + {actions,live,queries,jobs,tasks}/.ts + ui/ ``` -A primitive is registered by `defineApi()` and found by the module scan, never by its filename, so -this app's one `actions.ts` per feature and the `actions/-.ts` that `x g` writes are -the same primitives in two valid module layouts — nothing enforces either ([Project -layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface)). +One primitive per file, in its kind's directory — the layout `x g` writes, and the only one: a +module `actions.ts` beside `actions/` is `X_LAYOUT_SIBLING_MODULE` ([Project +layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface)). A mutator, a transition and +an `llm()`/`agent()`/`hive()` call live in `actions/`; every job factory but `backfill()` in `jobs/`. ## Cross-cutting checklist @@ -109,7 +109,7 @@ layout](../../wiki/Project-Layout.md#feature-slicing-inside-a-surface)). | **Dark theme** | [`apps/web/shared/global.scss`](apps/web/shared/global.scss) | every colour is `var(--color-*)`; no raw hex in any `.tsx` or `.scss`; the `:root` blocks that define those properties reach the document through [`shared/global.ts`](apps/web/shared/global.ts) | | **Timezones** | [`packages/core/src/digest-schedule.ts`](packages/core/src/digest-schedule.ts) | member `tz` drives every ``; 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 | +| **Offline** | [`apps/web/app/posts/actions/like-post.ts`](apps/web/app/posts/actions/like-post.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/posts/[id]/page.tsx`](apps/web/app/posts/%5Bid%5D/page.tsx) | one record, many places — two islands read `posts:` from the page's store; `useChannel(ORG_POSTS)` keeps it current, `useMutation(LIKE_POST)` writes it over HTTP | | **Auth** | [`apps/web/app/auth/login.ts`](apps/web/app/auth/login.ts) | "log in with GitHub" is `defineAuth` + `oauthLogin`, ~12 lines; the round trip — 302 with an S256 challenge, a forged `state` refused, a session `authenticate()` resolves — is asserted in [`login.test.ts`](apps/web/app/auth/login.test.ts) against a stubbed provider, because no client id exists in CI. **Not yet reachable in a browser:** see the gotcha in [`CLAUDE.md`](CLAUDE.md) | | **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 | diff --git a/examples/dummy/apps/web/api/index.ts b/examples/dummy/apps/web/api/index.ts index ba8d326d7..c68ce858e 100644 --- a/examples/dummy/apps/web/api/index.ts +++ b/examples/dummy/apps/web/api/index.ts @@ -25,77 +25,142 @@ // server, which snapshots the registry and must load after this call, never inside it. import * as mcpConfirmationActions from '@postly/mcp/confirmations'; import { defineApi } from '@ultimat3/action'; -import * as authActions from '../app/auth/actions'; -import * as contactActions from '../app/contact/actions'; -import * as contactJobs from '../app/contact/jobs'; -import * as digestJobs from '../app/digest/jobs'; -import * as orgActions from '../app/orgs/actions'; -import * as orgJobs from '../app/orgs/jobs'; -// `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. +// One module per primitive — the directory form `x g` writes (`/actions/.ts`), each +// bound under its own file's name. A factory's kind decides its list, never its file: a mutator, +// a transition and an `llm()`/`agent()`/`hive()` call are actions; a backfill, a notifier, a +// scrape, a webhook delivery, an export and an `agentJob()` are jobs. +import * as endSession from '../app/auth/actions/end-session'; +import * as contactSales from '../app/contact/actions/contact-sales'; +import * as sendSalesReceipt from '../app/contact/jobs/send-sales-receipt'; +import * as deliverDigest from '../app/digest/jobs/deliver-digest'; +import * as sendDigest from '../app/digest/jobs/send-digest'; +import * as nightlyDigest from '../app/digest/tasks/nightly-digest'; +import * as purgeMcpConfirmations from '../app/mcp/jobs/purge-mcp-confirmations'; +import * as hourlyPurge from '../app/mcp/tasks/hourly-purge'; +import * as grantAvatarUpload from '../app/orgs/actions/grant-avatar-upload'; +import * as inviteMember from '../app/orgs/actions/invite-member'; +import * as memberAvatar from '../app/orgs/actions/member-avatar'; +import * as upgradePlan from '../app/orgs/actions/upgrade-plan'; +import * as onboardOrg from '../app/orgs/jobs/onboard-org'; +import * as sendInvite from '../app/orgs/jobs/send-invite'; +import * as createComment from '../app/posts/actions/create-comment'; +import * as createPost from '../app/posts/actions/create-post'; +import * as keepDraftReview from '../app/posts/actions/keep-draft-review'; +import * as likePost from '../app/posts/actions/like-post'; +import * as movePostStatus from '../app/posts/actions/move-post-status'; +import * as publishPost from '../app/posts/actions/publish-post'; +import * as requestDraftReview from '../app/posts/actions/request-draft-review'; +import * as requestPostsExport from '../app/posts/actions/request-posts-export'; +import * as reviewDraft from '../app/posts/actions/review-draft'; +import * as summarize from '../app/posts/actions/summarize'; +import * as summarizePosts from '../app/posts/actions/summarize-posts'; +import * as postExcerpts from '../app/posts/backfills/post-excerpts'; +import * as commentPosted from '../app/posts/jobs/comment-posted'; +import * as exportPosts from '../app/posts/jobs/export-posts'; +import * as notifySubscribers from '../app/posts/jobs/notify-subscribers'; +import * as reviewDraftLater from '../app/posts/jobs/review-draft-later'; +import * as liveFeed from '../app/posts/live/live-feed'; +import * as feedActivity from '../app/posts/queries/feed-activity'; +import * as orgPosts from '../app/posts/queries/org-posts'; +import * as postById from '../app/posts/queries/post-by-id'; +import * as postBySlug from '../app/posts/queries/post-by-slug'; +import * as postRecord from '../app/posts/queries/post-record'; +import * as postReview from '../app/posts/queries/post-review'; +import * as publicPost from '../app/posts/queries/public-post'; +import * as publicPostSlugs from '../app/posts/queries/public-post-slugs'; +import * as publicPosts from '../app/posts/queries/public-posts'; +import * as answerPrompt from '../app/runs/actions/answer-prompt'; +import * as cancelRun from '../app/runs/actions/cancel-run'; +import * as connectSite from '../app/runs/actions/connect-site'; +import * as issueRunKey from '../app/runs/actions/issue-run-key'; +import * as revokeRunKey from '../app/runs/actions/revoke-run-key'; +import * as startRun from '../app/runs/actions/start-run'; +import * as syncConnection from '../app/runs/jobs/sync-connection'; +import { RUN_KEY_SCOPES, resolveRunKey } from '../app/runs/keys'; +import * as liveRunEvents from '../app/runs/live/live-run-events'; +import * as runConnections from '../app/runs/queries/run-connections'; +import * as savePreferences from '../app/settings/actions/save-preferences'; +import * as setTheme from '../app/settings/actions/set-theme'; +import * as subscribePush from '../app/settings/actions/subscribe-push'; +import * as toggleDigestOptIn from '../app/settings/actions/toggle-digest-opt-in'; +import * as unsubscribePush from '../app/settings/actions/unsubscribe-push'; +import * as addWebhookEndpoint from '../app/webhooks/actions/add-webhook-endpoint'; +import * as removeWebhookEndpoint from '../app/webhooks/actions/remove-webhook-endpoint'; +import * as postPublishedWebhook from '../app/webhooks/jobs/post-published-webhook'; +// `defineService(...)` and the channel declaration run on import — the same "importing IS the +// boot" rule as the registration below, so `ctx.orgs`, `ctx.posts`, `ctx.runs` and `ctx.webhooks` +// are installed wherever this module has run, including tests. import '../app/orgs/service'; -import * as postActions from '../app/posts/actions'; import '../app/posts/channels'; -// A backfill IS a job — `backfill()` is a factory over `job()` — so it registers in the `jobs` -// list and nowhere else. It carries its own `name`, unlike a plain job whose export name becomes -// its queue key, because a sweep's name is a durable key the `x_backfills` ledger already holds. -import * as postBackfills from '../app/posts/backfills/post-excerpts'; -import * as postJobs from '../app/posts/jobs'; -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'; -// `notifier()` is a job factory, so a notification registers in the `jobs` list. -import * as postNotifiers from '../app/posts/notifiers'; import '../app/posts/service'; -import * as mcpJobs from '../app/mcp/jobs'; -import * as runActions from '../app/runs/actions'; -// `scrape()` is a job factory, so the sync registers in the `jobs` list like any other job. It -// names itself (`runs.sync`): a scrape's name is a durable queue key, never an export name. -import * as runJobs from '../app/runs/jobs'; -import { RUN_KEY_SCOPES, resolveRunKey } from '../app/runs/keys'; -import * as runQueries from '../app/runs/live'; import '../app/runs/service'; -import * as settingsActions from '../app/settings/actions'; -// A mutator IS an action, exactly like `postMutators` above. -import * as settingsMutators from '../app/settings/mutator'; -import * as webhookActions from '../app/webhooks/actions'; -// `webhook()` is a job factory, so a delivery registers in the `jobs` list. It names itself -// (`posts.published.webhook`): a delivery's name is a durable queue key, never an export name. -import * as webhookJobs from '../app/webhooks/jobs'; import '../app/webhooks/service'; -import * as scheduledTasks from './tasks'; export const api = defineApi({ actions: [ - postActions, - orgActions, - settingsActions, - contactActions, - authActions, - runActions, - webhookActions, + endSession, + contactSales, + inviteMember, + upgradePlan, + grantAvatarUpload, + memberAvatar, + createPost, + publishPost, + createComment, + requestPostsExport, + summarize, + summarizePosts, + reviewDraft, + keepDraftReview, + requestDraftReview, + connectSite, + startRun, + answerPrompt, + cancelRun, + issueRunKey, + revokeRunKey, + savePreferences, + subscribePush, + unsubscribePush, + addWebhookEndpoint, + removeWebhookEndpoint, mcpConfirmationActions, ], - mutators: [postMutators, settingsMutators], - queries: [postQueries, runQueries], + // 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. + mutators: [likePost, movePostStatus, setTheme, toggleDigestOptIn], + queries: [ + liveFeed, + orgPosts, + postRecord, + postById, + feedActivity, + postReview, + publicPost, + publicPosts, + publicPostSlugs, + postBySlug, + liveRunEvents, + runConnections, + ], + // Named jobs keep their own durable names (`runs.sync`, `posts.published.webhook`, a backfill's + // ledger key); every other job's export name is its queue key. jobs: [ - postJobs, - postNotifiers, - postBackfills, - orgJobs, - digestJobs, - contactJobs, - runJobs, - mcpJobs, - webhookJobs, - // `agentJob()` is a job factory declared BESIDE the agent it wraps (`reviewDraftLater`, which - // reads `reviewDraft` at module scope), so the actions module is handed over here too. Each - // registrar takes only its own kind, so neither list double-registers anything. - postActions, + notifySubscribers, + exportPosts, + commentPosted, + reviewDraftLater, + postExcerpts, + onboardOrg, + sendInvite, + sendDigest, + deliverDigest, + sendSalesReceipt, + syncConnection, + purgeMcpConfirmations, + postPublishedWebhook, ], - tasks: [scheduledTasks], + tasks: [nightlyDigest, hourlyPurge], // A second door for machine callers, onto the run actions a browser already calls: same // handler, same policy, a bearer key instead of a session. `/api/runs/start` is `/v1/runs/start`. http: { mounts: [{ prefix: '/v1', scopes: RUN_KEY_SCOPES, resolveToken: resolveRunKey }] }, diff --git a/examples/dummy/apps/web/app/auth/actions.test.ts b/examples/dummy/apps/web/app/auth/actions.test.ts index c5a2332f6..29b441bdf 100644 --- a/examples/dummy/apps/web/app/auth/actions.test.ts +++ b/examples/dummy/apps/web/app/auth/actions.test.ts @@ -4,7 +4,7 @@ import { runWithContext } from '@ultimat3/core'; import { asCtx, defineHttpConfig, requestContext } from '@ultimat3/http'; import { describe, expect, test } from '@ultimat3/testing'; -import { AFTER_SIGN_OUT, endSession } from './actions'; +import { AFTER_SIGN_OUT, endSession } from './actions/end-session'; import { DEMO_MEMBER_COOKIE, DEMO_SIGNED_OUT } from './demo-actor'; const signOut = async (accept: string) => { diff --git a/examples/dummy/apps/web/app/auth/actions.ts b/examples/dummy/apps/web/app/auth/actions/end-session.ts similarity index 97% rename from examples/dummy/apps/web/app/auth/actions.ts rename to examples/dummy/apps/web/app/auth/actions/end-session.ts index e611117c1..d17ca740b 100644 --- a/examples/dummy/apps/web/app/auth/actions.ts +++ b/examples/dummy/apps/web/app/auth/actions/end-session.ts @@ -13,7 +13,7 @@ import { action, t } from '@ultimat3/action'; import { signOutHeaders } from '@ultimat3/auth'; import { setCookie, setRedirect, useRequestHeader } from '@ultimat3/http'; import { allow } from '@ultimat3/policy'; -import { DEMO_MEMBER_COOKIE, DEMO_SIGNED_OUT } from './demo-actor'; +import { DEMO_MEMBER_COOKIE, DEMO_SIGNED_OUT } from '../demo-actor'; /** Where a signed-out reader lands: the public site, which asks nothing of a session. */ export const AFTER_SIGN_OUT = '/'; diff --git a/examples/dummy/apps/web/app/contact/actions.ts b/examples/dummy/apps/web/app/contact/actions/contact-sales.ts similarity index 96% rename from examples/dummy/apps/web/app/contact/actions.ts rename to examples/dummy/apps/web/app/contact/actions/contact-sales.ts index f29554b9f..e3f61c373 100644 --- a/examples/dummy/apps/web/app/contact/actions.ts +++ b/examples/dummy/apps/web/app/contact/actions/contact-sales.ts @@ -8,8 +8,8 @@ import { BILLING_CURRENCIES, PLAN_CODES, SUPPORTED_LOCALES } from '@postly/domain'; import { action, t } from '@ultimat3/action'; -import { contactSubmit } from '../../shared/policies'; -import { sendSalesReceipt } from './jobs'; +import { contactSubmit } from '../../../shared/policies'; +import { sendSalesReceipt } from '../jobs/send-sales-receipt'; /** Long enough for a real question, short enough that the row and the idempotency key stay sane. */ export const ENQUIRY_MAX = 2_000; diff --git a/examples/dummy/apps/web/app/contact/jobs.ts b/examples/dummy/apps/web/app/contact/jobs/send-sales-receipt.ts similarity index 98% rename from examples/dummy/apps/web/app/contact/jobs.ts rename to examples/dummy/apps/web/app/contact/jobs/send-sales-receipt.ts index dbc8e0e55..74f2e0bb2 100644 --- a/examples/dummy/apps/web/app/contact/jobs.ts +++ b/examples/dummy/apps/web/app/contact/jobs/send-sales-receipt.ts @@ -8,7 +8,7 @@ import { job, t } from '@ultimat3/jobs'; import { send } from '@ultimat3/mail'; -import { salesReceiptEmail } from './mail'; +import { salesReceiptEmail } from '../mail'; export const sendSalesReceipt = job({ // The locale rides in the payload rather than being read off a request: the worker renders this diff --git a/examples/dummy/apps/web/app/digest/jobs.job.test.ts b/examples/dummy/apps/web/app/digest/jobs.job.test.ts index 0a69587a5..a1796f046 100644 --- a/examples/dummy/apps/web/app/digest/jobs.job.test.ts +++ b/examples/dummy/apps/web/app/digest/jobs.job.test.ts @@ -29,7 +29,8 @@ import { setMailDriver, tryMailDriver, } from '@ultimat3/mail'; -import { deliverDigest, digestEnabled, sendDigest } from './jobs'; +import { deliverDigest } from './jobs/deliver-digest'; +import { digestEnabled, sendDigest } from './jobs/send-digest'; const TINTA = '00000000-0000-4000-8000-00000000e001'; const NUBE = '00000000-0000-4000-8000-00000000e002'; diff --git a/examples/dummy/apps/web/app/digest/jobs/deliver-digest.ts b/examples/dummy/apps/web/app/digest/jobs/deliver-digest.ts new file mode 100644 index 000000000..eabac5719 --- /dev/null +++ b/examples/dummy/apps/web/app/digest/jobs/deliver-digest.ts @@ -0,0 +1,90 @@ +// `deliverDigest`, the digest slice's job — one primitive per file, the layout `x g` writes. +// +// The nightly digest. The scheduler fires once, in UTC; the *delivery* is per member, at 09:00 +// on their own wall clock. That is why this is two jobs: one fan-out that computes slots, and one +// delivery whose idempotency key is the slot it is for. +// +// The delivery's unit is one (org, zone) group, not one member. Both halves of that follow from +// what a digest costs: the post window is org-scoped and bounded by the group's own instant, so a +// per-member delivery re-ran the identical `publishedSince` once per reader and re-read the row +// it was handed the id of. A group runs each of those once and mails everybody from the result. +// The member's promise is unchanged — every member of a group shares a zone, so 09:00 still means +// 09:00 where they are. +// +// `t` comes from @ultimat3/jobs, not @ultimat3/schema: a job file imports one package. + +import { previousDigestAt } from '@postly/core'; +import { SUPPORTED_ZONES, orgId as toOrgId } from '@postly/domain'; +import { job, t } from '@ultimat3/jobs'; +import { send } from '@ultimat3/mail'; +import { digestEmail } from '../mail'; + +export const deliverDigest = job({ + /** + * `slotAt` is the instant this digest is FOR — the group's 09:00, in epoch ms. It travels in + * the payload because the queue is allowed to be late: four attempts of exponential backoff + * put `ctx.now()` hours past the slot, and a window measured from *then* would load a later + * day's posts than the `localDate` the email prints. + * + * `orgId` and `zone` travel for the same reason the window does: they are what the delivery is + * FOR. Reading them back off a member row is the read this payload exists to delete. + */ + input: t.object({ + orgId: t.uuid, + zone: t.enumerated(...SUPPORTED_ZONES), + localDate: t.string, + slotAt: t.number.int(), + }), + /** Local date, not UTC date: two zones inside one org are two different digests. */ + idempotencyKey: ({ orgId, zone, localDate }) => `digest:${orgId}:${zone}:${localDate}`, + /** One group is one org: the posts, the org row and the readers are all read under this id. */ + tenant: ({ orgId }) => orgId, + retry: { attempts: 4, backoff: 'exponential' }, + queue: 'mail', + /** One group in flight at a time, so a retry cannot race its own first attempt. */ + concurrency: 1, + async run({ input, step, ctx }) { + const orgId = toOrgId(input.orgId); + + // The window ends at the slot, never at execution time: the digest dated `localDate` must + // contain the posts of that day whether it went out on time or after three retries. It OPENS + // at the previous slot — the same calendar math, not `slotAt - 86_400_000`, because + // consecutive slots are 23h apart on spring-forward and 25h on autumn-back, and a fixed day + // of milliseconds mails those posts twice in March and to nobody in October. Same window + // `postly.digestPreview` promises. + // + // ONE read for every reader of this digest, which is the whole reason the group exists. + const since = previousDigestAt(new Date(input.slotAt), input.zone); + const posts = await step.run('load-posts', () => ctx.posts.publishedSince(orgId, since)); + + // An empty digest is not a digest. Skipping still records the step, so a retry does not + // re-query — and it skips the two reads below, so an org with nothing to say costs one + // statement in total. + if (posts.length === 0) return { sent: 0 }; + + // The org's NAME, because the mail's `{org}` is a name slot. + const org = await step.run('load-org', () => ctx.orgs.byId(orgId)); + + // Read at the slot rather than carried from the fan-out, and that is the point: a member who + // opted out during the night is gone from this list, and a page of rows in a job payload is + // the thing this repo refuses everywhere else. One statement, whatever the group's size. + const recipients = await step.run('load-recipients', async () => + (await ctx.orgs.digestRecipients(orgId)).filter((member) => member.tz === input.zone), + ); + + for (const member of recipients) { + // One step per member: the step IS the retry unit, so a provider blip on reader 40 of 50 + // re-sends reader 40 and replays the other 39 from storage in microseconds. The member's + // id names it because the id is what survives a replay — a loop index does not. + await step.run(`send:${member.id}`, () => + send( + digestEmail, + { member, posts, localDate: input.localDate, org: org.name }, + { to: member.email, locale: member.locale, tz: member.tz }, + ), + ); + } + + return { sent: recipients.length }; + }, +}); diff --git a/examples/dummy/apps/web/app/digest/jobs.ts b/examples/dummy/apps/web/app/digest/jobs/send-digest.ts similarity index 53% rename from examples/dummy/apps/web/app/digest/jobs.ts rename to examples/dummy/apps/web/app/digest/jobs/send-digest.ts index 438cc6e49..ede6782d0 100644 --- a/examples/dummy/apps/web/app/digest/jobs.ts +++ b/examples/dummy/apps/web/app/digest/jobs/send-digest.ts @@ -1,25 +1,23 @@ -/** - * The nightly digest. The scheduler fires once, in UTC; the *delivery* is per member, at 09:00 - * on their own wall clock. That is why this is two jobs: one fan-out that computes slots, and one - * delivery whose idempotency key is the slot it is for. - * - * The delivery's unit is one (org, zone) group, not one member. Both halves of that follow from - * what a digest costs: the post window is org-scoped and bounded by the group's own instant, so a - * per-member delivery re-ran the identical `publishedSince` once per reader and re-read the row - * it was handed the id of. A group runs each of those once and mails everybody from the result. - * The member's promise is unchanged — every member of a group shares a zone, so 09:00 still means - * 09:00 where they are. - * - * `t` comes from @ultimat3/jobs, not @ultimat3/schema: a job file imports one package. - */ - -import { localDateIn, previousDigestAt, scheduleByOrgAndZone } from '@postly/core'; -import { SUPPORTED_ZONES, orgId as toOrgId } from '@postly/domain'; +// `sendDigest`, the digest slice's job — one primitive per file, the layout `x g` writes. +// +// The nightly digest. The scheduler fires once, in UTC; the *delivery* is per member, at 09:00 +// on their own wall clock. That is why this is two jobs: one fan-out that computes slots, and one +// delivery whose idempotency key is the slot it is for. +// +// The delivery's unit is one (org, zone) group, not one member. Both halves of that follow from +// what a digest costs: the post window is org-scoped and bounded by the group's own instant, so a +// per-member delivery re-ran the identical `publishedSince` once per reader and re-read the row +// it was handed the id of. A group runs each of those once and mails everybody from the result. +// The member's promise is unchanged — every member of a group shares a zone, so 09:00 still means +// 09:00 where they are. +// +// `t` comes from @ultimat3/jobs, not @ultimat3/schema: a job file imports one package. + +import { localDateIn, scheduleByOrgAndZone } from '@postly/core'; import { assert } from '@ultimat3/core'; import { defineFlag, isEnabled } from '@ultimat3/flags'; import { job, t } from '@ultimat3/jobs'; -import { send } from '@ultimat3/mail'; -import { digestEmail } from './mail'; +import { deliverDigest } from './deliver-digest'; /** The shape the nightly task sends, and the only one the slot arithmetic below can read. */ const RUN_DATE = /^\d{4}-\d{2}-\d{2}$/; @@ -115,73 +113,3 @@ export const sendDigest = job({ return { runDate: input.runDate, groups: groups.length }; }, }); - -export const deliverDigest = job({ - /** - * `slotAt` is the instant this digest is FOR — the group's 09:00, in epoch ms. It travels in - * the payload because the queue is allowed to be late: four attempts of exponential backoff - * put `ctx.now()` hours past the slot, and a window measured from *then* would load a later - * day's posts than the `localDate` the email prints. - * - * `orgId` and `zone` travel for the same reason the window does: they are what the delivery is - * FOR. Reading them back off a member row is the read this payload exists to delete. - */ - input: t.object({ - orgId: t.uuid, - zone: t.enumerated(...SUPPORTED_ZONES), - localDate: t.string, - slotAt: t.number.int(), - }), - /** Local date, not UTC date: two zones inside one org are two different digests. */ - idempotencyKey: ({ orgId, zone, localDate }) => `digest:${orgId}:${zone}:${localDate}`, - /** One group is one org: the posts, the org row and the readers are all read under this id. */ - tenant: ({ orgId }) => orgId, - retry: { attempts: 4, backoff: 'exponential' }, - queue: 'mail', - /** One group in flight at a time, so a retry cannot race its own first attempt. */ - concurrency: 1, - async run({ input, step, ctx }) { - const orgId = toOrgId(input.orgId); - - // The window ends at the slot, never at execution time: the digest dated `localDate` must - // contain the posts of that day whether it went out on time or after three retries. It OPENS - // at the previous slot — the same calendar math, not `slotAt - 86_400_000`, because - // consecutive slots are 23h apart on spring-forward and 25h on autumn-back, and a fixed day - // of milliseconds mails those posts twice in March and to nobody in October. Same window - // `postly.digestPreview` promises. - // - // ONE read for every reader of this digest, which is the whole reason the group exists. - const since = previousDigestAt(new Date(input.slotAt), input.zone); - const posts = await step.run('load-posts', () => ctx.posts.publishedSince(orgId, since)); - - // An empty digest is not a digest. Skipping still records the step, so a retry does not - // re-query — and it skips the two reads below, so an org with nothing to say costs one - // statement in total. - if (posts.length === 0) return { sent: 0 }; - - // The org's NAME, because the mail's `{org}` is a name slot. - const org = await step.run('load-org', () => ctx.orgs.byId(orgId)); - - // Read at the slot rather than carried from the fan-out, and that is the point: a member who - // opted out during the night is gone from this list, and a page of rows in a job payload is - // the thing this repo refuses everywhere else. One statement, whatever the group's size. - const recipients = await step.run('load-recipients', async () => - (await ctx.orgs.digestRecipients(orgId)).filter((member) => member.tz === input.zone), - ); - - for (const member of recipients) { - // One step per member: the step IS the retry unit, so a provider blip on reader 40 of 50 - // re-sends reader 40 and replays the other 39 from storage in microseconds. The member's - // id names it because the id is what survives a replay — a loop index does not. - await step.run(`send:${member.id}`, () => - send( - digestEmail, - { member, posts, localDate: input.localDate, org: org.name }, - { to: member.email, locale: member.locale, tz: member.tz }, - ), - ); - } - - return { sent: recipients.length }; - }, -}); diff --git a/examples/dummy/apps/web/api/tasks.ts b/examples/dummy/apps/web/app/digest/tasks/nightly-digest.ts similarity index 56% rename from examples/dummy/apps/web/api/tasks.ts rename to examples/dummy/apps/web/app/digest/tasks/nightly-digest.ts index 121ce4c6a..5c7b810ff 100644 --- a/examples/dummy/apps/web/api/tasks.ts +++ b/examples/dummy/apps/web/app/digest/tasks/nightly-digest.ts @@ -1,13 +1,12 @@ -/** - * Scheduled triggers. A task only enqueues — if it does work, it is a job. The `scheduler` role - * is a single instance elected by a Postgres advisory lock; a missed tick fires late rather than - * being skipped, and the job's idempotency key absorbs a double fire during handover. - */ +// `nightlyDigest`, the api slice's task — one primitive per file, the layout `x g` writes. +// +// Scheduled triggers. A task only enqueues — if it does work, it is a job. The `scheduler` role +// is a single instance elected by a Postgres advisory lock; a missed tick fires late rather than +// being skipped, and the job's idempotency key absorbs a double fire during handover. import { localDateIn } from '@postly/core'; -import { DEFAULT_PURGE_CRON, task } from '@ultimat3/jobs'; -import { sendDigest } from '../app/digest/jobs'; -import { purgeMcpConfirmations } from '../app/mcp/jobs'; +import { task } from '@ultimat3/jobs'; +import { sendDigest } from '../jobs/send-digest'; /** * Fires once, in UTC, before any member's 09:00 — the fan-out then schedules each member for @@ -25,13 +24,3 @@ export const nightlyDigest = task({ [sendDigest, { runDate: localDateIn(new Date(occurrenceMs), 'UTC') }], ], }); - -/** - * The retention sweep, hourly at the framework's own minute (`DEFAULT_PURGE_CRON`). The job's key - * is fixed, so a tick that fires while the last pass is still running is that same pass. - */ -export const hourlyPurge = task({ - cron: DEFAULT_PURGE_CRON, - tz: 'UTC', - enqueue: () => [[purgeMcpConfirmations, {}]], -}); diff --git a/examples/dummy/apps/web/app/feed/live.ts b/examples/dummy/apps/web/app/feed/live.ts index 7d14dcd38..1f059f766 100644 --- a/examples/dummy/apps/web/app/feed/live.ts +++ b/examples/dummy/apps/web/app/feed/live.ts @@ -2,7 +2,7 @@ * The feed's live query, as the BROWSER can name it. * * `useQuery({ name: LIVE_FEED, live: true }, { orgId })` takes a name and the declaration's - * live-ness — never the query VALUE: importing `liveFeed` drags `app/posts/live.ts` → `repo.ts` → + * live-ness — never the query VALUE: importing `liveFeed` drags `app/posts/live/live-feed.ts` → `repo.ts` → * the whole read path into the client bundle. Measured, `Bun.build --target browser` over the old * `./hooks.ts`: **698,801 bytes**, against this route's 60 kB budget. The name crosses, the * implementation never does — the same seam `shared/client.ts` uses for reads and writes. diff --git a/examples/dummy/apps/web/app/layout.tsx b/examples/dummy/apps/web/app/layout.tsx index 715bc98d1..297dab37c 100644 --- a/examples/dummy/apps/web/app/layout.tsx +++ b/examples/dummy/apps/web/app/layout.tsx @@ -22,7 +22,7 @@ import { viewerOf } from '../shared/viewer'; import styles from './layout.module.scss'; import { ViewerProvider } from './viewer-context'; -/** `endSession` → `POST /api/sessions/end`, derived — `app/auth/actions.ts` sets the cookie. */ +/** `endSession` → `POST /api/sessions/end`, derived — `app/auth/actions/end-session.ts` sets the cookie. */ const SIGN_OUT_ENDPOINT = derivePath('endSession' satisfies keyof Api['actions']).path; const BANNER_PROPS = ['label', 'action'] as const; @@ -59,7 +59,7 @@ export function Layout(props: { {/* No org switcher yet. `@postly/ui`'s `OrgSwitcher` posts a native form to a path nothing serves (`/_x/session/org`, until 2026-08). A session write IS an action's to make — - `app/auth/actions.ts`' sign-out below sets its cookie on `ctx.headers` — so switching + `app/auth/actions/`' sign-out below sets its cookie on `ctx.headers` — so switching org is the same shape, one more action, when an org switcher earns its place. */}