6 Commits

Author SHA1 Message Date
322741e00c Document npm registry endpoints and auth gotchas
All checks were successful
continuous-integration/drone/push Build is passing
Add the exact pip/npm registry endpoints and secret names, the
scope-scoped (not global) registry requirement given Verdaccio's missing
uplink, the host-scoped _auth rule, the per-item Drone shell caveat, and
the Verdaccio storage uid/permissions gotcha behind the confusing
"404 no such package available" error on publish.
2026-10-21 01:31:07 +05:30
da1e46a921 Scope npm registry in drone template to @aetoskia
The registry was set globally (
egistry=...), which forced all public
transitive deps through the private Verdaccio. Verdaccio has no uplink,
so
pm install 404'd on public packages. Scope the registry to
@aetoskia:registry=... so public deps resolve from npmjs and only
@aetoskia/* go to the private registry.
2026-10-21 01:29:00 +05:30
08a9983984 Fix npm .npmrc: fold NPM_HOST into the same shell as its use
Drone runs each command list item in a separate shell, so a
standalone NPM_HOST= line is empty when the next item references it,
writing //:_auth= (wrong host) and causing a registry 401. Compute
NPM_HOST in the same | block that writes .npmrc.
2026-10-21 01:09:23 +05:30
e1e5daa30a Fix npm registry auth: use scoped //host/:_auth in .npmrc
npm rejects a bare _auth key (ERR_INVALID_AUTH) against a private
registry; derive the host from NPM_REPO_URL and write
//<host>/:_auth=<base64> instead.
2026-10-21 01:03:04 +05:30
5a1204fbcb Add npm backfill pipeline, shared htpasswd registry auth docs
All checks were successful
continuous-integration/drone/push Build is passing
- drone npm template gains backfill-npm-from-tags (mirrors PyPI)
- document the shared /auth/htpasswd credential model (npm + PyPI +
  docker) and Verdaccio's bcrypt requirement
- point doc-weaver source/path at ../react/doc-weaver
2026-10-21 00:57:08 +05:30
7f951f3fe7 fix: repair cp1252-mangled em-dashes and ellipsis in the reference-app wiki
All checks were successful
continuous-integration/drone/push Build is passing
All four 06_reference_app pages round-tripped U+2014 (em-dash) and U+2026
(ellipsis) through cp1252 during earlier PowerShell edits; restored the
characters and regenerated the wiki HTML + MCP modules.
2026-10-20 23:48:08 +05:30
28 changed files with 498 additions and 128 deletions

View File

@@ -42,7 +42,7 @@ repos:
mcp: { bundle: docs/mcp, server: docforge, port: 8001 } mcp: { bundle: docs/mcp, server: docforge, port: 8001 }
- name: doc-weaver - name: doc-weaver
source: ../doc-weaver source: ../react/doc-weaver
title: Doc Weaver title: Doc Weaver
description: TypeScript documentation compiler using TypeDoc + JSDoc (RDFC), generating a TypeDoc site and a doc-forge-shaped MCP bundle for React packages. description: TypeScript documentation compiler using TypeDoc + JSDoc (RDFC), generating a TypeDoc site and a doc-forge-shaped MCP bundle for React packages.
section: libraries section: libraries

View File

@@ -733,9 +733,10 @@ with <code>from_secret</code> — never commit credentials.</p>
</ul> </ul>
<hr /> <hr />
<h2 id="type-b-npm-package-npm-libyaml">Type B — npm package (<code>npm-lib.yaml</code>)</h2> <h2 id="type-b-npm-package-npm-libyaml">Type B — npm package (<code>npm-lib.yaml</code>)</h2>
<p>For internal npm libraries (the React packages). One pipeline, <p>For internal npm libraries (the React packages). The file contains <strong>two
<strong><code>build-and-publish-npm</code></strong>, triggered on <code>tag</code>:</p> pipelines</strong>:</p>
<ul> <ol>
<li><strong><code>build-and-publish-npm</code></strong> — triggered on <code>tag</code>:</li>
<li><code>check-version</code> — <strong>guard</strong>: fail unless <code>$DRONE_TAG</code> equals the version in <li><code>check-version</code> — <strong>guard</strong>: fail unless <code>$DRONE_TAG</code> equals the version in
<code>package.json</code>; query <code>$NPM_REPO_URL</code> for <code>name@version</code>; if it already <code>package.json</code>; query <code>$NPM_REPO_URL</code> for <code>name@version</code>; if it already
exists, exit <code>78</code> and skip the whole build.</li> exists, exit <code>78</code> and skip the whole build.</li>
@@ -744,14 +745,35 @@ with <code>from_secret</code> — never commit credentials.</p>
<li><code>build-package</code> — <code>npm install &amp;&amp; npm run build</code> into <code>dist/</code>.</li> <li><code>build-package</code> — <code>npm install &amp;&amp; npm run build</code> into <code>dist/</code>.</li>
<li><code>publish-to-private-npm</code> — <code>npm publish --access restricted</code> to <li><code>publish-to-private-npm</code> — <code>npm publish --access restricted</code> to
<code>$NPM_REPO_URL</code>.</li> <code>$NPM_REPO_URL</code>.</li>
</ul> <li><strong><code>backfill-npm-from-tags</code></strong> — triggered on <code>custom</code>:</li>
<li><code>fetch-tags</code> — <code>git fetch --tags --force</code>.</li>
<li><code>build-and-publish-missing</code> — walk every tag in version order, skip the
ones already on the registry, <code>git checkout</code> the rest, verify
<code>tag == package.json version</code> at that commit, build, and publish. This is
the disaster-recovery / replay path.</li>
</ol>
<p>Secrets: <code>NPM_REPO_URL</code>, <code>NPM_USERNAME</code>, <code>NPM_PASSWORD</code>. Auth is a generated <p>Secrets: <code>NPM_REPO_URL</code>, <code>NPM_USERNAME</code>, <code>NPM_PASSWORD</code>. Auth is a generated
<code>.npmrc</code> (<code>registry=</code> + <code>_auth=</code> base64 of <code>user:pass</code>) written from secrets at <code>.npmrc</code> written from secrets at runtime. It sets a <strong>scope-scoped</strong> registry,
runtime — the committed <code>.npmrc</code> in a repo only points at the registry URL.</p> never a global one — <code>@aetoskia:registry=$NPM_REPO_URL</code> — plus a host-scoped
auth line <code>//&lt;host&gt;/:_auth=&lt;base64(user:pass)&gt;</code>. The scope and host are derived
from <code>package.json</code> and <code>NPM_REPO_URL</code> respectively. A global <code>registry=</code> would
push public transitive deps through Verdaccio (which has no uplink) and 404
<code>npm install</code>; a bare <code>_auth=</code> is rejected by modern npm. See
<a href="../93_library_registry/">93 – Library Registry</a>.</p>
<ul> <ul>
<li><strong>Ship as-is.</strong> Name and version come from <code>package.json</code>, so the same file <li><strong>Ship as-is.</strong> Name and version come from <code>package.json</code>, so the same file
is reused verbatim across every npm package.</li> is reused verbatim across every npm package.</li>
</ul> </ul>
<blockquote>
<p><strong>Registry credentials are shared.</strong> The private npm registry (Verdaccio at
<code>npm.aetoskia.com</code>) authenticates against the <strong>same <code>htpasswd</code> file</strong> as the
private PyPI index and the Docker registry (<code>/auth/htpasswd</code>), with
registration disabled (<code>max_users: -1</code>). <code>NPM_USERNAME</code> / <code>NPM_PASSWORD</code> are
therefore the <strong>same credentials as <code>PIP_USERNAME</code> / <code>PIP_PASSWORD</code></strong> — no
separate npm account exists. Verdaccio's htpasswd plugin accepts <strong>bcrypt</strong>
hashes (<code>$2y$…</code>); non-bcrypt entries that pypiserver tolerates will not
authenticate against npm. See <a href="../93_library_registry/">93 – Library Registry</a>.</p>
</blockquote>
<hr /> <hr />
<h2 id="type-c-docker-deployment-service-dockeryaml">Type C — Docker deployment (<code>service-docker.yaml</code>)</h2> <h2 id="type-c-docker-deployment-service-dockeryaml">Type C — Docker deployment (<code>service-docker.yaml</code>)</h2>
<p>For a service that ships a container image — a long-running API/worker, e.g. <p>For a service that ships a container image — a long-running API/worker, e.g.
@@ -821,8 +843,8 @@ triggered on <code>tag</code>:</p>
<code>package.json</code> version; a service needs a root <code>VERSION</code> file. See <code>package.json</code> version; a service needs a root <code>VERSION</code> file. See
<a href="../91_versioning_and_changelog/">91 – Versioning &amp; Changelog</a>.</li> <a href="../91_versioning_and_changelog/">91 – Versioning &amp; Changelog</a>.</li>
<li>Do not rename the pipelines; tooling and runbooks key off <li>Do not rename the pipelines; tooling and runbooks key off
<code>build-and-publish-pypi</code>, <code>build-and-publish-npm</code>, and <code>build-and-publish-pypi</code>, <code>build-and-publish-npm</code>,
<code>build-and-deploy-service</code>.</li> <code>build-and-deploy-service</code>, and their <code>backfill-*-from-tags</code> companions.</li>
</ul> </ul>
<hr /> <hr />
<h2 id="related">Related</h2> <h2 id="related">Related</h2>

View File

@@ -626,6 +626,48 @@
</span> </span>
</a> </a>
</li>
<li class="md-nav__item">
<a href="#publishing-registry-auth" class="md-nav__link">
<span class="md-ellipsis">
Publishing &amp; registry auth
</span>
</a>
<nav class="md-nav" aria-label="Publishing &amp; registry auth">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#endpoints" class="md-nav__link">
<span class="md-ellipsis">
Endpoints
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#npm-scope-the-registry-never-set-it-globally" class="md-nav__link">
<span class="md-ellipsis">
npm: scope the registry, never set it globally
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#verdaccio-storage-permissions" class="md-nav__link">
<span class="md-ellipsis">
Verdaccio storage permissions
</span>
</a>
</li>
</ul>
</nav>
</li> </li>
<li class="md-nav__item"> <li class="md-nav__item">
@@ -821,6 +863,76 @@ matching library, its import name, its "use instead of" guidance, and its MCP
endpoint (or, for the auth-server service, a spec pointer). It also serves the endpoint (or, for the auth-server service, a spec pointer). It also serves the
pinned auth-server OpenAPI contract at <code>docs://spec</code>. Query it before writing pinned auth-server OpenAPI contract at <code>docs://spec</code>. Query it before writing
any infrastructure.</p> any infrastructure.</p>
<h2 id="publishing-registry-auth">Publishing &amp; registry auth</h2>
<p>Python packages publish to the private index (<code>pip.aetoskia.com</code>, <code>pypiserver</code>)
and npm packages to the private registry (<code>npm.aetoskia.com</code>, Verdaccio). Both
authenticate against the <strong>same <code>htpasswd</code> file</strong> (<code>/auth/htpasswd</code>) used by the
Docker registry and the Traefik basic-auth middleware; registration is disabled,
so the CI credentials (<code>PIP_USERNAME</code> / <code>PIP_PASSWORD</code>, <code>NPM_USERNAME</code> /
<code>NPM_PASSWORD</code>) are one and the same account — there is no separate npm user.
Verdaccio requires <strong>bcrypt</strong> htpasswd entries; see
<a href="../90_drone_pipelines/">90 – Drone Pipelines</a> for the CI wiring.</p>
<h3 id="endpoints">Endpoints</h3>
<table>
<thead>
<tr>
<th>Registry</th>
<th>URL</th>
<th>Backend</th>
<th>CI secret</th>
</tr>
</thead>
<tbody>
<tr>
<td>PyPI</td>
<td><code>https://pip.aetoskia.com</code></td>
<td>pypiserver (<code>server-pi:6007</code>)</td>
<td><code>PIP_REPO_URL</code></td>
</tr>
<tr>
<td>npm</td>
<td><code>https://npm.aetoskia.com</code></td>
<td>Verdaccio (<code>server-pi:6008</code>)</td>
<td><code>NPM_REPO_URL</code></td>
</tr>
</tbody>
</table>
<p>Both are exposed through Traefik on the <code>websecure</code> entrypoint as <strong>bare
origins</strong> — no path, no trailing slash. Set the secret values exactly as above.</p>
<h3 id="npm-scope-the-registry-never-set-it-globally">npm: scope the registry, never set it globally</h3>
<p>Verdaccio here has <strong>no uplink / proxy</strong>, so it can only serve <code>@aetoskia/*</code>
packages. A global <code>registry=https://npm.aetoskia.com</code> in <code>.npmrc</code> would route
<em>every</em> dependency — including public transitive deps such as
<code>zod-to-json-schema</code> (pulled in by <code>@modelcontextprotocol/sdk</code>) — through
Verdaccio, which 404s on anything it does not host. Always scope the registry to
the account:</p>
<div class="language-text highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-0-1">1</a></span>
<span class="normal"><a href="#__codelineno-0-2">2</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-0-1"><a id="__codelineno-0-1" name="__codelineno-0-1"></a>@aetoskia:registry=https://npm.aetoskia.com
</span><span id="__span-0-2"><a id="__codelineno-0-2" name="__codelineno-0-2"></a>//npm.aetoskia.com/:_auth=&lt;base64(user:password)&gt;
</span></code></pre></div></td></tr></table></div>
<p>Public deps then resolve from <code>registry.npmjs.org</code> and only <code>@aetoskia/*</code> hits
the private registry. The auth line must be <strong>scoped to the host</strong>
(<code>//host/:_auth=</code>), not a bare <code>_auth=</code> — modern npm rejects the bare form with
<code>ERR_INVALID_AUTH</code>. The drone template derives both from the package scope and
<code>NPM_REPO_URL</code> automatically; see
<a href="../90_drone_pipelines/">90 – Drone Pipelines</a>.</p>
<blockquote>
<p>The Drone pipeline runs each <code>commands:</code> list item in a <strong>separate shell</strong>, so
shell variables defined in one item do not survive into the next. Keep the
variable definitions and the commands that use them in the same <code>|</code> block.</p>
</blockquote>
<h3 id="verdaccio-storage-permissions">Verdaccio storage permissions</h3>
<p>Verdaccio runs as uid <strong>10001</strong> (<code>verdaccio</code>) inside <code>verdaccio/verdaccio</code>.
The <code>storage</code> directory is bind-mounted from the server
(<code>/mnt/omnissiah-vault/data/verdaccio/storage</code>); if it is owned by <code>root:root</code>
(mode <code>755</code>) the process cannot write to it and every publish fails with a
confusing <code>404 no such package available</code> (<code>NotFoundError</code> from
<code>local-storage</code>). Ensure the directory — and its <code>plugins</code> subdirectory — are
owned by <code>10001:101</code>:</p>
<div class="language-sh highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-1-1">1</a></span>
<span class="normal"><a href="#__codelineno-1-2">2</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1"></a>sudo<span class="w"> </span>chown<span class="w"> </span>-R<span class="w"> </span><span class="m">10001</span>:101<span class="w"> </span>/mnt/omnissiah-vault/data/verdaccio/storage
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2"></a>docker<span class="w"> </span>restart<span class="w"> </span>npm-registry
</span></code></pre></div></td></tr></table></div>
<h2 id="notes">Notes</h2> <h2 id="notes">Notes</h2>
<ul> <ul>
<li><strong>Hexa</strong> is a full library with its own MCP server on <code>:8008</code>: read <li><strong>Hexa</strong> is a full library with its own MCP server on <code>:8008</code>: read

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"module": "wiki_development_releasing", "module": "wiki_development_releasing",
"content": { "content": {
"path": "wiki_development_releasing", "path": "wiki_development_releasing",
"docstring": "# Releasing & Versioning\n\nHow to cut a release of any Aetos repository. The rule lives in the reference\npage [Versioning & Changelog](../lib/91_versioning_and_changelog/); this is the\nrunbook you follow.\n\nEvery repository is one of two Drone shapes — a **PyPI package** or a **Docker\ndeployment** ([Drone Pipelines](../lib/90_drone_pipelines/)). Both release the\nsame way: the version bump and changelog promotion go **in the merge request**,\nand the tag is created on the merged commit.\n\n---\n\n## 🧾 Before you start\n\n- You have an open **merge request** holding the change, and `main` is green\n (the Drone quality gate passed on the last run).\n- You know whether this is a **breaking**, **feature**, or **fix** release.\n- The version file exists:\n - package → `version` in `pyproject.toml`\n - service → `VERSION` at the repo root\n\n---\n\n## 🚦 Pick the next version\n\nThe version file and the tag must be identical. For `0.y.z`, `0.y` is the major\nline (see the reference page); once at `1.0.0`, normal SemVer applies.\n\n| Change | `0.y.z` → | `1.0.0+` → |\n|--------|-----------|------------|\n| Breaking | `0.(y+1).0` | `(x+1).0.0` |\n| Feature | `0.y.(z+1)` | `x.(y+1).0` |\n| Fix | `0.y.(z+1)` | `x.y.(z+1)` |\n\n---\n\n## 🌿 Release in the MR\n\nThe version is known while the change is still a merge request, so the bump\nrides along with it. Do all edits **in the MR**:\n\n- **Reconcile the changelog first.** `[Unreleased]` is a **draft, not the source\n of truth**: diff the change against the last tag\n (`git log <last-tag>..HEAD`, `git diff --stat <last-tag>..HEAD`) and add an\n entry for every user-facing change that is missing. **Always show the proposed\n additions to the user and wait for approval** before promoting — ask rather\n than guess when an entry is ambiguous.\n\n- **Promote the changelog** in `CHANGELOG.md`: move the (now reconciled)\n `[Unreleased]` content under a new `## [x.y.z] - YYYY-MM-DD` heading, leave a\n fresh empty `[Unreleased]` on top, and add the compare links — naming the\n **version/tag**, never a commit id:\n\n ```markdown\n [Unreleased]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.z...HEAD\n [x.y.z]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.(z-1)...x.y.z\n ```\n\n- **Bump the version file** to the intended release version: `version = \"x.y.z\"`\n in `pyproject.toml` (packages) or the `VERSION` file (services).\n\n---\n\n## 🏷️ Tag the merged commit\n\n1. **Merge or squash** the MR into `main`.\n2. **Create the tag** on the resulting commit: `git tag x.y.z` (lightweight).\n3. **Push the tag** (`git push origin x.y.z`) — Drone builds the release.\n\n### 🤖 Agent-assisted tagging\n\nThe agent does not author or merge the MR and never tags before the merge. After\nyou confirm the MR is merged/squashed and tell it to proceed, the agent:\n\n1. Waits for that explicit go-ahead.\n2. Finds the commit that introduced the bump:\n - package:\n `git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml`\n - service:\n `git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION`\n3. Verifies that commit's version file equals `x.y.z`, then tags **only** that\n commit: `git tag x.y.z <sha>`.\n4. Does **not** push — it asks you to run `git push origin x.y.z`.\n\n---\n\n## 🤖 What Drone does\n\n- **Type A (package):** asserts `tag == pyproject version`, then skips if that\n version already exists on the index, runs the quality gate, builds, and\n uploads the wheel + sdist.\n- **Type B (service):** asserts `tag == VERSION`, resolves the tag, skips if the\n image already exists, runs the quality gate, builds, pushes, and restarts the\n container.\n\nIf the guard fails, nothing is published — fix the version file, amend the\ncommit, and move the tag **only** if it was never pushed.\n\n---\n\n## 🛟 Backfill / recovery\n\nIf an older tag never published (a past version-file mismatch, an index\noutage):\n\n- **Package:** run the `backfill-pypi-from-tags` pipeline (custom event). It\n checks out every tag, verifies `tag == pyproject version` at that commit,\n and uploads the ones missing from the index. Already-fixed history is never\n rewritten.\n- **Service:** rebuild the image for the missing tag with a fresh commit and a\n new patch version; do not move a published tag.\n\n---\n\n## 🔁 If a release is wrong\n\n- Never reuse or force-move a pushed tag.\n- Fix forward: bump to the next patch, promote the changelog, tag again.\n\n---\n\n## Related\n\n- [Testing & Quality Gates](01_testing.md) · [Versioning & Changelog](../lib/91_versioning_and_changelog/) · [Drone Pipelines](../lib/90_drone_pipelines/)\n", "docstring": "# Releasing & Versioning\n\nHow to cut a release of any Aetos repository. The rule lives in the reference\npage [Versioning & Changelog](../lib/91_versioning_and_changelog/); this is the\nrunbook you follow.\n\nEvery repository is one of two Drone shapes — a **PyPI package** or a **Docker\ndeployment** ([Drone Pipelines](../lib/90_drone_pipelines/)). Both release the\nsame way: the version bump and changelog promotion go **in the merge request**,\nand the tag is created on the merged commit.\n\n---\n\n## 🧾 Before you start\n\n- You have an open **merge request** holding the change, and `main` is green\n (the Drone quality gate passed on the last run).\n- You know whether this is a **breaking**, **feature**, or **fix** release.\n- The version file exists:\n - package → `version` in `pyproject.toml`\n - service → `VERSION` at the repo root\n\n---\n\n## 🚦 Pick the next version\n\nThe version file and the tag must be identical. For `0.y.z`, `0.y` is the major\nline (see the reference page); once at `1.0.0`, normal SemVer applies.\n\n| Change | `0.y.z` → | `1.0.0+` → |\n|--------|-----------|------------|\n| Breaking | `0.(y+1).0` | `(x+1).0.0` |\n| Feature | `0.y.(z+1)` | `x.(y+1).0` |\n| Fix | `0.y.(z+1)` | `x.y.(z+1)` |\n\n---\n\n## 🌿 Release in the MR\n\nThe version is known while the change is still a merge request, so the bump\nrides along with it. Do all edits **in the MR**:\n\n- **Reconcile the changelog first.** `[Unreleased]` is a **draft, not the source\n of truth**: diff the change against the last tag\n (`git log <last-tag>..HEAD`, `git diff --stat <last-tag>..HEAD`) and add an\n entry for every user-facing change that is missing. **Always show the proposed\n additions to the user and wait for approval** before promoting — ask rather\n than guess when an entry is ambiguous.\n\n- **Promote the changelog** in `CHANGELOG.md`: move the (now reconciled)\n `[Unreleased]` content under a new `## [x.y.z] - YYYY-MM-DD` heading, leave a\n fresh empty `[Unreleased]` on top, and add the compare links — naming the\n **version/tag**, never a commit id:\n\n ```markdown\n [Unreleased]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.z...HEAD\n [x.y.z]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.(z-1)...x.y.z\n ```\n\n- **Bump the version file** to the intended release version: `version = \"x.y.z\"`\n in `pyproject.toml` (packages) or the `VERSION` file (services).\n\n---\n\n## 🏷️ Tag the merged commit\n\n1. **Merge or squash** the MR into `main`.\n2. **Create the tag** on the resulting commit: `git tag x.y.z` (lightweight).\n3. **Push the tag** (`git push origin x.y.z`) — Drone builds the release.\n\n### 🤖 Agent-assisted tagging\n\nThe agent does not author or merge the MR and never tags before the merge. After\nyou confirm the MR is merged/squashed and tell it to proceed, the agent:\n\n1. Waits for that explicit go-ahead.\n2. Finds the commit that introduced the bump:\n - package:\n `git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml`\n - service:\n `git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION`\n3. Verifies that commit's version file equals `x.y.z`, then tags **only** that\n commit: `git tag x.y.z <sha>`.\n4. Does **not** push — it asks you to run `git push origin x.y.z`.\n\n---\n\n## 🤖 What Drone does\n\n- **Type A (package):** asserts `tag == pyproject version`, then skips if that\n version already exists on the index, runs the quality gate, builds, and\n uploads the wheel + sdist.\n- **Type B (service):** asserts `tag == VERSION`, resolves the tag, skips if the\n image already exists, runs the quality gate, builds, pushes, and restarts the\n container.\n\nIf the guard fails, nothing is published — fix the version file, amend the\ncommit, and move the tag **only** if it was never pushed.\n\n---\n\n## 🛟 Backfill / recovery\n\nIf an older tag never published (a past version-file mismatch, an index\noutage):\n\n- **Package:** run the matching `backfill-*-from-tags` pipeline (custom event) —\n `backfill-pypi-from-tags` for Python, `backfill-npm-from-tags` for npm. It\n checks out every tag, verifies `tag ==` the version file at that commit, and\n uploads the ones missing from the index. Already-fixed history is never\n rewritten.\n- **Service:** rebuild the image for the missing tag with a fresh commit and a\n new patch version; do not move a published tag.\n\n---\n\n## 🔁 If a release is wrong\n\n- Never reuse or force-move a pushed tag.\n- Fix forward: bump to the next patch, promote the changelog, tag again.\n\n---\n\n## Related\n\n- [Testing & Quality Gates](01_testing.md) · [Versioning & Changelog](../lib/91_versioning_and_changelog/) · [Drone Pipelines](../lib/90_drone_pipelines/)\n",
"objects": {} "objects": {}
} }
} }

View File

@@ -2,7 +2,7 @@
"module": "wiki_reference_app_client_admin", "module": "wiki_reference_app_client_admin",
"content": { "content": {
"path": "wiki_reference_app_client_admin", "path": "wiki_reference_app_client_admin",
"docstring": "# Client Admin — the spec-driven frontend\n\nThe client sample (`standards/src/templates/client/`) renders a full admin —\nauth pages, resource lists, forms, detail views — **from the server's\n`openapi.yaml`**, driven entirely by the x- extension fields. There are no\nhand-written screens per resource.\n\n## What ships\n\nA Vite + React + MUI app wiring the two Aetos packages:\n\n- **`@aetoskia/react-openapi`** — fetches `${VITE_API_BASE_URL}/openapi.json`,\n builds the router and the resource admin from the spec's schemas and x-\n fields (list columns, autocomplete, fk pickers, enum selects, hidden fields…)\n- **`@aetoskia/react-auth`** — login/register/logout against the auth-server\n paths declared in the spec's `securitySchemes.bearerAuth`, and attaches the\n JWT to every API call\n- a shared MUI theme (`src/shared-theme/`) and the shell\n (`Header` / `Footer` / `Home`)\n\n`src/openapi-config.ts` is the only configuration:\n\n```ts\nexport const specConfiguration: SpecConfiguration = {\n specUrl: `${apiBase}/openapi.json`,\n baseApiUrl: apiBase,\n title: \"Vet Admin\",\n getToken: () => tokenStore.get(),\n};\n```\n\n## Pair it with the sample server\n\n```bash\ncd apps/vet-sample-client\nprintf 'VITE_API_BASE_URL=http://localhost:8020\\nVITE_AUTH_BASE_URL=http://localhost:8020\\n' > .env.local\nnpm install\nnpm run dev\n```\n\nOpen the dev-server URL; the admin lists Parents, Pets, Treatments, Vets,\nAppointments and Jobs straight from the vet spec. Edit `openapi.yaml` on the\nserver and the client follows on reload — the spec is the contract on both\nsides.\n\n## Where the x- extensions come from\n\nThe canonical catalog lives in the react-openapi package\n(`spec-extensions.ts`, served to agents as the `specExtensions` module of the\nreact-openapi MCP bundle). The worked examples on the server side are the\n`Job` schema (enum selects via `x-edit-as: select` + `x-ui-type: enum`,\n`x-hidden` field masks, `x-list-columns`) and the FK fields on `Appointment`\n(`x-fk: {resource: pets, prefetch: true}`).\n\n## CI\n\nThe client ships a `drone-npm` shape (Type B in\n[Drone Pipelines](../lib/90_drone_pipelines/)) and follows the\n[Versioning](../lib/91_versioning/) standard (`package.json` +\n`CHANGELOG.md`).\n", "docstring": "# Client Admin — the spec-driven frontend\n\nThe client sample (`standards/src/templates/client/`) renders a full admin —\nauth pages, resource lists, forms, detail views — **from the server's\n`openapi.yaml`**, driven entirely by the x- extension fields. There are no\nhand-written screens per resource.\n\n## What ships\n\nA Vite + React + MUI app wiring the two Aetos packages:\n\n- **`@aetoskia/react-openapi`** — fetches `${VITE_API_BASE_URL}/openapi.json`,\n builds the router and the resource admin from the spec's schemas and x-\n fields (list columns, autocomplete, fk pickers, enum selects, hidden fields…)\n- **`@aetoskia/react-auth`** — login/register/logout against the auth-server\n paths declared in the spec's `securitySchemes.bearerAuth`, and attaches the\n JWT to every API call\n- a shared MUI theme (`src/shared-theme/`) and the shell\n (`Header` / `Footer` / `Home`)\n\n`src/openapi-config.ts` is the only configuration:\n\n```ts\nexport const specConfiguration: SpecConfiguration = {\n specUrl: `${apiBase}/openapi.json`,\n baseApiUrl: apiBase,\n title: \"Vet Admin\",\n getToken: () => tokenStore.get(),\n};\n```\n\n## Pair it with the sample server\n\n```bash\ncd apps/vet-sample-client\nprintf 'VITE_API_BASE_URL=http://localhost:8020\\nVITE_AUTH_BASE_URL=http://localhost:8020\\n' > .env.local\nnpm install\nnpm run dev\n```\n\nOpen the dev-server URL; the admin lists Parents, Pets, Treatments, Vets,\nAppointments and Jobs straight from the vet spec. Edit `openapi.yaml` on the\nserver and the client follows on reload — the spec is the contract on both\nsides.\n\n## Where the x- extensions come from\n\nThe canonical catalog lives in the react-openapi package\n(`spec-extensions.ts`, served to agents as the `specExtensions` module of the\nreact-openapi MCP bundle). The worked examples on the server side are the\n`Job` schema (enum selects via `x-edit-as: select` + `x-ui-type: enum`,\n`x-hidden` field masks, `x-list-columns`) and the FK fields on `Appointment`\n(`x-fk: {resource: pets, prefetch: true}`).\n\n## CI\n\nThe client ships a `drone-npm` shape (Type B in\n[Drone Pipelines](../lib/90_drone_pipelines/)) and follows the\n[Versioning](../lib/91_versioning/) standard (`package.json` +\n`CHANGELOG.md`).\n",
"objects": {} "objects": {}
} }
} }

View File

@@ -2,7 +2,7 @@
"module": "wiki_reference_app_domain_and_api", "module": "wiki_reference_app_domain_and_api",
"content": { "content": {
"path": "wiki_reference_app_domain_and_api", "path": "wiki_reference_app_domain_and_api",
"docstring": "# Domain & API — the resource axis\n\nEvery aggregate follows the same four-file slice\n([CRUD Template](../lib/21_crud_template/)); walk `parents` first, then\n`appointments` when you need foreign keys.\n\n## The slice, end to end (parents)\n\n```\ndomain/parents/\n models.py # framework-free Pydantic model (id optional)\n ports/\n db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo()\n service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service()\ninterfaces/parents/\n routes.py # list/create/get/update/delete — names == operationIds\n```\n\nWiring happens in exactly one place — the composition root\n`init.py`:\n\n```python\nparent_repo = create_parent_repo()\n_resources[\"parent_svc\"] = get_parent_service(parent_repo)\n```\n\nRoutes resolve the service from app state through the shared helper\n(`interfaces/_common.py`):\n\n```python\nasync def list_parents(request: Request, limit: int = 20, offset: int = 0) -> dict:\n docs = await svc(request, \"parent\").search({})\n return paginate([dump(d) for d in docs], limit, offset)\n```\n\n## The spec is the contract\n\n`openapi.yaml` is OpenAPI 3.1 and the\nsingle source of truth: `main.py` builds an `OpenAPIFirstApp` that binds each\n`operationId` to a function of the same name in `interfaces/openapi.py` (a flat\nre-export of every routes module). There are no decorator routers.\n\nThe x- extension fields on schemas and properties are what make the admin\nclient render itself — see [Auto-Rendered Admin](../lib/40_ui_admin/) and\nthe worked `Job` schema (enum selects, hidden fields, filterable columns).\n\n## Foreign keys (appointments)\n\n`Appointment` declares `pet`, `vet`, `treatment` as `dict | int | None`. The\nwrite path normalizes bare ids through `expand_ref`; the read path returns\nfully expanded objects:\n\n```python\npet = await expand_ref(svc(request, \"pet\"), body.get(\"pet\"), \"pet\")\n```\n\nThe list endpoint additionally filters by date prefix and embedded fk ids —\nclient-side over the repository result, the pattern for small reference data.\n\n## Ids on the wire\n\nThe domain models declare `id: int | None = None` and documents use the\n**integer-id** repository variants (`infra/db/repos/mongo_int.py`,\n`create_repo()` in `domain/*/ports/db.py`, `REPO_BACKEND=memory|mongo`). This\nkeeps the OpenAPI `id: integer` contract, the in-memory demo store, and the\nMongo persistence layer consistent.\n\n## Tests\n\n- `tests/integration/test_openapi_app_builds.py` — generic gate: every\n `operationId` resolves and the app builds. Copy it verbatim.\n- `tests/unit/test_jobs_routes.py` — the pattern for route unit tests with a\n service double (no HTTP client needed).\n", "docstring": "# Domain & API — the resource axis\n\nEvery aggregate follows the same four-file slice\n([CRUD Template](../lib/21_crud_template/)); walk `parents` first, then\n`appointments` when you need foreign keys.\n\n## The slice, end to end (parents)\n\n```\ndomain/parents/\n models.py # framework-free Pydantic model (id optional)\n ports/\n db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo()\n service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service()\ninterfaces/parents/\n routes.py # list/create/get/update/delete — names == operationIds\n```\n\nWiring happens in exactly one place — the composition root\n`init.py`:\n\n```python\nparent_repo = create_parent_repo()\n_resources[\"parent_svc\"] = get_parent_service(parent_repo)\n```\n\nRoutes resolve the service from app state through the shared helper\n(`interfaces/_common.py`):\n\n```python\nasync def list_parents(request: Request, limit: int = 20, offset: int = 0) -> dict:\n docs = await svc(request, \"parent\").search({})\n return paginate([dump(d) for d in docs], limit, offset)\n```\n\n## The spec is the contract\n\n`openapi.yaml` is OpenAPI 3.1 and the\nsingle source of truth: `main.py` builds an `OpenAPIFirstApp` that binds each\n`operationId` to a function of the same name in `interfaces/openapi.py` (a flat\nre-export of every routes module). There are no decorator routers.\n\nThe x- extension fields on schemas and properties are what make the admin\nclient render itself — see [Auto-Rendered Admin](../lib/40_ui_admin/) and\nthe worked `Job` schema (enum selects, hidden fields, filterable columns).\n\n## Foreign keys (appointments)\n\n`Appointment` declares `pet`, `vet`, `treatment` as `dict | int | None`. The\nwrite path normalizes bare ids through `expand_ref`; the read path returns\nfully expanded objects:\n\n```python\npet = await expand_ref(svc(request, \"pet\"), body.get(\"pet\"), \"pet\")\n```\n\nThe list endpoint additionally filters by date prefix and embedded fk ids —\nclient-side over the repository result, the pattern for small reference data.\n\n## Ids on the wire\n\nThe domain models declare `id: int | None = None` and documents use the\n**integer-id** repository variants (`infra/db/repos/mongo_int.py`,\n`create_repo()` in `domain/*/ports/db.py`, `REPO_BACKEND=memory|mongo`). This\nkeeps the OpenAPI `id: integer` contract, the in-memory demo store, and the\nMongo persistence layer consistent.\n\n## Tests\n\n- `tests/integration/test_openapi_app_builds.py` — generic gate: every\n `operationId` resolves and the app builds. Copy it verbatim.\n- `tests/unit/test_jobs_routes.py` — the pattern for route unit tests with a\n service double (no HTTP client needed).\n",
"objects": {} "objects": {}
} }
} }

