Skip to content

Commit 78bc371

Browse files
committed
feat(artifacts): mountable artifact store
A standalone artifact plane that drops onto any Interchange host: const api = new Hono(); mountArtifacts(api, { db, contentStore, resolvePrincipal, grantStore }); app.route("/api", api); - Owns its schema, indexes and idempotent checksummed migrations; coexists with a host's own tables via search_path isolation. - Accepts the host's drizzle handle rather than opening a second pool. - artifact + artifact_version, with content behind a pluggable ContentStore port so inline and repo-backed hosts share one contract. - Attachments are artifacts, associated to mail rather than duplicated. - Keyset pagination at full microsecond precision. Licensed LGPL-2.1-only. 199 unit tests plus 34 against a reference host.
0 parents  commit 78bc371

48 files changed

Lines changed: 9834 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/publish.yml‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: publish
2+
3+
# Tags are the release trigger: `v0.1.0` publishes whatever
4+
# packages/artifact-core/package.json says version 0.1.0 is.
5+
on:
6+
push:
7+
tags: ["v*"]
8+
9+
jobs:
10+
publish:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
id-token: write # npm provenance
15+
16+
services:
17+
postgres:
18+
image: postgres:16
19+
env:
20+
POSTGRES_PASSWORD: postgres
21+
POSTGRES_DB: artifact_core
22+
ports: ["5457:5432"]
23+
options: >-
24+
--health-cmd pg_isready
25+
--health-interval 10s
26+
--health-timeout 5s
27+
--health-retries 5
28+
29+
env:
30+
ARTIFACT_DATABASE_URL: postgres://postgres:postgres@localhost:5457/artifact_core
31+
32+
steps:
33+
- uses: actions/checkout@v4
34+
- uses: oven-sh/setup-bun@v2
35+
with:
36+
bun-version: 1.3.14
37+
- uses: actions/setup-node@v4
38+
with:
39+
node-version: 22
40+
registry-url: https://registry.npmjs.org
41+
42+
- run: bun install --frozen-lockfile
43+
44+
# The tag and the manifest must agree, or the tag is a lie about what
45+
# got published.
46+
- name: verify tag matches package version
47+
run: |
48+
set -euo pipefail
49+
TAG="${GITHUB_REF_NAME#v}"
50+
PKG="$(node -p "require('./packages/artifact-core/package.json').version")"
51+
if [ "$TAG" != "$PKG" ]; then
52+
echo "tag $GITHUB_REF_NAME does not match package version $PKG" >&2
53+
exit 1
54+
fi
55+
56+
- run: bun run --cwd packages/artifact-core test
57+
- run: bun run build
58+
59+
- name: publish
60+
run: npm publish --access public --provenance
61+
working-directory: packages/artifact-core
62+
env:
63+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

‎.github/workflows/test.yml‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
12+
services:
13+
postgres:
14+
image: postgres:16
15+
env:
16+
POSTGRES_PASSWORD: postgres
17+
POSTGRES_DB: artifact_core
18+
ports: ["5457:5432"]
19+
options: >-
20+
--health-cmd pg_isready
21+
--health-interval 10s
22+
--health-timeout 5s
23+
--health-retries 5
24+
25+
env:
26+
ARTIFACT_DATABASE_URL: postgres://postgres:postgres@localhost:5457/artifact_core
27+
28+
steps:
29+
- uses: actions/checkout@v4
30+
- uses: oven-sh/setup-bun@v2
31+
with:
32+
bun-version: 1.3.14
33+
34+
- run: bun install --frozen-lockfile
35+
36+
# No unpublished scope, no @intx/db, no host workflow tables. Runs as
37+
# `pretest`, but standalone so a violation is an obvious failing step
38+
# rather than buried in test output.
39+
- name: dep-guard
40+
run: bun run --cwd packages/artifact-core scripts/dep-guard.ts
41+
42+
- name: typecheck
43+
run: bun run typecheck
44+
45+
- name: unit + integration tests
46+
run: bun run --cwd packages/artifact-core test:coverage
47+
48+
# Emits dist/, which is also what the reference host resolves through —
49+
# so this proves the published artifact, not just the sources.
50+
- name: build
51+
run: bun run build
52+
53+
- name: reference-host acceptance
54+
run: bun test --cwd examples/reference-host
55+
56+
# A consumer on plain Node must be able to install and import the
57+
# tarball; Node cannot strip types, so a src-pointing manifest would die
58+
# here rather than after publish.
59+
- name: node consumer smoke test
60+
run: |
61+
set -euo pipefail
62+
TARBALL="$(cd packages/artifact-core && npm pack --silent)"
63+
TARBALL="$PWD/packages/artifact-core/$TARBALL"
64+
mkdir -p "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer"
65+
npm init -y >/dev/null && npm pkg set type=module >/dev/null
66+
npm install "$TARBALL"
67+
node -e '
68+
import("@corbits/artifact-core").then((m) => {
69+
for (const name of ["mountArtifacts", "runArtifactMigrations"]) {
70+
if (typeof m[name] !== "function") throw new Error(`missing export: ${name}`);
71+
}
72+
console.log("node consumer ok");
73+
});
74+
'

