Compare commits
6 Commits
a7f0576f1d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 322741e00c | |||
| da1e46a921 | |||
| 08a9983984 | |||
| e1e5daa30a | |||
| 5a1204fbcb | |||
| 7f951f3fe7 |
@@ -42,7 +42,7 @@ repos:
|
||||
mcp: { bundle: docs/mcp, server: docforge, port: 8001 }
|
||||
|
||||
- name: doc-weaver
|
||||
source: ../doc-weaver
|
||||
source: ../react/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.
|
||||
section: libraries
|
||||
|
||||
@@ -733,25 +733,47 @@ with <code>from_secret</code> — never commit credentials.</p>
|
||||
</ul>
|
||||
<hr />
|
||||
<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,
|
||||
<strong><code>build-and-publish-npm</code></strong>, triggered on <code>tag</code>:</p>
|
||||
<ul>
|
||||
<p>For internal npm libraries (the React packages). The file contains <strong>two
|
||||
pipelines</strong>:</p>
|
||||
<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
|
||||
<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>
|
||||
<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>
|
||||
<li><code>quality-gate</code> — write <code>.npmrc</code> from the secrets, <code>npm install</code>, then
|
||||
<code>npm run quality-gate</code>.</li>
|
||||
<code>npm run quality-gate</code>.</li>
|
||||
<li><code>build-package</code> — <code>npm install && npm run build</code> into <code>dist/</code>.</li>
|
||||
<li><code>publish-to-private-npm</code> — <code>npm publish --access restricted</code> to
|
||||
<code>$NPM_REPO_URL</code>.</li>
|
||||
</ul>
|
||||
<code>$NPM_REPO_URL</code>.</li>
|
||||
<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
|
||||
<code>.npmrc</code> (<code>registry=</code> + <code>_auth=</code> base64 of <code>user:pass</code>) written from secrets at
|
||||
runtime — the committed <code>.npmrc</code> in a repo only points at the registry URL.</p>
|
||||
<code>.npmrc</code> written from secrets at runtime. It sets a <strong>scope-scoped</strong> registry,
|
||||
never a global one — <code>@aetoskia:registry=$NPM_REPO_URL</code> — plus a host-scoped
|
||||
auth line <code>//<host>/:_auth=<base64(user:pass)></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>
|
||||
<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>
|
||||
</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 />
|
||||
<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.
|
||||
@@ -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
|
||||
<a href="../91_versioning_and_changelog/">91 – Versioning & Changelog</a>.</li>
|
||||
<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-deploy-service</code>.</li>
|
||||
<code>build-and-publish-pypi</code>, <code>build-and-publish-npm</code>,
|
||||
<code>build-and-deploy-service</code>, and their <code>backfill-*-from-tags</code> companions.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="related">Related</h2>
|
||||
|
||||
@@ -626,6 +626,48 @@
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</li>
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="#publishing-registry-auth" class="md-nav__link">
|
||||
<span class="md-ellipsis">
|
||||
Publishing & registry auth
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="md-nav" aria-label="Publishing & 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 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
|
||||
pinned auth-server OpenAPI contract at <code>docs://spec</code>. Query it before writing
|
||||
any infrastructure.</p>
|
||||
<h2 id="publishing-registry-auth">Publishing & 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=<base64(user:password)>
|
||||
</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>
|
||||
<ul>
|
||||
<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
@@ -2,7 +2,7 @@
|
||||
"module": "wiki_development_releasing",
|
||||
"content": {
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "wiki_reference_app_client_admin",
|
||||
"content": {
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "wiki_reference_app_domain_and_api",
|
||||
"content": {
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "wiki_reference_app_overview",
|
||||
"content": {
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "wiki_reference_app_pipelines_and_worker",
|
||||
"content": {
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
{
|
||||
"name": "doc-weaver",
|
||||
"type": "package",
|
||||
"path": "doc-weaver",
|
||||
"path": "react/doc-weaver",
|
||||
"version_source": "package.json",
|
||||
"drone": "npm-lib.yaml",
|
||||
"trigger": "tag",
|
||||
|
||||
@@ -22,17 +22,18 @@ steps:
|
||||
from_secret: NPM_PASSWORD
|
||||
commands:
|
||||
- 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
|
||||
echo "Tag '$DRONE_TAG' does not match package.json version '$VERSION'; refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
- echo "registry=$NPM_REPO_URL" > /tmp/.npmrc
|
||||
- echo "_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> /tmp/.npmrc
|
||||
- echo "Checking if $PACKAGE_NAME@$VERSION exists on $NPM_REPO_URL ..."
|
||||
- |
|
||||
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
|
||||
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
|
||||
echo "$PACKAGE_NAME@$VERSION already exists; skipping build."
|
||||
exit 78
|
||||
@@ -55,8 +56,10 @@ steps:
|
||||
echo "NPM_REPO_URL / NPM_USERNAME / NPM_PASSWORD are not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
- 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
|
||||
- npm install
|
||||
- echo "Running quality gate..."
|
||||
- npm run quality-gate
|
||||
@@ -79,11 +82,84 @@ steps:
|
||||
NPM_PASSWORD:
|
||||
from_secret: NPM_PASSWORD
|
||||
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 ..."
|
||||
- npm publish --access restricted
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- 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
|
||||
|
||||
@@ -52,25 +52,46 @@ Secrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD`.
|
||||
|
||||
## Type B — npm package (`npm-lib.yaml`)
|
||||
|
||||
For internal npm libraries (the React packages). One pipeline,
|
||||
**`build-and-publish-npm`**, triggered on `tag`:
|
||||
For internal npm libraries (the React packages). The file contains **two
|
||||
pipelines**:
|
||||
|
||||
- `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in
|
||||
`package.json`; query `$NPM_REPO_URL` for `name@version`; if it already
|
||||
exists, exit `78` and skip the whole build.
|
||||
- `quality-gate` — write `.npmrc` from the secrets, `npm install`, then
|
||||
`npm run quality-gate`.
|
||||
- `build-package` — `npm install && npm run build` into `dist/`.
|
||||
- `publish-to-private-npm` — `npm publish --access restricted` to
|
||||
`$NPM_REPO_URL`.
|
||||
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
|
||||
exists, exit `78` and skip the whole build.
|
||||
- `quality-gate` — write `.npmrc` from the secrets, `npm install`, then
|
||||
`npm run quality-gate`.
|
||||
- `build-package` — `npm install && npm run build` into `dist/`.
|
||||
- `publish-to-private-npm` — `npm publish --access restricted` to
|
||||
`$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
|
||||
`.npmrc` (`registry=` + `_auth=` base64 of `user:pass`) written from secrets at
|
||||
runtime — the committed `.npmrc` in a repo only points at the registry URL.
|
||||
`.npmrc` written from secrets at runtime. It sets a **scope-scoped** registry,
|
||||
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
|
||||
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`)
|
||||
@@ -121,8 +142,8 @@ Placeholders to fill in per repo:
|
||||
`package.json` version; a service needs a root `VERSION` file. See
|
||||
[91 – Versioning & Changelog](91_versioning_and_changelog.md).
|
||||
- Do not rename the pipelines; tooling and runbooks key off
|
||||
`build-and-publish-pypi`, `build-and-publish-npm`, and
|
||||
`build-and-deploy-service`.
|
||||
`build-and-publish-pypi`, `build-and-publish-npm`,
|
||||
`build-and-deploy-service`, and their `backfill-*-from-tags` companions.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **Hexa** is a full library with its own MCP server on `:8008`: read
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{
|
||||
"name": "doc-weaver",
|
||||
"type": "package",
|
||||
"path": "doc-weaver",
|
||||
"path": "react/doc-weaver",
|
||||
"version_source": "package.json",
|
||||
"drone": "npm-lib.yaml",
|
||||
"trigger": "tag",
|
||||
|
||||
@@ -22,17 +22,18 @@ steps:
|
||||
from_secret: NPM_PASSWORD
|
||||
commands:
|
||||
- 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
|
||||
echo "Tag '$DRONE_TAG' does not match package.json version '$VERSION'; refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
- echo "registry=$NPM_REPO_URL" > /tmp/.npmrc
|
||||
- echo "_auth=$(echo -n "$NPM_USERNAME:$NPM_PASSWORD" | base64 | tr -d '\n')" >> /tmp/.npmrc
|
||||
- echo "Checking if $PACKAGE_NAME@$VERSION exists on $NPM_REPO_URL ..."
|
||||
- |
|
||||
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
|
||||
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
|
||||
echo "$PACKAGE_NAME@$VERSION already exists; skipping build."
|
||||
exit 78
|
||||
@@ -55,8 +56,10 @@ steps:
|
||||
echo "NPM_REPO_URL / NPM_USERNAME / NPM_PASSWORD are not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
- 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
|
||||
- npm install
|
||||
- echo "Running quality gate..."
|
||||
- npm run quality-gate
|
||||
@@ -79,11 +82,84 @@ steps:
|
||||
NPM_PASSWORD:
|
||||
from_secret: NPM_PASSWORD
|
||||
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 ..."
|
||||
- npm publish --access restricted
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
outage):
|
||||
|
||||
- **Package:** run the `backfill-pypi-from-tags` pipeline (custom event). It
|
||||
checks out every tag, verifies `tag == pyproject version` at that commit,
|
||||
and uploads the ones missing from the index. Already-fixed history is never
|
||||
- **Package:** run the matching `backfill-*-from-tags` pipeline (custom event) —
|
||||
`backfill-pypi-from-tags` for Python, `backfill-npm-from-tags` for npm. It
|
||||
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.
|
||||
- **Service:** rebuild the image for the missing tag with a fresh commit and a
|
||||
new patch version; do not move a published tag.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Reference App — Overview
|
||||
# Reference App — Overview
|
||||
|
||||
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
|
||||
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 |
|
||||
|---------------|---------------------------------------------------------------|
|
||||
| **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 |
|
||||
| **treatments**| Plain resource with pricing fields |
|
||||
| **vets** | Plain resource; target of prefetch FKs |
|
||||
| **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
|
||||
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
|
||||
services** — repositories are process-local dictionaries. Switch to
|
||||
services** — repositories are process-local dictionaries. Switch to
|
||||
`REPO_BACKEND=mongo` for persistence (see `.env.example`).
|
||||
|
||||
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
|
||||
answers `401` on resource routes; `/health` stays public. For a local
|
||||
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/).
|
||||
|
||||
## Next
|
||||
|
||||
- [Domain & API](02_domain_and_api.md) — walking the resource axis
|
||||
- [Pipelines & Worker](03_pipelines_and_worker.md) — the pipeline axis
|
||||
- [Client Admin](04_client_admin.md) — the spec-driven frontend
|
||||
- [Domain & API](02_domain_and_api.md) — walking the resource axis
|
||||
- [Pipelines & Worker](03_pipelines_and_worker.md) — the pipeline axis
|
||||
- [Client Admin](04_client_admin.md) — the spec-driven frontend
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Domain & API — the resource axis
|
||||
# Domain & API — the resource axis
|
||||
|
||||
Every aggregate follows the same four-file slice
|
||||
([CRUD Template](../lib/21_crud_template/)); walk `parents` first, then
|
||||
@@ -13,10 +13,10 @@ domain/parents/
|
||||
db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo()
|
||||
service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service()
|
||||
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`:
|
||||
|
||||
```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.
|
||||
|
||||
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).
|
||||
|
||||
## Foreign keys (appointments)
|
||||
@@ -54,7 +54,7 @@ fully expanded objects:
|
||||
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.
|
||||
|
||||
## Ids on the wire
|
||||
@@ -67,7 +67,7 @@ Mongo persistence layer consistent.
|
||||
|
||||
## 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.
|
||||
- `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).
|
||||
|
||||
@@ -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 →
|
||||
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
|
||||
`register(kind, variant, root_cls)` — see
|
||||
`register(kind, variant, root_cls)` — see
|
||||
[Pipeline Scaffold](../lib/50_pipeline_scaffold/).
|
||||
|
||||
## The worker
|
||||
@@ -56,7 +56,7 @@ documents into execution:
|
||||
|
||||
1. poll `job_svc.search({"status": "pending"})` on `WORKER_POLL_SECONDS`
|
||||
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`,
|
||||
`retry_count += 1`, `error_message` captured; jobs at
|
||||
`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
|
||||
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"
|
||||
API and worker are separate processes (see `docker-compose.yaml`: `api`,
|
||||
`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
|
||||
one — see [Worker & Progress](../lib/51_worker_and_progress/).
|
||||
one — see [Worker & Progress](../lib/51_worker_and_progress/).
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
|
||||
@@ -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 —
|
||||
auth pages, resource lists, forms, detail views — **from the server's
|
||||
The client sample (`standards/src/templates/client/`) renders a full admin —
|
||||
auth pages, resource lists, forms, detail views — **from the server's
|
||||
`openapi.yaml`**, driven entirely by the x- extension fields. There are no
|
||||
hand-written screens per resource.
|
||||
|
||||
@@ -9,10 +9,10 @@ hand-written screens per resource.
|
||||
|
||||
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-
|
||||
fields (list columns, autocomplete, fk pickers, enum selects, hidden fields…)
|
||||
- **`@aetoskia/react-auth`** — login/register/logout against the auth-server
|
||||
fields (list columns, autocomplete, fk pickers, enum selects, hidden fields…)
|
||||
- **`@aetoskia/react-auth`** — login/register/logout against the auth-server
|
||||
paths declared in the spec's `securitySchemes.bearerAuth`, and attaches the
|
||||
JWT to every API call
|
||||
- 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,
|
||||
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.
|
||||
|
||||
## Where the x- extensions come from
|
||||
|
||||
@@ -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
|
||||
outage):</p>
|
||||
<ul>
|
||||
<li><strong>Package:</strong> run the <code>backfill-pypi-from-tags</code> pipeline (custom event). It
|
||||
checks out every tag, verifies <code>tag == pyproject version</code> at that commit,
|
||||
and uploads the ones missing from the index. Already-fixed history is never
|
||||
<li><strong>Package:</strong> run the matching <code>backfill-*-from-tags</code> pipeline (custom event) —
|
||||
<code>backfill-pypi-from-tags</code> for Python, <code>backfill-npm-from-tags</code> for npm. It
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<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
|
||||
</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>
|
||||
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
|
||||
through the MCP tools (<code>get_app_template</code>, <code>get_app_component</code>).</p>
|
||||
<div class="admonition note">
|
||||
@@ -866,7 +866,7 @@ at <code>standards/src/templates/client/</code> (Vite + MUI + <code>@aetoskia/re
|
||||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
<td><strong>pets</strong></td>
|
||||
@@ -886,7 +886,7 @@ at <code>standards/src/templates/client/</code> (Vite + MUI + <code>@aetoskia/re
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>jobs</strong></td>
|
||||
<td>The worker's queue — durable status machine</td>
|
||||
<td>The worker's queue — durable status machine</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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></code></pre></div></td></tr></table></div>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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
|
||||
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
|
||||
global security entry — see
|
||||
global security entry — see
|
||||
<a href="../../../lib/22_auth_integration/">Auth Integration</a>.</p>
|
||||
</div>
|
||||
<h2 id="next">Next</h2>
|
||||
<ul>
|
||||
<li><a href="../02_domain_and_api/">Domain & API</a> — walking the resource axis</li>
|
||||
<li><a href="../03_pipelines_and_worker/">Pipelines & Worker</a> — the pipeline axis</li>
|
||||
<li><a href="../04_client_admin/">Client Admin</a> — the spec-driven frontend</li>
|
||||
<li><a href="../02_domain_and_api/">Domain & API</a> — walking the resource axis</li>
|
||||
<li><a href="../03_pipelines_and_worker/">Pipelines & Worker</a> — the pipeline axis</li>
|
||||
<li><a href="../04_client_admin/">Client Admin</a> — the spec-driven frontend</li>
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<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
|
||||
</a>
|
||||
|
||||
@@ -860,7 +860,7 @@
|
||||
|
||||
|
||||
|
||||
<h1 id="domain-api-a-the-resource-axis">Domain & API — the resource axis</h1>
|
||||
<h1 id="domain-api-the-resource-axis">Domain & API — the resource axis</h1>
|
||||
<p>Every aggregate follows the same four-file slice
|
||||
(<a href="../../../lib/21_crud_template/">CRUD Template</a>); walk <code>parents</code> first, then
|
||||
<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-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-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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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
|
||||
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
|
||||
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>
|
||||
<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
|
||||
@@ -907,7 +907,7 @@ write path normalizes bare ids through <code>expand_ref</code>; the read path re
|
||||
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">"pet"</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">"pet"</span><span class="p">),</span> <span class="s2">"pet"</span><span class="p">)</span>
|
||||
</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>
|
||||
<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
|
||||
@@ -917,9 +917,9 @@ keeps the OpenAPI <code>id: integer</code> contract, the in-memory demo store, a
|
||||
Mongo persistence layer consistent.</p>
|
||||
<h2 id="tests">Tests</h2>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<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
|
||||
</a>
|
||||
|
||||
@@ -851,7 +851,7 @@
|
||||
|
||||
|
||||
|
||||
<h1 id="pipelines-worker-a-the-pipeline-axis">Pipelines & Worker — the pipeline axis</h1>
|
||||
<h1 id="pipelines-worker-the-pipeline-axis">Pipelines & Worker — the pipeline axis</h1>
|
||||
<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>
|
||||
<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></code></pre></div></td></tr></table></div>
|
||||
<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>
|
||||
<h2 id="the-worker">The worker</h2>
|
||||
<p><code>worker.py</code> is the process that turns
|
||||
@@ -909,7 +909,7 @@ documents into execution:</p>
|
||||
<ol>
|
||||
<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>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>,
|
||||
<code>retry_count += 1</code>, <code>error_message</code> captured; jobs at
|
||||
<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></code></pre></div></td></tr></table></div>
|
||||
<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">
|
||||
<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>,
|
||||
<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
|
||||
one — see <a href="../../../lib/51_worker_and_progress/">Worker & Progress</a>.</p>
|
||||
one — see <a href="../../../lib/51_worker_and_progress/">Worker & Progress</a>.</p>
|
||||
</div>
|
||||
<h2 id="tests">Tests</h2>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<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
|
||||
</a>
|
||||
|
||||
@@ -851,18 +851,18 @@
|
||||
|
||||
|
||||
|
||||
<h1 id="client-admin-a-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 —
|
||||
auth pages, resource lists, forms, detail views — <strong>from the server's
|
||||
<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 —
|
||||
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
|
||||
hand-written screens per resource.</p>
|
||||
<h2 id="what-ships">What ships</h2>
|
||||
<p>A Vite + React + MUI app wiring the two Aetos packages:</p>
|
||||
<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-
|
||||
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
|
||||
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
|
||||
paths declared in the spec's <code>securitySchemes.bearerAuth</code>, and attaches the
|
||||
JWT to every API call</li>
|
||||
<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>
|
||||
<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
|
||||
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>
|
||||
<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
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user