View File

@@ -2,7 +2,7 @@
"module": "wiki_reference_app_overview", "module": "wiki_reference_app_overview",
"content": { "content": {
"path": "wiki_reference_app_overview", "path": "wiki_reference_app_overview",
"docstring": "# Reference App — Overview\n\nThe standards ship a **runnable sample** of the whole blueprint: the **vet**\nveterinary-clinic application. It exists to be copied — it is the reference\nfor how a real domain sits on the architecture, and the code an agent pulls\nthrough the MCP tools (`get_app_template`, `get_app_component`).\n\n!!! note \"One sample, two templates\"\n The **server** sample lives at `standards/src/templates/app/`\n (FastAPI + openapi-first + mongo-ops + hexa). The **client** sample lives\n at `standards/src/templates/client/` (Vite + MUI + `@aetoskia/react-openapi`\n + `@aetoskia/react-auth`). Together they are the full-stack reference.\n\n---\n\n## The domain at a glance\n\n| Aggregate | What it demonstrates |\n|---------------|---------------------------------------------------------------|\n| **parents** | The simplest CRUD slice (no foreign keys) — copy this first |\n| **pets** | Embedded list-of-refs (`parents[]`), file upload, enum fields |\n| **treatments**| Plain resource with pricing fields |\n| **vets** | Plain resource; target of prefetch FKs |\n| **appointments** | Single FKs (`pet`, `vet`, `treatment`) expanded on read, filtered list |\n| **jobs** | The worker's queue — durable status machine |\n\nThe pipeline axis ships one worked family, **`appointment_reminder`**, plus the\nreusable `pipelines/shared/` scaffold and a `worker.py` that dispatches jobs by\n`job.kind`.\n\n## Run it\n\n```bash\ncp -r <docs>/standards/src/templates/app apps/vet-sample\ncd apps/vet-sample\npip install -e \".[dev]\"\n\nuvicorn main:app --reload # API on :8020 (memory backend, zero services)\npython worker.py # background worker (polls jobs)\npython -m scripts.seed # optional: seed demo data (mongo backend)\n```\n\nThe default `REPO_BACKEND=memory` means the API boots with **no external\nservices** — repositories are process-local dictionaries. Switch to\n`REPO_BACKEND=mongo` for persistence (see `.env.example`).\n\nThe client pairs with it:\n\n```bash\ncp -r <docs>/standards/src/templates/client apps/vet-sample-client\ncd apps/vet-sample-client\necho \"VITE_API_BASE_URL=http://localhost:8020\" > .env.local\necho \"VITE_AUTH_BASE_URL=http://localhost:8020\" >> .env.local\nnpm install\nnpm run dev # Vite dev server\n```\n\n!!! warning \"Auth\"\n The server ships with the `bearerAuth` security scheme declared\n (`{AUTH_SERVER}` introspection). Without a reachable auth-server the API\n answers `401` on resource routes; `/health` stays public. For a local\n no-auth demo, remove the `security` block from `openapi.yaml` and the\n global security entry — see\n [Auth Integration](../lib/22_auth_integration/).\n\n## Next\n\n- [Domain & API](02_domain_and_api.md) — walking the resource axis\n- [Pipelines & Worker](03_pipelines_and_worker.md) — the pipeline axis\n- [Client Admin](04_client_admin.md) — the spec-driven frontend\n", "docstring": "# Reference App — Overview\n\nThe standards ship a **runnable sample** of the whole blueprint: the **vet**\nveterinary-clinic application. It exists to be copied — it is the reference\nfor how a real domain sits on the architecture, and the code an agent pulls\nthrough the MCP tools (`get_app_template`, `get_app_component`).\n\n!!! note \"One sample, two templates\"\n The **server** sample lives at `standards/src/templates/app/`\n (FastAPI + openapi-first + mongo-ops + hexa). The **client** sample lives\n at `standards/src/templates/client/` (Vite + MUI + `@aetoskia/react-openapi`\n + `@aetoskia/react-auth`). Together they are the full-stack reference.\n\n---\n\n## The domain at a glance\n\n| Aggregate | What it demonstrates |\n|---------------|---------------------------------------------------------------|\n| **parents** | The simplest CRUD slice (no foreign keys) — copy this first |\n| **pets** | Embedded list-of-refs (`parents[]`), file upload, enum fields |\n| **treatments**| Plain resource with pricing fields |\n| **vets** | Plain resource; target of prefetch FKs |\n| **appointments** | Single FKs (`pet`, `vet`, `treatment`) expanded on read, filtered list |\n| **jobs** | The worker's queue — durable status machine |\n\nThe pipeline axis ships one worked family, **`appointment_reminder`**, plus the\nreusable `pipelines/shared/` scaffold and a `worker.py` that dispatches jobs by\n`job.kind`.\n\n## Run it\n\n```bash\ncp -r <docs>/standards/src/templates/app apps/vet-sample\ncd apps/vet-sample\npip install -e \".[dev]\"\n\nuvicorn main:app --reload # API on :8020 (memory backend, zero services)\npython worker.py # background worker (polls jobs)\npython -m scripts.seed # optional: seed demo data (mongo backend)\n```\n\nThe default `REPO_BACKEND=memory` means the API boots with **no external\nservices** — repositories are process-local dictionaries. Switch to\n`REPO_BACKEND=mongo` for persistence (see `.env.example`).\n\nThe client pairs with it:\n\n```bash\ncp -r <docs>/standards/src/templates/client apps/vet-sample-client\ncd apps/vet-sample-client\necho \"VITE_API_BASE_URL=http://localhost:8020\" > .env.local\necho \"VITE_AUTH_BASE_URL=http://localhost:8020\" >> .env.local\nnpm install\nnpm run dev # Vite dev server\n```\n\n!!! warning \"Auth\"\n The server ships with the `bearerAuth` security scheme declared\n (`{AUTH_SERVER}` introspection). Without a reachable auth-server the API\n answers `401` on resource routes; `/health` stays public. For a local\n no-auth demo, remove the `security` block from `openapi.yaml` and the\n global security entry — see\n [Auth Integration](../lib/22_auth_integration/).\n\n## Next\n\n- [Domain & API](02_domain_and_api.md) — walking the resource axis\n- [Pipelines & Worker](03_pipelines_and_worker.md) — the pipeline axis\n- [Client Admin](04_client_admin.md) — the spec-driven frontend\n",
"objects": {} "objects": {}
} }
} }