‎.gitignore‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
node_modules/
2+
dist/
3+
.env
4+
.env.local
5+
*.tsbuildinfo
6+
coverage/
7+
.DS_Store
8+
9+
# npm pack output
10+
*.tgz

‎ARCHITECTURE.md‎

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# Architecture
2+
3+
How `@corbits/artifact-core` is put together, and where its boundaries are. For
4+
install, the mount snippet, the route table and the response contracts, see the
5+
[package README](./packages/artifact-core/README.md) — this document is about
6+
structure and reasoning, and does not repeat them.
7+
8+
## The shape of the thing
9+
10+
A **library, not a service**. It creates no app, starts no background work, and
11+
opens no pool unless asked. A host calls two functions:
12+
13+
- `runArtifactMigrations(db)` — once at boot, before serving.
14+
- `mountArtifacts(app, opts)` — registers the artifact routes on a Hono app the
15+
host already built.
16+
17+
## Where the routes are served
18+
19+
The core registers root-relative paths (`/artifacts*`,
20+
`/instances/:id/mail-attachments`) and takes no base path, so the *mount point*
21+
is the host's decision. The convention every `@corbits/*-core` package
22+
documents, and every example here demonstrates, is **`/api`** — the same prefix
23+
Interchange serves its own routes under (`app.route("/api/me", …)`,
24+
`app.route("/api/tenants", …)`). No `/v1` segment, no vendor prefix.
25+
26+
```ts
27+
const api = new Hono<AppEnv>();
28+
mountArtifacts(api, { db, contentStore, resolvePrincipal });
29+
app.route("/api", api);
30+
```
31+
32+
which serves `/api/artifacts`, `/api/artifacts/:id`,
33+
`/api/artifacts/:id/versions`, `/api/artifacts/:id/download`, and
34+
`/api/instances/:instanceId/mail-attachments`. Nesting rather than teaching the
35+
core a base path keeps the frozen `mountX<E extends Env>(app, opts) => Hono<E>`
36+
seam untouched.
37+
38+
Everything else it needs arrives through `opts`. Nothing is reached for; the
39+
dep-guard enforces that mechanically.
40+
41+
## The mount seam
42+
43+
`mountArtifacts<E extends Env>(app: Hono<E>, opts): Hono<E>` is generic over the
44+
host's Hono `Env`, so it composes with an app that carries its own environment
45+
rather than requiring a bare `Hono`.
46+
47+
Three options have no sensible default — `db`, `contentStore`,
48+
`resolvePrincipal` — and the rest degrade a *feature*, never safety, when
49+
omitted. The README's table of what a minimal host passes is the reference; what
50+
matters architecturally is that every default **fails closed**: no `adminAuthz`
51+
means nobody is an admin, no `identity` means no directory and no cross-tenant
52+
reads, no `provenance` means no decoration.
53+
54+
What the package does **not** require of a host: no auth middleware, no session
55+
library, no particular schema, no control-plane tables, no id scheme, no UI.
56+
57+
`resolvePrincipal`'s signature is identical across the Corbits cores, so a host
58+
mounting more than one passes the same function to each. The resolved tenant is
59+
authoritative — there is no caller-supplied tenant override anywhere in the
60+
route surface.
61+
62+
## The ports
63+
64+
Five host seams. Four are types declared in `ports.ts`; `resolvePrincipal` is a
65+
`mountArtifacts` option, typed at the mount boundary because it takes the host's
66+
request context as `unknown`. Every optional seam has a **named, exported**
67+
default.
68+
69+
| Seam | What it is for | Default |
70+
| --- | --- | --- |
71+
| `resolvePrincipal` | Who the request runs as. Reads the host session; returns `null` when signed out. | none — required |
72+
| `ContentStore` | Where an artifact's file bytes live. | none — required |
73+
| `AdminAuthz` (Seam A) | Authorization. Only archive/unarchive consults it. | `denyAllAdminAuthz` |
74+
| `Identity` (Seam B) | Owner display names, the agent→human ownership resolution, creator-kind principal sets, and cross-tenant membership. | `anonymousIdentity` |
75+
| `Provenance` (Seam C) | A **display-only** decorator over serialized rows. | `noProvenance` |
76+
77+
Seam C's display-only status is a contract, not a convention: it may add fields
78+
to rows on their way out and must never affect *what* is returned or *who* may
79+
see it. That is why provenance is a port at all — joining a host's workflow
80+
tables to decorate a row would make this package depend on a schema it must not
81+
know, and the dep-guard fails the build on exactly that.
82+
83+
Seam B's `ownerIsMemberOfTenant` gates cross-tenant reads and must fail closed;
84+
the shipped `anonymousIdentity` does.
85+
86+
### ContentStore
87+
88+
`ContentStore` is the substrate seam:
89+
90+
- `put(tx, scope, blob)` persists bytes **inside the caller's transaction** and
91+
returns the two artifact-row fields a stored file determines (`content` and
92+
`source`). Taking the transaction rather than the handle is what makes "bytes,
93+
artifact row, and version 1" a single atomic write — a failure anywhere leaves
94+
no orphan bytes and no orphan artifact.
95+
- `get(db, artifact)` resolves **out-of-band** bytes, or `null` when the store
96+
keeps content in the row itself. It is tenant-scoped: a reference resolving to
97+
another tenant's bytes must return `null`.
98+
99+
Two implementations ship and both pass the same suite: `InlineContentStore`
100+
(bytes in a tenant-owned `upload` row, referenced by `source.upload.id`) and
101+
`DataUrlContentStore` (bytes inline in `content` as a base64 `data:` URL, no
102+
side-table row, so its `get` returns `null`). A third backend — object storage,
103+
say — is a third implementation, not a rewrite. The single download path
104+
resolves the storage conventions in precedence order rather than branching on
105+
which store is installed.
106+
107+
## Modules
108+
109+
| File | Role |
110+
| --- | --- |
111+
| `mount.ts` | HTTP surface: parsing, validation, status codes. |
112+
| `artifacts.ts` | The core domain — create, revise, list, get, archive, serialize. |
113+
| `uploads.ts` | `createFileArtifact`, the MIME policies, and the size caps. |
114+
| `download.ts` | One download path over the three storage conventions. |
115+
| `content-store.ts` | The two shipped `ContentStore` implementations. |
116+
| `tools.ts` | Agent-facing tool definitions and windowed artifact reads. |
117+
| `web-site.ts` | The `web-site` kind's content encoding and validation. |
118+
| `mail-attachments.ts` | Artifact↔message associations. |
119+
| `ports.ts` | Four of the five host seam types, and the three fail-closed defaults. |
120+
| `schema.ts` / `migrations.ts` | The four tables, and the DDL that creates them. |
121+
122+
## Data model
123+
124+
Four physical tables — `artifact`, `artifact_version`, `upload`,
125+
`mail_attachment_ref` — plus this package's own migration ledger.
126+
127+
**Zero control-plane foreign keys.** `tenant_id`, `principal_id` and
128+
`owner_principal_id` are plain `text` held by value. The package must stand up on
129+
a host schema it knows nothing about, so it cannot assume the host has tables by
130+
those names, let alone that their keys are uuids. The only foreign keys are
131+
*internal*: `artifact.parent_id` (self-referential, cascading) and
132+
`artifact_version.artifact_id`.
133+
134+
**`kind` is free-form text, not a pg enum,** validated at the application edge.
135+
New kinds cost no migration. What is *not* free-form is the import allowlist:
136+
`POST /api/artifacts` may only mint `link` or `document`, so an untrusted caller
137+
cannot stamp a file-shaped, downloadable kind onto a row whose content is a URL
138+
or a pasted body.
139+
140+
**History is append-only.** Every create and every revision writes an
141+
`artifact_version` row — including version 1, written eagerly with the artifact —
142+
so a version-pinned read always resolves. The version bump has a double guard:
143+
`SELECT ... FOR UPDATE` serializes writers, and the `(artifact_id, version)`
144+
unique constraint makes a racing writer that somehow computed the same next
145+
version fail loudly rather than corrupt history.
146+
147+
**Archival is a soft hide.** `archived_at` null means visible; a timestamp means
148+
hidden from discovery. Deep links to archived artifacts still load.
149+
150+
**`parent_id` has no live writer.** It is kept deliberately as the declared
151+
nesting seam: one nullable column and one cascade now, versus a breaking schema
152+
change the moment a host nests.
153+
154+
**`upload` is never a standalone resource.** There is no `POST /uploads`; every
155+
upload eagerly mints its artifact, and the row is reachable only through
156+
`source.upload.id`. `mail_attachment_ref` carries no bytes at all — the file
157+
already *is* an artifact, and the ref only records which artifacts rode with
158+
which message.
159+
160+
The list index is `(tenant_id, updated_at, id)`. The `id` is the list's
161+
tie-break and must be *in* the index, or the keyset cursor's row-value
162+
comparison falls out of the index condition into a filter and drags a sort
163+
behind it.
164+
165+
## Migrations
166+
167+
`runArtifactMigrations(db)` is idempotent and safe to call unconditionally on
168+
every boot of every replica.
169+
170+
- The whole run is one transaction whose first statements are
171+
`SET LOCAL client_min_messages = warning` and a **transaction-scoped**
172+
advisory lock. A transaction pins one pooled connection, so the lock, the
173+
ledger read and the DDL are the same session; the lock releases on commit or
174+
rollback, so there is no unlock call to lose on an error path.
175+
`CREATE TABLE IF NOT EXISTS` is not itself race-safe, so the lock — not the
176+
`IF NOT EXISTS` — is what makes concurrent cold starts safe.
177+
- Lowering `client_min_messages` is why a re-run prints **nothing**: every
178+
statement is `IF NOT EXISTS`, and on the second boot Postgres answers each with
179+
a NOTICE that postgres.js would otherwise dump to the console, making a clean
180+
re-boot look like a wall of errors. `SET LOCAL` scopes it to the transaction
181+
and stops at NOTICE — WARNING and above still reach the host.
182+
- Each migration applies inside a nested transaction (a savepoint) together with
183+
its ledger row, so a migration can never be recorded as applied with only some
184+
of its statements run.
185+
- The ledger is this package's own table, `corbits_artifact_core_migrations`,
186+
never shared with a host's. Each row records a **checksum of the migration's
187+
rendered SQL**, so editing a shipped migration fails with
188+
`MigrationChecksumError` on the next boot instead of letting existing and
189+
fresh databases diverge silently. Ship a new migration instead. The column is
190+
`NOT NULL`, so the guarantee is unconditional: there is no unrecorded row for
191+
the runner to adopt and wave through.
192+
193+
**Mounting under a host's own Postgres schema works.** No DDL and no query here
194+
is schema-qualified, so everything resolves through `search_path`: point a handle
195+
at `search_path=its_schema` and the four tables, their indexes and the ledger are
196+
created there, isolated from the host's own tables. `test/migrations.test.ts`
197+
exercises exactly this against a live database.
198+
199+
## Boundaries
200+
201+
Owned by this package: the four tables and their migrations; the HTTP surface,
202+
its validation and its status codes; the version and archive semantics; the
203+
upload **gate** (`createFileArtifact` takes `policy` as a required argument and
204+
refuses anything outside it before the `ContentStore` is touched); and the
205+
download path with its `nosniff`/`attachment` behaviour.
206+
207+
Supplied by the host: the Hono app and the database handle; who the caller is;
208+
whether they are an admin; the directory, if there is one; provenance
209+
decoration; and a `ContentStore`.
210+
211+
## Known limits
212+
213+
- **No file parsing, ever.** No PDF parser, no spreadsheet parser, no text
214+
extractor, and none is planned. An extractor is a heavyweight, fast-moving
215+
native dependency, and what the extracted text is *for* is the host's product.
216+
The contract the package offers a parsing host instead is **parse before you
217+
store**: `createFileArtifact` is the only way a file becomes an artifact, so a
218+
host that parses first and fails leaves nothing orphaned.
219+
- **Two of the three MIME allowlists are host-owned surfaces.** This package
220+
ships all three constants but only owns the gallery import route; spreadsheet
221+
ingest and attachment divert are the host's routes calling this package's gate.
222+
- **Upload caps are fixed constants,** not configuration: 10 MB per file, 50
223+
files and 100 MB per request.
224+
- **`InlineContentStore` keeps bytes in Postgres.** That is a deliberate
225+
zero-dependency default, not a recommendation at scale; a large corpus wants a
226+
`ContentStore` over object storage.
227+
- **List paging caps at 100** rows (default 20).
228+
- **One 404 covers four causes** for a resolved caller — never minted,
229+
malformed, a `skill-draft`, or another tenant's. Distinguishing them would be
230+
an existence oracle. Expect no more detail than that from the API.

0 commit comments

Comments
 (0)