View File

@@ -2,7 +2,7 @@
"module": "wiki_reference_app_pipelines_and_worker", "module": "wiki_reference_app_pipelines_and_worker",
"content": { "content": {
"path": "wiki_reference_app_pipelines_and_worker", "path": "wiki_reference_app_pipelines_and_worker",
"docstring": "# Pipelines & Worker — the pipeline axis\n\nThe sample proves the full loop: **API creates a job document → worker polls →\npipeline family runs → job reaches a terminal status.**\n\n## The jobs slice\n\n`domain/jobs/` is an ordinary CRUD slice; its model is the durable state\nmachine the worker drives:\n\n```python\nclass Job(BaseModel):\n name: str\n kind: str # dispatch key\n status: Literal[\"pending\", \"processing\", \"done\", \"failed\"] = \"pending\"\n payload: dict | None = None\n retry_count: int = 0\n error_message: str | None = None\n```\n\n`POST /jobs` with `{\"name\": \"...\", \"kind\": \"appointment_reminder\",\n\"payload\": {\"appointment_id\": 1}}` enqueues work; `GET /jobs?status=...`\ninspects the queue.\n\n## The appointment_reminder family\n\n```\npipelines/appointment_reminder/\n abc.py # LoadPort / ComposePort / DeliverPort + root interface\n impl.py # concrete stages, wired root, register(\"appointment_reminder\", ...)\n pipeline.yaml # graph spec (nodes, edges, roots)\n resources/\n pipeline.yaml # runtime spec consumed by the loader\n patterns/reminder.yaml # message template fragments\npipelines/shared/\n ports.py loader.py registry.py # reused by every family\n```\n\nEach stage is a small, independently testable unit. The loader\n(`pipelines/shared/loader.py`) wires the root through `hexa.build` and merges\n`resources/patterns/*.yaml` into the stage-injected registry:\n\n```python\npipeline = get_appointment_reminder_pipeline(appointment_svc)\nreminder = await pipeline.run(appointment_id)\n```\n\nTo add a family: copy `appointment_reminder/`, replace the stages, call\n`register(kind, variant, root_cls)` — see\n[Pipeline Scaffold](../lib/50_pipeline_scaffold/).\n\n## The worker\n\n`worker.py` is the process that turns\ndocuments into execution:\n\n1. poll `job_svc.search({\"status\": \"pending\"})` on `WORKER_POLL_SECONDS`\n2. claim a job: `status → processing`\n3. dispatch through the `HANDLERS` table — `HANDLERS[job.kind](job, resources)`\n4. on success `status → done`; on exception `status → failed`,\n `retry_count += 1`, `error_message` captured; jobs at\n `WORKER_MAX_RETRIES` are skipped\n\n```python\nHANDLERS: dict[str, Callable] = {\n \"appointment_reminder\": run_appointment_reminder,\n # add yours here\n}\n```\n\nThe worker shares the composition root: `from init import init` gives it the\nsame wired services the API uses — one place to add a dependency.\n\n!!! note \"Same process model in production\"\n API and worker are separate processes (see `docker-compose.yaml`: `api`,\n `worker`, `mongo`). Any in-memory progress bus is per-process; to surface\n live progress through the API, swap the bus adaptor for a broker-backed\n one — see [Worker & Progress](../lib/51_worker_and_progress/).\n\n## Tests\n\n- `tests/unit/test_appointment_reminder_pipeline.py` — stage units + the wired\n root against a fake service.\n- `tests/unit/test_worker.py` — dispatch, done/failed transitions, retry\n exhaustion, using an in-memory job service double and a cancelled poll task.\n", "docstring": "# Pipelines & Worker — the pipeline axis\n\nThe sample proves the full loop: **API creates a job document → worker polls →\npipeline family runs → job reaches a terminal status.**\n\n## The jobs slice\n\n`domain/jobs/` is an ordinary CRUD slice; its model is the durable state\nmachine the worker drives:\n\n```python\nclass Job(BaseModel):\n name: str\n kind: str # dispatch key\n status: Literal[\"pending\", \"processing\", \"done\", \"failed\"] = \"pending\"\n payload: dict | None = None\n retry_count: int = 0\n error_message: str | None = None\n```\n\n`POST /jobs` with `{\"name\": \"...\", \"kind\": \"appointment_reminder\",\n\"payload\": {\"appointment_id\": 1}}` enqueues work; `GET /jobs?status=...`\ninspects the queue.\n\n## The appointment_reminder family\n\n```\npipelines/appointment_reminder/\n abc.py # LoadPort / ComposePort / DeliverPort + root interface\n impl.py # concrete stages, wired root, register(\"appointment_reminder\", ...)\n pipeline.yaml # graph spec (nodes, edges, roots)\n resources/\n pipeline.yaml # runtime spec consumed by the loader\n patterns/reminder.yaml # message template fragments\npipelines/shared/\n ports.py loader.py registry.py # reused by every family\n```\n\nEach stage is a small, independently testable unit. The loader\n(`pipelines/shared/loader.py`) wires the root through `hexa.build` and merges\n`resources/patterns/*.yaml` into the stage-injected registry:\n\n```python\npipeline = get_appointment_reminder_pipeline(appointment_svc)\nreminder = await pipeline.run(appointment_id)\n```\n\nTo add a family: copy `appointment_reminder/`, replace the stages, call\n`register(kind, variant, root_cls)` — see\n[Pipeline Scaffold](../lib/50_pipeline_scaffold/).\n\n## The worker\n\n`worker.py` is the process that turns\ndocuments into execution:\n\n1. poll `job_svc.search({\"status\": \"pending\"})` on `WORKER_POLL_SECONDS`\n2. claim a job: `status → processing`\n3. dispatch through the `HANDLERS` table — `HANDLERS[job.kind](job, resources)`\n4. on success `status → done`; on exception `status → failed`,\n `retry_count += 1`, `error_message` captured; jobs at\n `WORKER_MAX_RETRIES` are skipped\n\n```python\nHANDLERS: dict[str, Callable] = {\n \"appointment_reminder\": run_appointment_reminder,\n # add yours here\n}\n```\n\nThe worker shares the composition root: `from init import init` gives it the\nsame wired services the API uses — one place to add a dependency.\n\n!!! note \"Same process model in production\"\n API and worker are separate processes (see `docker-compose.yaml`: `api`,\n `worker`, `mongo`). Any in-memory progress bus is per-process; to surface\n live progress through the API, swap the bus adaptor for a broker-backed\n one — see [Worker & Progress](../lib/51_worker_and_progress/).\n\n## Tests\n\n- `tests/unit/test_appointment_reminder_pipeline.py` — stage units + the wired\n root against a fake service.\n- `tests/unit/test_worker.py` — dispatch, done/failed transitions, retry\n exhaustion, using an in-memory job service double and a cancelled poll task.\n",
"objects": {} "objects": {}
} }
} }

View File

@@ -38,7 +38,7 @@
{ {
"name": "doc-weaver", "name": "doc-weaver",
"type": "package", "type": "package",
"path": "doc-weaver", "path": "react/doc-weaver",
"version_source": "package.json", "version_source": "package.json",
"drone": "npm-lib.yaml", "drone": "npm-lib.yaml",
"trigger": "tag", "trigger": "tag",

View File

@@ -22,17 +22,18 @@ steps:
from_secret: NPM_PASSWORD from_secret: NPM_PASSWORD
commands: commands:
- apk add --no-cache curl - apk add --no-cache curl
- PACKAGE_NAME=$(node -p "require('./package.json').name")
- VERSION=$(node -p "require('./package.json').version")
- | - |
PACKAGE_NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
if [ "$DRONE_TAG" != "$VERSION" ]; then if [ "$DRONE_TAG" != "$VERSION" ]; then
echo "Tag '$DRONE_TAG' does not match package.json version '$VERSION'; refusing to publish." echo "Tag '$DRONE_TAG' does not match package.json version '$VERSION'; refusing to publish."
exit 1 exit 1
fi fi
- echo "registry=$NPM_REPO_URL" > /tmp/.npmrc NPM_HOST=$(echo "$NPM_REPO_URL" | sed -E 's|^[a-z]+://||; s|/*$||')
- echo "_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> /tmp/.npmrc NPM_SCOPE="@$(node -p "require('./package.json').name.split('/')[0].replace(/^@/,'')")"
- echo "Checking if $PACKAGE_NAME@$VERSION exists on $NPM_REPO_URL ..." echo "$NPM_SCOPE:registry=$NPM_REPO_URL" > /tmp/.npmrc
- | echo "//$NPM_HOST/:_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> /tmp/.npmrc
echo "Checking if $PACKAGE_NAME@$VERSION exists on $NPM_REPO_URL ..."
if npm view "$PACKAGE_NAME@$VERSION" version --userconfig /tmp/.npmrc >/dev/null 2>&1; then if npm view "$PACKAGE_NAME@$VERSION" version --userconfig /tmp/.npmrc >/dev/null 2>&1; then
echo "$PACKAGE_NAME@$VERSION already exists; skipping build." echo "$PACKAGE_NAME@$VERSION already exists; skipping build."
exit 78 exit 78
@@ -55,8 +56,10 @@ steps:
echo "NPM_REPO_URL / NPM_USERNAME / NPM_PASSWORD are not set" >&2 echo "NPM_REPO_URL / NPM_USERNAME / NPM_PASSWORD are not set" >&2
exit 1 exit 1
fi fi
- echo "registry=$NPM_REPO_URL" > .npmrc NPM_HOST=$(echo "$NPM_REPO_URL" | sed -E 's|^[a-z]+://||; s|/*$||')
- echo "_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> .npmrc NPM_SCOPE="@$(node -p "require('./package.json').name.split('/')[0].replace(/^@/,'')")"
echo "$NPM_SCOPE:registry=$NPM_REPO_URL" > .npmrc
echo "//$NPM_HOST/:_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> .npmrc
- npm install - npm install
- echo "Running quality gate..." - echo "Running quality gate..."
- npm run quality-gate - npm run quality-gate
@@ -79,11 +82,84 @@ steps:
NPM_PASSWORD: NPM_PASSWORD:
from_secret: NPM_PASSWORD from_secret: NPM_PASSWORD
commands: commands:
- echo "registry=$NPM_REPO_URL" > .npmrc - |
- echo "_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> .npmrc NPM_HOST=$(echo "$NPM_REPO_URL" | sed -E 's|^[a-z]+://||; s|/*$||')
NPM_SCOPE="@$(node -p "require('./package.json').name.split('/')[0].replace(/^@/,'')")"
echo "$NPM_SCOPE:registry=$NPM_REPO_URL" > .npmrc
echo "//$NPM_HOST/:_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> .npmrc
- echo "Publishing to private npm at $NPM_REPO_URL ..." - echo "Publishing to private npm at $NPM_REPO_URL ..."
- npm publish --access restricted - npm publish --access restricted
trigger: trigger:
event: event:
- tag - tag
---
kind: pipeline
type: docker
name: backfill-npm-from-tags
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
steps:
- name: fetch-tags
image: alpine/git
commands:
- git fetch --tags --force
- name: build-and-publish-missing
image: node:22-alpine
environment:
NPM_REPO_URL:
from_secret: NPM_REPO_URL
NPM_USERNAME:
from_secret: NPM_USERNAME
NPM_PASSWORD:
from_secret: NPM_PASSWORD
commands:
- apk add --no-cache git
- |
NPM_HOST=$(echo "$NPM_REPO_URL" | sed -E 's|^[a-z]+://||; s|/*$||')
NPM_SCOPE="@$(node -p "require('./package.json').name.split('/')[0].replace(/^@/,'')")"
echo "$NPM_SCOPE:registry=$NPM_REPO_URL" > /tmp/.npmrc
echo "//$NPM_HOST/:_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> /tmp/.npmrc
- |
set -e
PACKAGE_NAME=$(node -p "require('./package.json').name")
echo "Package: $PACKAGE_NAME"
for TAG in $(git tag --sort=version:refname); do
VERSION="$TAG"
echo "Version: $VERSION"
if npm view "$PACKAGE_NAME@$VERSION" version --userconfig /tmp/.npmrc >/dev/null 2>&1; then
echo "Exists, skipping"
continue
fi
git checkout --force "$TAG"
TAG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG" != "$TAG_VERSION" ]; then
echo "Tag $TAG != package.json version $TAG_VERSION at that commit; skipping"
continue
fi
echo "Building $VERSION"
rm -rf dist
npm install
npm run build
echo "Publishing $VERSION"
npm publish --access restricted --userconfig /tmp/.npmrc
done
trigger:
event:
- custom

View File

@@ -52,25 +52,46 @@ Secrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD`.
## Type B — npm package (`npm-lib.yaml`) ## Type B — npm package (`npm-lib.yaml`)
For internal npm libraries (the React packages). One pipeline, For internal npm libraries (the React packages). The file contains **two
**`build-and-publish-npm`**, triggered on `tag`: pipelines**:
- `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in 1. **`build-and-publish-npm`** — triggered on `tag`:
- `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in
`package.json`; query `$NPM_REPO_URL` for `name@version`; if it already `package.json`; query `$NPM_REPO_URL` for `name@version`; if it already
exists, exit `78` and skip the whole build. exists, exit `78` and skip the whole build.
- `quality-gate` — write `.npmrc` from the secrets, `npm install`, then - `quality-gate` — write `.npmrc` from the secrets, `npm install`, then
`npm run quality-gate`. `npm run quality-gate`.
- `build-package` — `npm install && npm run build` into `dist/`. - `build-package` — `npm install && npm run build` into `dist/`.
- `publish-to-private-npm` — `npm publish --access restricted` to - `publish-to-private-npm` — `npm publish --access restricted` to
`$NPM_REPO_URL`. `$NPM_REPO_URL`.
2. **`backfill-npm-from-tags`** — triggered on `custom`:
- `fetch-tags` — `git fetch --tags --force`.
- `build-and-publish-missing` — walk every tag in version order, skip the
ones already on the registry, `git checkout` the rest, verify
`tag == package.json version` at that commit, build, and publish. This is
the disaster-recovery / replay path.
Secrets: `NPM_REPO_URL`, `NPM_USERNAME`, `NPM_PASSWORD`. Auth is a generated Secrets: `NPM_REPO_URL`, `NPM_USERNAME`, `NPM_PASSWORD`. Auth is a generated
`.npmrc` (`registry=` + `_auth=` base64 of `user:pass`) written from secrets at `.npmrc` written from secrets at runtime. It sets a **scope-scoped** registry,
runtime — the committed `.npmrc` in a repo only points at the registry URL. never a global one — `@aetoskia:registry=$NPM_REPO_URL` — plus a host-scoped
auth line `//<host>/:_auth=<base64(user:pass)>`. The scope and host are derived
from `package.json` and `NPM_REPO_URL` respectively. A global `registry=` would
push public transitive deps through Verdaccio (which has no uplink) and 404
`npm install`; a bare `_auth=` is rejected by modern npm. See
[93 – Library Registry](93_library_registry.md).
- **Ship as-is.** Name and version come from `package.json`, so the same file - **Ship as-is.** Name and version come from `package.json`, so the same file
is reused verbatim across every npm package. is reused verbatim across every npm package.
> **Registry credentials are shared.** The private npm registry (Verdaccio at
> `npm.aetoskia.com`) authenticates against the **same `htpasswd` file** as the
> private PyPI index and the Docker registry (`/auth/htpasswd`), with
> registration disabled (`max_users: -1`). `NPM_USERNAME` / `NPM_PASSWORD` are
> therefore the **same credentials as `PIP_USERNAME` / `PIP_PASSWORD`** — no
> separate npm account exists. Verdaccio's htpasswd plugin accepts **bcrypt**
> hashes (`$2y$…`); non-bcrypt entries that pypiserver tolerates will not
> authenticate against npm. See [93 – Library Registry](93_library_registry.md).
--- ---
## Type C — Docker deployment (`service-docker.yaml`) ## Type C — Docker deployment (`service-docker.yaml`)
@@ -121,8 +142,8 @@ Placeholders to fill in per repo:
`package.json` version; a service needs a root `VERSION` file. See `package.json` version; a service needs a root `VERSION` file. See
[91 – Versioning & Changelog](91_versioning_and_changelog.md). [91 – Versioning & Changelog](91_versioning_and_changelog.md).
- Do not rename the pipelines; tooling and runbooks key off - Do not rename the pipelines; tooling and runbooks key off
`build-and-publish-pypi`, `build-and-publish-npm`, and `build-and-publish-pypi`, `build-and-publish-npm`,
`build-and-deploy-service`. `build-and-deploy-service`, and their `backfill-*-from-tags` companions.
--- ---

View File

@@ -53,6 +53,67 @@ endpoint (or, for the auth-server service, a spec pointer). It also serves the
pinned auth-server OpenAPI contract at `docs://spec`. Query it before writing pinned auth-server OpenAPI contract at `docs://spec`. Query it before writing
any infrastructure. any infrastructure.
## Publishing & registry auth
Python packages publish to the private index (`pip.aetoskia.com`, `pypiserver`)
and npm packages to the private registry (`npm.aetoskia.com`, Verdaccio). Both
authenticate against the **same `htpasswd` file** (`/auth/htpasswd`) used by the
Docker registry and the Traefik basic-auth middleware; registration is disabled,
so the CI credentials (`PIP_USERNAME` / `PIP_PASSWORD`, `NPM_USERNAME` /
`NPM_PASSWORD`) are one and the same account — there is no separate npm user.
Verdaccio requires **bcrypt** htpasswd entries; see
[90 – Drone Pipelines](90_drone_pipelines.md) for the CI wiring.
### Endpoints
| Registry | URL | Backend | CI secret |
|---|---|---|---|
| PyPI | `https://pip.aetoskia.com` | pypiserver (`server-pi:6007`) | `PIP_REPO_URL` |
| npm | `https://npm.aetoskia.com` | Verdaccio (`server-pi:6008`) | `NPM_REPO_URL` |
Both are exposed through Traefik on the `websecure` entrypoint as **bare
origins** — no path, no trailing slash. Set the secret values exactly as above.
### npm: scope the registry, never set it globally
Verdaccio here has **no uplink / proxy**, so it can only serve `@aetoskia/*`
packages. A global `registry=https://npm.aetoskia.com` in `.npmrc` would route
*every* dependency — including public transitive deps such as
`zod-to-json-schema` (pulled in by `@modelcontextprotocol/sdk`) — through
Verdaccio, which 404s on anything it does not host. Always scope the registry to
the account:
```
@aetoskia:registry=https://npm.aetoskia.com
//npm.aetoskia.com/:_auth=<base64(user:password)>
```
Public deps then resolve from `registry.npmjs.org` and only `@aetoskia/*` hits
the private registry. The auth line must be **scoped to the host**
(`//host/:_auth=`), not a bare `_auth=` — modern npm rejects the bare form with
`ERR_INVALID_AUTH`. The drone template derives both from the package scope and
`NPM_REPO_URL` automatically; see
[90 – Drone Pipelines](90_drone_pipelines.md).
> The Drone pipeline runs each `commands:` list item in a **separate shell**, so
> shell variables defined in one item do not survive into the next. Keep the
> variable definitions and the commands that use them in the same `|` block.
### Verdaccio storage permissions
Verdaccio runs as uid **10001** (`verdaccio`) inside `verdaccio/verdaccio`.
The `storage` directory is bind-mounted from the server
(`/mnt/omnissiah-vault/data/verdaccio/storage`); if it is owned by `root:root`
(mode `755`) the process cannot write to it and every publish fails with a
confusing `404 no such package available` (`NotFoundError` from
`local-storage`). Ensure the directory — and its `plugins` subdirectory — are
owned by `10001:101`:
```sh
sudo chown -R 10001:101 /mnt/omnissiah-vault/data/verdaccio/storage
docker restart npm-registry
```
## Notes ## Notes
- **Hexa** is a full library with its own MCP server on `:8008`: read - **Hexa** is a full library with its own MCP server on `:8008`: read

View File

@@ -26,7 +26,7 @@
{ {
"name": "doc-weaver", "name": "doc-weaver",
"type": "package", "type": "package",
"path": "doc-weaver", "path": "react/doc-weaver",
"version_source": "package.json", "version_source": "package.json",
"drone": "npm-lib.yaml", "drone": "npm-lib.yaml",
"trigger": "tag", "trigger": "tag",

View File

@@ -22,17 +22,18 @@ steps:
from_secret: NPM_PASSWORD from_secret: NPM_PASSWORD
commands: commands:
- apk add --no-cache curl - apk add --no-cache curl
- PACKAGE_NAME=$(node -p "require('./package.json').name")
- VERSION=$(node -p "require('./package.json').version")
- | - |
PACKAGE_NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
if [ "$DRONE_TAG" != "$VERSION" ]; then if [ "$DRONE_TAG" != "$VERSION" ]; then
echo "Tag '$DRONE_TAG' does not match package.json version '$VERSION'; refusing to publish." echo "Tag '$DRONE_TAG' does not match package.json version '$VERSION'; refusing to publish."
exit 1 exit 1
fi fi
- echo "registry=$NPM_REPO_URL" > /tmp/.npmrc NPM_HOST=$(echo "$NPM_REPO_URL" | sed -E 's|^[a-z]+://||; s|/*$||')
- echo "_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> /tmp/.npmrc NPM_SCOPE="@$(node -p "require('./package.json').name.split('/')[0].replace(/^@/,'')")"
- echo "Checking if $PACKAGE_NAME@$VERSION exists on $NPM_REPO_URL ..." echo "$NPM_SCOPE:registry=$NPM_REPO_URL" > /tmp/.npmrc
- | echo "//$NPM_HOST/:_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> /tmp/.npmrc
echo "Checking if $PACKAGE_NAME@$VERSION exists on $NPM_REPO_URL ..."
if npm view "$PACKAGE_NAME@$VERSION" version --userconfig /tmp/.npmrc >/dev/null 2>&1; then if npm view "$PACKAGE_NAME@$VERSION" version --userconfig /tmp/.npmrc >/dev/null 2>&1; then
echo "$PACKAGE_NAME@$VERSION already exists; skipping build." echo "$PACKAGE_NAME@$VERSION already exists; skipping build."
exit 78 exit 78
@@ -55,8 +56,10 @@ steps:
echo "NPM_REPO_URL / NPM_USERNAME / NPM_PASSWORD are not set" >&2 echo "NPM_REPO_URL / NPM_USERNAME / NPM_PASSWORD are not set" >&2
exit 1 exit 1
fi fi
- echo "registry=$NPM_REPO_URL" > .npmrc NPM_HOST=$(echo "$NPM_REPO_URL" | sed -E 's|^[a-z]+://||; s|/*$||')
- echo "_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> .npmrc NPM_SCOPE="@$(node -p "require('./package.json').name.split('/')[0].replace(/^@/,'')")"
echo "$NPM_SCOPE:registry=$NPM_REPO_URL" > .npmrc
echo "//$NPM_HOST/:_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> .npmrc
- npm install - npm install
- echo "Running quality gate..." - echo "Running quality gate..."
- npm run quality-gate - npm run quality-gate
@@ -79,11 +82,84 @@ steps:
NPM_PASSWORD: NPM_PASSWORD:
from_secret: NPM_PASSWORD from_secret: NPM_PASSWORD
commands: commands:
- echo "registry=$NPM_REPO_URL" > .npmrc - |
- echo "_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> .npmrc NPM_HOST=$(echo "$NPM_REPO_URL" | sed -E 's|^[a-z]+://||; s|/*$||')
NPM_SCOPE="@$(node -p "require('./package.json').name.split('/')[0].replace(/^@/,'')")"
echo "$NPM_SCOPE:registry=$NPM_REPO_URL" > .npmrc
echo "//$NPM_HOST/:_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> .npmrc
- echo "Publishing to private npm at $NPM_REPO_URL ..." - echo "Publishing to private npm at $NPM_REPO_URL ..."
- npm publish --access restricted - npm publish --access restricted
trigger: trigger:
event: event:
- tag - tag
---
kind: pipeline
type: docker
name: backfill-npm-from-tags
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
steps:
- name: fetch-tags
image: alpine/git
commands:
- git fetch --tags --force
- name: build-and-publish-missing
image: node:22-alpine
environment:
NPM_REPO_URL:
from_secret: NPM_REPO_URL
NPM_USERNAME:
from_secret: NPM_USERNAME
NPM_PASSWORD:
from_secret: NPM_PASSWORD
commands:
- apk add --no-cache git
- |
NPM_HOST=$(echo "$NPM_REPO_URL" | sed -E 's|^[a-z]+://||; s|/*$||')
NPM_SCOPE="@$(node -p "require('./package.json').name.split('/')[0].replace(/^@/,'')")"
echo "$NPM_SCOPE:registry=$NPM_REPO_URL" > /tmp/.npmrc
echo "//$NPM_HOST/:_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> /tmp/.npmrc
- |
set -e
PACKAGE_NAME=$(node -p "require('./package.json').name")
echo "Package: $PACKAGE_NAME"
for TAG in $(git tag --sort=version:refname); do
VERSION="$TAG"
echo "Version: $VERSION"
if npm view "$PACKAGE_NAME@$VERSION" version --userconfig /tmp/.npmrc >/dev/null 2>&1; then
echo "Exists, skipping"
continue
fi
git checkout --force "$TAG"
TAG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG" != "$TAG_VERSION" ]; then
echo "Tag $TAG != package.json version $TAG_VERSION at that commit; skipping"
continue
fi
echo "Building $VERSION"
rm -rf dist
npm install
npm run build
echo "Publishing $VERSION"
npm publish --access restricted --userconfig /tmp/.npmrc
done
trigger:
event:
- custom

View File

@@ -104,9 +104,10 @@ commit, and move the tag **only** if it was never pushed.
If an older tag never published (a past version-file mismatch, an index If an older tag never published (a past version-file mismatch, an index
outage): outage):
- **Package:** run the `backfill-pypi-from-tags` pipeline (custom event). It - **Package:** run the matching `backfill-*-from-tags` pipeline (custom event) —
checks out every tag, verifies `tag == pyproject version` at that commit, `backfill-pypi-from-tags` for Python, `backfill-npm-from-tags` for npm. It
and uploads the ones missing from the index. Already-fixed history is never checks out every tag, verifies `tag ==` the version file at that commit, and
uploads the ones missing from the index. Already-fixed history is never
rewritten. rewritten.
- **Service:** rebuild the image for the missing tag with a fresh commit and a - **Service:** rebuild the image for the missing tag with a fresh commit and a
new patch version; do not move a published tag. new patch version; do not move a published tag.

View File

@@ -1,7 +1,7 @@
# Reference App — Overview # Reference App — Overview
The standards ship a **runnable sample** of the whole blueprint: the **vet** The standards ship a **runnable sample** of the whole blueprint: the **vet**
veterinary-clinic application. It exists to be copied — it is the reference veterinary-clinic application. It exists to be copied — it is the reference
for how a real domain sits on the architecture, and the code an agent pulls for how a real domain sits on the architecture, and the code an agent pulls
through the MCP tools (`get_app_template`, `get_app_component`). through the MCP tools (`get_app_template`, `get_app_component`).
@@ -17,12 +17,12 @@ through the MCP tools (`get_app_template`, `get_app_component`).
| Aggregate | What it demonstrates | | Aggregate | What it demonstrates |
|---------------|---------------------------------------------------------------| |---------------|---------------------------------------------------------------|
| **parents** | The simplest CRUD slice (no foreign keys) — copy this first | | **parents** | The simplest CRUD slice (no foreign keys) — copy this first |
| **pets** | Embedded list-of-refs (`parents[]`), file upload, enum fields | | **pets** | Embedded list-of-refs (`parents[]`), file upload, enum fields |
| **treatments**| Plain resource with pricing fields | | **treatments**| Plain resource with pricing fields |
| **vets** | Plain resource; target of prefetch FKs | | **vets** | Plain resource; target of prefetch FKs |
| **appointments** | Single FKs (`pet`, `vet`, `treatment`) expanded on read, filtered list | | **appointments** | Single FKs (`pet`, `vet`, `treatment`) expanded on read, filtered list |
| **jobs** | The worker's queue — durable status machine | | **jobs** | The worker's queue — durable status machine |
The pipeline axis ships one worked family, **`appointment_reminder`**, plus the The pipeline axis ships one worked family, **`appointment_reminder`**, plus the
reusable `pipelines/shared/` scaffold and a `worker.py` that dispatches jobs by reusable `pipelines/shared/` scaffold and a `worker.py` that dispatches jobs by
@@ -41,7 +41,7 @@ python -m scripts.seed # optional: seed demo data (mongo backend)
``` ```
The default `REPO_BACKEND=memory` means the API boots with **no external The default `REPO_BACKEND=memory` means the API boots with **no external
services** — repositories are process-local dictionaries. Switch to services** — repositories are process-local dictionaries. Switch to
`REPO_BACKEND=mongo` for persistence (see `.env.example`). `REPO_BACKEND=mongo` for persistence (see `.env.example`).
The client pairs with it: The client pairs with it:
@@ -60,11 +60,11 @@ npm run dev # Vite dev server
(`{AUTH_SERVER}` introspection). Without a reachable auth-server the API (`{AUTH_SERVER}` introspection). Without a reachable auth-server the API
answers `401` on resource routes; `/health` stays public. For a local answers `401` on resource routes; `/health` stays public. For a local
no-auth demo, remove the `security` block from `openapi.yaml` and the no-auth demo, remove the `security` block from `openapi.yaml` and the
global security entry — see global security entry — see
[Auth Integration](../lib/22_auth_integration/). [Auth Integration](../lib/22_auth_integration/).
## Next ## Next
- [Domain & API](02_domain_and_api.md) — walking the resource axis - [Domain & API](02_domain_and_api.md) — walking the resource axis
- [Pipelines & Worker](03_pipelines_and_worker.md) — the pipeline axis - [Pipelines & Worker](03_pipelines_and_worker.md) — the pipeline axis
- [Client Admin](04_client_admin.md) — the spec-driven frontend - [Client Admin](04_client_admin.md) — the spec-driven frontend

View File

@@ -1,4 +1,4 @@
# Domain & API — the resource axis # Domain & API — the resource axis
Every aggregate follows the same four-file slice Every aggregate follows the same four-file slice
([CRUD Template](../lib/21_crud_template/)); walk `parents` first, then ([CRUD Template](../lib/21_crud_template/)); walk `parents` first, then
@@ -13,10 +13,10 @@ domain/parents/
db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo() db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo()
service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service() service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service()
interfaces/parents/ interfaces/parents/
routes.py # list/create/get/update/delete — names == operationIds routes.py # list/create/get/update/delete — names == operationIds
``` ```
Wiring happens in exactly one place — the composition root Wiring happens in exactly one place — the composition root
`init.py`: `init.py`:
```python ```python
@@ -41,7 +41,7 @@ single source of truth: `main.py` builds an `OpenAPIFirstApp` that binds each
re-export of every routes module). There are no decorator routers. re-export of every routes module). There are no decorator routers.
The x- extension fields on schemas and properties are what make the admin The x- extension fields on schemas and properties are what make the admin
client render itself — see [Auto-Rendered Admin](../lib/40_ui_admin/) and client render itself — see [Auto-Rendered Admin](../lib/40_ui_admin/) and
the worked `Job` schema (enum selects, hidden fields, filterable columns). the worked `Job` schema (enum selects, hidden fields, filterable columns).
## Foreign keys (appointments) ## Foreign keys (appointments)
@@ -54,7 +54,7 @@ fully expanded objects:
pet = await expand_ref(svc(request, "pet"), body.get("pet"), "pet") pet = await expand_ref(svc(request, "pet"), body.get("pet"), "pet")
``` ```
The list endpoint additionally filters by date prefix and embedded fk ids — The list endpoint additionally filters by date prefix and embedded fk ids —
client-side over the repository result, the pattern for small reference data. client-side over the repository result, the pattern for small reference data.
## Ids on the wire ## Ids on the wire
@@ -67,7 +67,7 @@ Mongo persistence layer consistent.
## Tests ## Tests
- `tests/integration/test_openapi_app_builds.py` — generic gate: every - `tests/integration/test_openapi_app_builds.py` — generic gate: every
`operationId` resolves and the app builds. Copy it verbatim. `operationId` resolves and the app builds. Copy it verbatim.
- `tests/unit/test_jobs_routes.py` — the pattern for route unit tests with a - `tests/unit/test_jobs_routes.py` — the pattern for route unit tests with a
service double (no HTTP client needed). service double (no HTTP client needed).

View File

@@ -1,4 +1,4 @@
# Pipelines & Worker — the pipeline axis # Pipelines & Worker — the pipeline axis
The sample proves the full loop: **API creates a job document → worker polls → The sample proves the full loop: **API creates a job document → worker polls →
pipeline family runs → job reaches a terminal status.** pipeline family runs → job reaches a terminal status.**
@@ -46,7 +46,7 @@ reminder = await pipeline.run(appointment_id)
``` ```
To add a family: copy `appointment_reminder/`, replace the stages, call To add a family: copy `appointment_reminder/`, replace the stages, call
`register(kind, variant, root_cls)` — see `register(kind, variant, root_cls)` — see
[Pipeline Scaffold](../lib/50_pipeline_scaffold/). [Pipeline Scaffold](../lib/50_pipeline_scaffold/).
## The worker ## The worker
@@ -56,7 +56,7 @@ documents into execution:
1. poll `job_svc.search({"status": "pending"})` on `WORKER_POLL_SECONDS` 1. poll `job_svc.search({"status": "pending"})` on `WORKER_POLL_SECONDS`
2. claim a job: `status → processing` 2. claim a job: `status → processing`
3. dispatch through the `HANDLERS` table — `HANDLERS[job.kind](job, resources)` 3. dispatch through the `HANDLERS` table — `HANDLERS[job.kind](job, resources)`
4. on success `status → done`; on exception `status → failed`, 4. on success `status → done`; on exception `status → failed`,
`retry_count += 1`, `error_message` captured; jobs at `retry_count += 1`, `error_message` captured; jobs at
`WORKER_MAX_RETRIES` are skipped `WORKER_MAX_RETRIES` are skipped
@@ -69,17 +69,17 @@ HANDLERS: dict[str, Callable] = {
``` ```
The worker shares the composition root: `from init import init` gives it the The worker shares the composition root: `from init import init` gives it the
same wired services the API uses — one place to add a dependency. same wired services the API uses — one place to add a dependency.
!!! note "Same process model in production" !!! note "Same process model in production"
API and worker are separate processes (see `docker-compose.yaml`: `api`, API and worker are separate processes (see `docker-compose.yaml`: `api`,
`worker`, `mongo`). Any in-memory progress bus is per-process; to surface `worker`, `mongo`). Any in-memory progress bus is per-process; to surface
live progress through the API, swap the bus adaptor for a broker-backed live progress through the API, swap the bus adaptor for a broker-backed
one — see [Worker & Progress](../lib/51_worker_and_progress/). one — see [Worker & Progress](../lib/51_worker_and_progress/).
## Tests ## Tests
- `tests/unit/test_appointment_reminder_pipeline.py` — stage units + the wired - `tests/unit/test_appointment_reminder_pipeline.py` — stage units + the wired
root against a fake service. root against a fake service.
- `tests/unit/test_worker.py` — dispatch, done/failed transitions, retry - `tests/unit/test_worker.py` — dispatch, done/failed transitions, retry
exhaustion, using an in-memory job service double and a cancelled poll task. exhaustion, using an in-memory job service double and a cancelled poll task.

View File

@@ -1,7 +1,7 @@
# Client Admin — the spec-driven frontend # Client Admin — the spec-driven frontend
The client sample (`standards/src/templates/client/`) renders a full admin — The client sample (`standards/src/templates/client/`) renders a full admin —
auth pages, resource lists, forms, detail views — **from the server's auth pages, resource lists, forms, detail views — **from the server's
`openapi.yaml`**, driven entirely by the x- extension fields. There are no `openapi.yaml`**, driven entirely by the x- extension fields. There are no
hand-written screens per resource. hand-written screens per resource.
@@ -9,10 +9,10 @@ hand-written screens per resource.
A Vite + React + MUI app wiring the two Aetos packages: A Vite + React + MUI app wiring the two Aetos packages:
- **`@aetoskia/react-openapi`** — fetches `${VITE_API_BASE_URL}/openapi.json`, - **`@aetoskia/react-openapi`** — fetches `${VITE_API_BASE_URL}/openapi.json`,
builds the router and the resource admin from the spec's schemas and x- builds the router and the resource admin from the spec's schemas and x-
fields (list columns, autocomplete, fk pickers, enum selects, hidden fields…) fields (list columns, autocomplete, fk pickers, enum selects, hidden fields…)
- **`@aetoskia/react-auth`** — login/register/logout against the auth-server - **`@aetoskia/react-auth`** — login/register/logout against the auth-server
paths declared in the spec's `securitySchemes.bearerAuth`, and attaches the paths declared in the spec's `securitySchemes.bearerAuth`, and attaches the
JWT to every API call JWT to every API call
- a shared MUI theme (`src/shared-theme/`) and the shell - a shared MUI theme (`src/shared-theme/`) and the shell
@@ -40,7 +40,7 @@ npm run dev
Open the dev-server URL; the admin lists Parents, Pets, Treatments, Vets, Open the dev-server URL; the admin lists Parents, Pets, Treatments, Vets,
Appointments and Jobs straight from the vet spec. Edit `openapi.yaml` on the Appointments and Jobs straight from the vet spec. Edit `openapi.yaml` on the
server and the client follows on reload — the spec is the contract on both server and the client follows on reload — the spec is the contract on both
sides. sides.
## Where the x- extensions come from ## Where the x- extensions come from

View File

@@ -1011,9 +1011,10 @@ commit, and move the tag <strong>only</strong> if it was never pushed.</p>
<p>If an older tag never published (a past version-file mismatch, an index <p>If an older tag never published (a past version-file mismatch, an index
outage):</p> outage):</p>
<ul> <ul>
<li><strong>Package:</strong> run the <code>backfill-pypi-from-tags</code> pipeline (custom event). It <li><strong>Package:</strong> run the matching <code>backfill-*-from-tags</code> pipeline (custom event) —
checks out every tag, verifies <code>tag == pyproject version</code> at that commit, <code>backfill-pypi-from-tags</code> for Python, <code>backfill-npm-from-tags</code> for npm. It
and uploads the ones missing from the index. Already-fixed history is never checks out every tag, verifies <code>tag ==</code> the version file at that commit, and
uploads the ones missing from the index. Already-fixed history is never
rewritten.</li> rewritten.</li>
<li><strong>Service:</strong> rebuild the image for the missing tag with a fresh commit and a <li><strong>Service:</strong> rebuild the image for the missing tag with a fresh commit and a
new patch version; do not move a published tag.</li> new patch version; do not move a published tag.</li>

View File

@@ -74,7 +74,7 @@
<div data-md-component="skip"> <div data-md-component="skip">
<a href="#reference-app-a-overview" class="md-skip"> <a href="#reference-app-overview" class="md-skip">
Skip to content Skip to content
</a> </a>
@@ -842,9 +842,9 @@
<h1 id="reference-app-a-overview">Reference App — Overview</h1> <h1 id="reference-app-overview">Reference App — Overview</h1>
<p>The standards ship a <strong>runnable sample</strong> of the whole blueprint: the <strong>vet</strong> <p>The standards ship a <strong>runnable sample</strong> of the whole blueprint: the <strong>vet</strong>
veterinary-clinic application. It exists to be copied — it is the reference veterinary-clinic application. It exists to be copied — it is the reference
for how a real domain sits on the architecture, and the code an agent pulls for how a real domain sits on the architecture, and the code an agent pulls
through the MCP tools (<code>get_app_template</code>, <code>get_app_component</code>).</p> through the MCP tools (<code>get_app_template</code>, <code>get_app_component</code>).</p>
<div class="admonition note"> <div class="admonition note">
@@ -866,7 +866,7 @@ at <code>standards/src/templates/client/</code> (Vite + MUI + <code>@aetoskia/re
<tbody> <tbody>
<tr> <tr>
<td><strong>parents</strong></td> <td><strong>parents</strong></td>
<td>The simplest CRUD slice (no foreign keys) — copy this first</td> <td>The simplest CRUD slice (no foreign keys) — copy this first</td>
</tr> </tr>
<tr> <tr>
<td><strong>pets</strong></td> <td><strong>pets</strong></td>
@@ -886,7 +886,7 @@ at <code>standards/src/templates/client/</code> (Vite + MUI + <code>@aetoskia/re
</tr> </tr>
<tr> <tr>
<td><strong>jobs</strong></td> <td><strong>jobs</strong></td>
<td>The worker's queue — durable status machine</td> <td>The worker's queue — durable status machine</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -909,7 +909,7 @@ reusable <code>pipelines/shared/</code> scaffold and a <code>worker.py</code> th
</span><span id="__span-0-7"><a id="__codelineno-0-7" name="__codelineno-0-7"></a>python<span class="w"> </span>-m<span class="w"> </span>scripts.seed<span class="w"> </span><span class="c1"># optional: seed demo data (mongo backend)</span> </span><span id="__span-0-7"><a id="__codelineno-0-7" name="__codelineno-0-7"></a>python<span class="w"> </span>-m<span class="w"> </span>scripts.seed<span class="w"> </span><span class="c1"># optional: seed demo data (mongo backend)</span>
</span></code></pre></div></td></tr></table></div> </span></code></pre></div></td></tr></table></div>
<p>The default <code>REPO_BACKEND=memory</code> means the API boots with <strong>no external <p>The default <code>REPO_BACKEND=memory</code> means the API boots with <strong>no external
services</strong> — repositories are process-local dictionaries. Switch to services</strong> — repositories are process-local dictionaries. Switch to
<code>REPO_BACKEND=mongo</code> for persistence (see <code>.env.example</code>).</p> <code>REPO_BACKEND=mongo</code> for persistence (see <code>.env.example</code>).</p>
<p>The client pairs with it:</p> <p>The client pairs with it:</p>
<div class="language-bash highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-1-1">1</a></span> <div class="language-bash highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-1-1">1</a></span>
@@ -930,14 +930,14 @@ services</strong> — repositories are process-local dictionaries. Switch t
(<code>{AUTH_SERVER}</code> introspection). Without a reachable auth-server the API (<code>{AUTH_SERVER}</code> introspection). Without a reachable auth-server the API
answers <code>401</code> on resource routes; <code>/health</code> stays public. For a local answers <code>401</code> on resource routes; <code>/health</code> stays public. For a local
no-auth demo, remove the <code>security</code> block from <code>openapi.yaml</code> and the no-auth demo, remove the <code>security</code> block from <code>openapi.yaml</code> and the
global security entry — see global security entry — see
<a href="../../../lib/22_auth_integration/">Auth Integration</a>.</p> <a href="../../../lib/22_auth_integration/">Auth Integration</a>.</p>
</div> </div>
<h2 id="next">Next</h2> <h2 id="next">Next</h2>
<ul> <ul>
<li><a href="../02_domain_and_api/">Domain &amp; API</a> — walking the resource axis</li> <li><a href="../02_domain_and_api/">Domain &amp; API</a> — walking the resource axis</li>
<li><a href="../03_pipelines_and_worker/">Pipelines &amp; Worker</a> — the pipeline axis</li> <li><a href="../03_pipelines_and_worker/">Pipelines &amp; Worker</a> — the pipeline axis</li>
<li><a href="../04_client_admin/">Client Admin</a> — the spec-driven frontend</li> <li><a href="../04_client_admin/">Client Admin</a> — the spec-driven frontend</li>
</ul> </ul>

View File

@@ -74,7 +74,7 @@
<div data-md-component="skip"> <div data-md-component="skip">
<a href="#domain-api-a-the-resource-axis" class="md-skip"> <a href="#domain-api-the-resource-axis" class="md-skip">
Skip to content Skip to content
</a> </a>
@@ -860,7 +860,7 @@
<h1 id="domain-api-a-the-resource-axis">Domain &amp; API — the resource axis</h1> <h1 id="domain-api-the-resource-axis">Domain &amp; API — the resource axis</h1>
<p>Every aggregate follows the same four-file slice <p>Every aggregate follows the same four-file slice
(<a href="../../../lib/21_crud_template/">CRUD Template</a>); walk <code>parents</code> first, then (<a href="../../../lib/21_crud_template/">CRUD Template</a>); walk <code>parents</code> first, then
<code>appointments</code> when you need foreign keys.</p> <code>appointments</code> when you need foreign keys.</p>
@@ -877,9 +877,9 @@
</span><span id="__span-0-4"><a id="__codelineno-0-4" name="__codelineno-0-4"></a> db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo() </span><span id="__span-0-4"><a id="__codelineno-0-4" name="__codelineno-0-4"></a> db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo()
</span><span id="__span-0-5"><a id="__codelineno-0-5" name="__codelineno-0-5"></a> service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service() </span><span id="__span-0-5"><a id="__codelineno-0-5" name="__codelineno-0-5"></a> service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service()
</span><span id="__span-0-6"><a id="__codelineno-0-6" name="__codelineno-0-6"></a>interfaces/parents/ </span><span id="__span-0-6"><a id="__codelineno-0-6" name="__codelineno-0-6"></a>interfaces/parents/
</span><span id="__span-0-7"><a id="__codelineno-0-7" name="__codelineno-0-7"></a> routes.py # list/create/get/update/delete — names == operationIds </span><span id="__span-0-7"><a id="__codelineno-0-7" name="__codelineno-0-7"></a> routes.py # list/create/get/update/delete — names == operationIds
</span></code></pre></div></td></tr></table></div> </span></code></pre></div></td></tr></table></div>
<p>Wiring happens in exactly one place — the composition root <p>Wiring happens in exactly one place — the composition root
<code>init.py</code>:</p> <code>init.py</code>:</p>
<div class="language-python highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-1-1">1</a></span> <div class="language-python highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-1-1">1</a></span>
<span class="normal"><a href="#__codelineno-1-2">2</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1"></a><span class="n">parent_repo</span> <span class="o">=</span> <span class="n">create_parent_repo</span><span class="p">()</span> <span class="normal"><a href="#__codelineno-1-2">2</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1"></a><span class="n">parent_repo</span> <span class="o">=</span> <span class="n">create_parent_repo</span><span class="p">()</span>
@@ -899,7 +899,7 @@ single source of truth: <code>main.py</code> builds an <code>OpenAPIFirstApp</co
<code>operationId</code> to a function of the same name in <code>interfaces/openapi.py</code> (a flat <code>operationId</code> to a function of the same name in <code>interfaces/openapi.py</code> (a flat
re-export of every routes module). There are no decorator routers.</p> re-export of every routes module). There are no decorator routers.</p>
<p>The x- extension fields on schemas and properties are what make the admin <p>The x- extension fields on schemas and properties are what make the admin
client render itself — see <a href="../../../lib/40_ui_admin/">Auto-Rendered Admin</a> and client render itself — see <a href="../../../lib/40_ui_admin/">Auto-Rendered Admin</a> and
the worked <code>Job</code> schema (enum selects, hidden fields, filterable columns).</p> the worked <code>Job</code> schema (enum selects, hidden fields, filterable columns).</p>
<h2 id="foreign-keys-appointments">Foreign keys (appointments)</h2> <h2 id="foreign-keys-appointments">Foreign keys (appointments)</h2>
<p><code>Appointment</code> declares <code>pet</code>, <code>vet</code>, <code>treatment</code> as <code>dict | int | None</code>. The <p><code>Appointment</code> declares <code>pet</code>, <code>vet</code>, <code>treatment</code> as <code>dict | int | None</code>. The
@@ -907,7 +907,7 @@ write path normalizes bare ids through <code>expand_ref</code>; the read path re
fully expanded objects:</p> fully expanded objects:</p>
<div class="language-python highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-3-1">1</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1"></a><span class="n">pet</span> <span class="o">=</span> <span class="k">await</span> <span class="n">expand_ref</span><span class="p">(</span><span class="n">svc</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="s2">&quot;pet&quot;</span><span class="p">),</span> <span class="n">body</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;pet&quot;</span><span class="p">),</span> <span class="s2">&quot;pet&quot;</span><span class="p">)</span> <div class="language-python highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-3-1">1</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1"></a><span class="n">pet</span> <span class="o">=</span> <span class="k">await</span> <span class="n">expand_ref</span><span class="p">(</span><span class="n">svc</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="s2">&quot;pet&quot;</span><span class="p">),</span> <span class="n">body</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;pet&quot;</span><span class="p">),</span> <span class="s2">&quot;pet&quot;</span><span class="p">)</span>
</span></code></pre></div></td></tr></table></div> </span></code></pre></div></td></tr></table></div>
<p>The list endpoint additionally filters by date prefix and embedded fk ids — <p>The list endpoint additionally filters by date prefix and embedded fk ids —
client-side over the repository result, the pattern for small reference data.</p> client-side over the repository result, the pattern for small reference data.</p>
<h2 id="ids-on-the-wire">Ids on the wire</h2> <h2 id="ids-on-the-wire">Ids on the wire</h2>
<p>The domain models declare <code>id: int | None = None</code> and documents use the <p>The domain models declare <code>id: int | None = None</code> and documents use the
@@ -917,9 +917,9 @@ keeps the OpenAPI <code>id: integer</code> contract, the in-memory demo store, a
Mongo persistence layer consistent.</p> Mongo persistence layer consistent.</p>
<h2 id="tests">Tests</h2> <h2 id="tests">Tests</h2>
<ul> <ul>
<li><code>tests/integration/test_openapi_app_builds.py</code> — generic gate: every <li><code>tests/integration/test_openapi_app_builds.py</code> — generic gate: every
<code>operationId</code> resolves and the app builds. Copy it verbatim.</li> <code>operationId</code> resolves and the app builds. Copy it verbatim.</li>
<li><code>tests/unit/test_jobs_routes.py</code> — the pattern for route unit tests with a <li><code>tests/unit/test_jobs_routes.py</code> — the pattern for route unit tests with a
service double (no HTTP client needed).</li> service double (no HTTP client needed).</li>
</ul> </ul>

View File

@@ -74,7 +74,7 @@
<div data-md-component="skip"> <div data-md-component="skip">
<a href="#pipelines-worker-a-the-pipeline-axis" class="md-skip"> <a href="#pipelines-worker-the-pipeline-axis" class="md-skip">
Skip to content Skip to content
</a> </a>
@@ -851,7 +851,7 @@
<h1 id="pipelines-worker-a-the-pipeline-axis">Pipelines &amp; Worker — the pipeline axis</h1> <h1 id="pipelines-worker-the-pipeline-axis">Pipelines &amp; Worker — the pipeline axis</h1>
<p>The sample proves the full loop: <strong>API creates a job document → worker polls → <p>The sample proves the full loop: <strong>API creates a job document → worker polls →
pipeline family runs → job reaches a terminal status.</strong></p> pipeline family runs → job reaches a terminal status.</strong></p>
<h2 id="the-jobs-slice">The jobs slice</h2> <h2 id="the-jobs-slice">The jobs slice</h2>
@@ -901,7 +901,7 @@ inspects the queue.</p>
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2"></a><span class="n">reminder</span> <span class="o">=</span> <span class="k">await</span> <span class="n">pipeline</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">appointment_id</span><span class="p">)</span> </span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2"></a><span class="n">reminder</span> <span class="o">=</span> <span class="k">await</span> <span class="n">pipeline</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">appointment_id</span><span class="p">)</span>
</span></code></pre></div></td></tr></table></div> </span></code></pre></div></td></tr></table></div>
<p>To add a family: copy <code>appointment_reminder/</code>, replace the stages, call <p>To add a family: copy <code>appointment_reminder/</code>, replace the stages, call
<code>register(kind, variant, root_cls)</code> — see <code>register(kind, variant, root_cls)</code> — see
<a href="../../../lib/50_pipeline_scaffold/">Pipeline Scaffold</a>.</p> <a href="../../../lib/50_pipeline_scaffold/">Pipeline Scaffold</a>.</p>
<h2 id="the-worker">The worker</h2> <h2 id="the-worker">The worker</h2>
<p><code>worker.py</code> is the process that turns <p><code>worker.py</code> is the process that turns
@@ -909,7 +909,7 @@ documents into execution:</p>
<ol> <ol>
<li>poll <code>job_svc.search({"status": "pending"})</code> on <code>WORKER_POLL_SECONDS</code></li> <li>poll <code>job_svc.search({"status": "pending"})</code> on <code>WORKER_POLL_SECONDS</code></li>
<li>claim a job: <code>status → processing</code></li> <li>claim a job: <code>status → processing</code></li>
<li>dispatch through the <code>HANDLERS</code> table — <code>HANDLERS[job.kind](job, resources)</code></li> <li>dispatch through the <code>HANDLERS</code> table — <code>HANDLERS[job.kind](job, resources)</code></li>
<li>on success <code>status → done</code>; on exception <code>status → failed</code>, <li>on success <code>status → done</code>; on exception <code>status → failed</code>,
<code>retry_count += 1</code>, <code>error_message</code> captured; jobs at <code>retry_count += 1</code>, <code>error_message</code> captured; jobs at
<code>WORKER_MAX_RETRIES</code> are skipped</li> <code>WORKER_MAX_RETRIES</code> are skipped</li>
@@ -923,19 +923,19 @@ documents into execution:</p>
</span><span id="__span-3-4"><a id="__codelineno-3-4" name="__codelineno-3-4"></a><span class="p">}</span> </span><span id="__span-3-4"><a id="__codelineno-3-4" name="__codelineno-3-4"></a><span class="p">}</span>
</span></code></pre></div></td></tr></table></div> </span></code></pre></div></td></tr></table></div>
<p>The worker shares the composition root: <code>from init import init</code> gives it the <p>The worker shares the composition root: <code>from init import init</code> gives it the
same wired services the API uses — one place to add a dependency.</p> same wired services the API uses — one place to add a dependency.</p>
<div class="admonition note"> <div class="admonition note">
<p class="admonition-title">Same process model in production</p> <p class="admonition-title">Same process model in production</p>
<p>API and worker are separate processes (see <code>docker-compose.yaml</code>: <code>api</code>, <p>API and worker are separate processes (see <code>docker-compose.yaml</code>: <code>api</code>,
<code>worker</code>, <code>mongo</code>). Any in-memory progress bus is per-process; to surface <code>worker</code>, <code>mongo</code>). Any in-memory progress bus is per-process; to surface
live progress through the API, swap the bus adaptor for a broker-backed live progress through the API, swap the bus adaptor for a broker-backed
one — see <a href="../../../lib/51_worker_and_progress/">Worker &amp; Progress</a>.</p> one — see <a href="../../../lib/51_worker_and_progress/">Worker &amp; Progress</a>.</p>
</div> </div>
<h2 id="tests">Tests</h2> <h2 id="tests">Tests</h2>
<ul> <ul>
<li><code>tests/unit/test_appointment_reminder_pipeline.py</code> — stage units + the wired <li><code>tests/unit/test_appointment_reminder_pipeline.py</code> — stage units + the wired
root against a fake service.</li> root against a fake service.</li>
<li><code>tests/unit/test_worker.py</code> — dispatch, done/failed transitions, retry <li><code>tests/unit/test_worker.py</code> — dispatch, done/failed transitions, retry
exhaustion, using an in-memory job service double and a cancelled poll task.</li> exhaustion, using an in-memory job service double and a cancelled poll task.</li>
</ul> </ul>

View File

@@ -74,7 +74,7 @@
<div data-md-component="skip"> <div data-md-component="skip">
<a href="#client-admin-a-the-spec-driven-frontend" class="md-skip"> <a href="#client-admin-the-spec-driven-frontend" class="md-skip">
Skip to content Skip to content
</a> </a>
@@ -851,18 +851,18 @@
<h1 id="client-admin-a-the-spec-driven-frontend">Client Admin — the spec-driven frontend</h1> <h1 id="client-admin-the-spec-driven-frontend">Client Admin — the spec-driven frontend</h1>
<p>The client sample (<code>standards/src/templates/client/</code>) renders a full admin — <p>The client sample (<code>standards/src/templates/client/</code>) renders a full admin —
auth pages, resource lists, forms, detail views — <strong>from the server's auth pages, resource lists, forms, detail views — <strong>from the server's
<code>openapi.yaml</code></strong>, driven entirely by the x- extension fields. There are no <code>openapi.yaml</code></strong>, driven entirely by the x- extension fields. There are no
hand-written screens per resource.</p> hand-written screens per resource.</p>
<h2 id="what-ships">What ships</h2> <h2 id="what-ships">What ships</h2>
<p>A Vite + React + MUI app wiring the two Aetos packages:</p> <p>A Vite + React + MUI app wiring the two Aetos packages:</p>
<ul> <ul>
<li><strong><code>@aetoskia/react-openapi</code></strong> — fetches <code>${VITE_API_BASE_URL}/openapi.json</code>, <li><strong><code>@aetoskia/react-openapi</code></strong> — fetches <code>${VITE_API_BASE_URL}/openapi.json</code>,
builds the router and the resource admin from the spec's schemas and x- builds the router and the resource admin from the spec's schemas and x-
fields (list columns, autocomplete, fk pickers, enum selects, hidden fields…)</li> fields (list columns, autocomplete, fk pickers, enum selects, hidden fields…)</li>
<li><strong><code>@aetoskia/react-auth</code></strong> — login/register/logout against the auth-server <li><strong><code>@aetoskia/react-auth</code></strong> — login/register/logout against the auth-server
paths declared in the spec's <code>securitySchemes.bearerAuth</code>, and attaches the paths declared in the spec's <code>securitySchemes.bearerAuth</code>, and attaches the
JWT to every API call</li> JWT to every API call</li>
<li>a shared MUI theme (<code>src/shared-theme/</code>) and the shell <li>a shared MUI theme (<code>src/shared-theme/</code>) and the shell
@@ -892,7 +892,7 @@ hand-written screens per resource.</p>
</span></code></pre></div></td></tr></table></div> </span></code></pre></div></td></tr></table></div>
<p>Open the dev-server URL; the admin lists Parents, Pets, Treatments, Vets, <p>Open the dev-server URL; the admin lists Parents, Pets, Treatments, Vets,
Appointments and Jobs straight from the vet spec. Edit <code>openapi.yaml</code> on the Appointments and Jobs straight from the vet spec. Edit <code>openapi.yaml</code> on the
server and the client follows on reload — the spec is the contract on both server and the client follows on reload — the spec is the contract on both
sides.</p> sides.</p>
<h2 id="where-the-x-extensions-come-from">Where the x- extensions come from</h2> <h2 id="where-the-x-extensions-come-from">Where the x- extensions come from</h2>
<p>The canonical catalog lives in the react-openapi package <p>The canonical catalog lives in the react-openapi package

File diff suppressed because one or more lines are too long