Skip to content

Commit ac6c0be

Browse files
authored
refactor(metadata)!: remove the artifact-api artifact source (#4246) (#4259)
MetadataPluginOptions.artifactSource loses its artifact-api union member; { mode: 'local-file', path } is the single artifact source (path may be an http(s) URL — the control plane's /pub/v1/environments/:id/artifact route serves it verbatim). Removed on the owner's call after a cross-repo consumer audit found zero call sites: the cloud runtime pulls artifacts through its own ArtifactApiClient, and package distribution into a running OSS instance goes through @objectstack/cloud-connection. A still-configured artifact-api source fails loudly at start() with a migration pointer instead of silently falling back to the filesystem scan. Breaking: @objectstack/metadata major.
1 parent 623e555 commit ac6c0be

7 files changed

Lines changed: 253 additions & 63 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/metadata": major
3+
---
4+
5+
refactor(metadata)!: remove the `artifact-api` artifact source (#4246)
6+
7+
`MetadataPluginOptions.artifactSource` loses its `artifact-api` union member;
8+
`{ mode: 'local-file', path }` is now the single artifact source. The
9+
`_loadFromArtifactApi` loader, its `environmentId` pre-flight guard, and the
10+
Bearer-token support in `_fetchJson` go with it.
11+
12+
**Why removal, not the doc fix this branch first carried.** #4246 found the
13+
declaration and the implementation contradicting each other — the option's
14+
comment called `artifact-api` "reserved for M3/M4" while the loader shipped and
15+
all three bootstrap modes dispatched to it — and asked the owner to pick a
16+
direction. Auditing both repos to answer that settled it:
17+
18+
- **Zero consumers anywhere.** No `mode: 'artifact-api'` call site exists in
19+
this repo or in cloud. The two real "pull an artifact from the cloud" paths
20+
both bypass it: the cloud runtime uses its own `ArtifactApiClient` (TTL
21+
cache, singleflight, hostname resolution, runtime config injection — a
22+
superset this option was never going to grow into), and package distribution
23+
into a running OSS instance goes through `@objectstack/cloud-connection`
24+
(`os package install`, ADR-0008).
25+
- **Half its input contract had been dead since v5.0 with no one noticing.**
26+
The URL builder decided "append the canonical path vs use as-is" by testing
27+
for an `/api/v{n}/cloud/projects/` segment that the v5.0
28+
`project → environment` rename deleted, so every already-resolved URL got
29+
the path appended a second time and 404'd. A year of silence on a bug like
30+
that is consumer-count evidence of its own.
31+
- **Its one non-replaceable capability was declined.** A Bearer-authenticated
32+
pull of a *private* environment artifact is the single thing `local-file`
33+
cannot do (`local-file` URLs fetch verbatim, unauthenticated). The owner
34+
confirmed that sealed-private-artifact deployments are not a supported need
35+
right now, which removed the last reason to keep the mode.
36+
37+
**Migration.** Public or commit-pinned artifacts load through the existing
38+
`local-file` URL form, which every bootstrap mode already honors:
39+
40+
```ts
41+
artifactSource: {
42+
mode: 'local-file',
43+
path: 'https://cloud.example.com/pub/v1/environments/env_42/artifact?commit=cmt_1a2b',
44+
}
45+
```
46+
47+
(`private` environments still serve exact-commit deep links through the same
48+
`/pub` route; fully private pulls have no replacement — by decision, not
49+
oversight.) For installing packages into a running runtime, use
50+
`os package install` / `@objectstack/cloud-connection`.
51+
52+
**The removal is loud, not silent.** A still-configured `artifact-api` source
53+
(reachable from JS or `any`-typed config now that the TS union is
54+
single-member) throws at `start()` with the migration pointer above. This
55+
guard exists because the dispatch's old fall-through would have treated
56+
"unsupported source" as "no source" — under `eager` that silently scans the
57+
filesystem instead of loading the artifact the caller named. Tests pin the
58+
rejection in `artifact-only` and `eager`, and pin the migration target
59+
(`local-file` fetching an http(s) URL and registering the envelope) so the
60+
path the error message points at stays real.
61+
62+
Also replaces a test that passed for the wrong reason: "artifact-only
63+
bootstrap rejects the not-yet-implemented artifact-api source" matched
64+
`/artifact-api/` against the missing-`environmentId` guard's message — which
65+
merely contained the string — proving nothing about implementation status.
66+
The doc comment, `implementation-status.mdx`, `metadata-service.mdx`, and the
67+
package ROADMAP now all describe the single `local-file` source, ending the
68+
docs-audit loop #4246 was filed to stop.

content/docs/protocol/kernel/metadata-service.mdx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Four orthogonal levers shape how `MetadataManager` and `MetadataPlugin` behave a
187187
| :--- | :--- | :--- |
188188
| `eager` (default) | Local dev, traditional servers | Scans filesystem (or hydrates from `artifactSource`) at boot. |
189189
| `lazy` | Cold-start sensitive runtimes, on-demand workloads | Skips the FS prime; reads flow through registered loaders (incl. DatabaseLoader cache). Honors `artifactSource` when set. |
190-
| `artifact-only` | Edge / serverless / read-only production | Refuses to touch the filesystem. Requires `artifactSource` (`mode: 'local-file'` or `'artifact-api'`). |
190+
| `artifact-only` | Edge / serverless / read-only production | Refuses to touch the filesystem. Requires `artifactSource` (`mode: 'local-file'`; `path` may be an `http(s)` URL). |
191191

192192
```typescript
193193
new MetadataPlugin({
@@ -196,6 +196,37 @@ new MetadataPlugin({
196196
});
197197
```
198198

199+
#### Artifact source
200+
201+
`artifactSource: { mode: 'local-file', path, fetchTimeoutMs? }` is the single
202+
artifact source, honored by every bootstrap mode above. `path` is a filesystem
203+
path or an `http(s)` URL fetched verbatim — the control plane's public
204+
artifact route (`/pub/v1/environments/:id/artifact[?commit=<id>]`) serves
205+
exactly such URLs, so a sealed runtime can boot straight off a published
206+
revision:
207+
208+
```typescript
209+
new MetadataPlugin({
210+
config: { bootstrap: 'artifact-only' },
211+
artifactSource: {
212+
mode: 'local-file',
213+
path: 'https://cloud.example.com/pub/v1/environments/env_42/artifact?commit=cmt_1a2b',
214+
},
215+
});
216+
```
217+
218+
Only non-URL paths get the artifact-file HMR watcher (`artifactWatch`); remote
219+
reads honor `fetchTimeoutMs` (and `OS_ARTIFACT_FETCH_TIMEOUT_MS`, default
220+
60 s). Outside `artifact-only`, an absent local file is "nothing compiled
221+
yet", not a fault.
222+
223+
A second `artifact-api` mode (a Bearer-authenticated control-plane pull) was
224+
removed while resolving #4246: it had no consumers — the cloud runtime uses
225+
its own `ArtifactApiClient`, and package distribution into a running OSS
226+
instance goes through `@objectstack/cloud-connection` (`os package install`).
227+
A still-configured `artifact-api` source fails loudly at `start()` rather
228+
than silently falling back to the filesystem scan.
229+
199230
### 2. Persistence Write Gates
200231

201232
`MetadataManagerConfigSchema.persistence` is a two-axis runtime freeze. Both flags default to `true`.

content/docs/releases/implementation-status.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ This matrix is generated from actual codebase analysis and represents the curren
7272
- Metadata is loaded from config files or `dist/objectstack.json` at startup and held in memory
7373
- `MetadataPlugin` auto-provisions the `sys_metadata` / `sys_metadata_history` storage objects (plus the ADR-0067 commit log and the metadata audit trail) by default, so `PUT /api/v1/meta/{type}/{name}` always has somewhere to write. Per-project (cloud) kernels opt out with `registerSystemObjects: false`, because there the control plane owns those tables
7474
- `MetadataPlugin` does **not** auto-bridge ObjectQL to a `DatabaseLoader` — there is no automatic runtime project-DB bridge, and nothing in the packages calls the bridge during boot. Database-backed metadata is an explicit `MetadataManager` opt-in (`setDataEngine()` / `setDatabaseDriver()`, or a `datasource` + `driver` pair on the manager config), and even then it is control-plane only: both methods return early whenever an `environmentId` is supplied, so a per-project kernel never gets a database-backed metadata loader
75-
- Future: production Artifact API loader (`artifactSource: { mode: 'artifact-api' }` is reserved) and durable local artifact cache
75+
- The artifact source is `artifactSource: { mode: 'local-file', path }` — a compiled artifact read from a filesystem path or fetched from an `http(s)` URL (the control plane's public `/pub/v1/environments/:id/artifact[?commit=…]` route serves exactly such URLs), honored by all three bootstrap modes (`eager` / `lazy` / `artifact-only`). A second `artifact-api` mode (a Bearer-authenticated control-plane pull) this page once called "Future / reserved" was **removed** while resolving #4246: it had zero consumers in any repo — the cloud runtime uses its own `ArtifactApiClient`, and package installs into a running OSS instance go through `@objectstack/cloud-connection` — and a still-configured `artifact-api` source now fails loudly at `start()` instead of lingering half-declared
76+
- Future: durable local artifact cache — remote artifact URLs re-fetch on every boot
7677

7778
### System Services
7879

packages/metadata/ROADMAP.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
| **Bootstrap modes** | `MetadataPluginConfig.bootstrap` = `eager` \| `lazy` \| `artifact-only` — supports edge / serverless / read-only deployments. |
3232
| **Persistence write gates** | `MetadataManagerConfig.persistence.{ writable, overlayWritable }` — runtime freeze for sealed kernels. |
3333
| **Single-source schema discipline** | Canonical `MetadataManagerConfigSchema` / `MetadataFallbackStrategySchema` live in `kernel/metadata-loader.zod.ts` and are re-exported from `system/metadata-persistence.zod.ts`. |
34-
| **`artifact-api` runtime source** | `MetadataPlugin` can boot from a remote control-plane artifact (`artifactSource: { mode: 'artifact-api', url, projectId, commitId? }`). Wired across `eager` / `lazy` / `artifact-only` bootstrap modes. Configurable timeout via `fetchTimeoutMs` or `OS_ARTIFACT_FETCH_TIMEOUT_MS` (default 60 s). |
34+
| **Remote artifact boot** | `MetadataPlugin` boots from a compiled artifact via `artifactSource: { mode: 'local-file', path }`, where `path` may be an `http(s)` URL — e.g. the control plane's public `/pub/v1/environments/:id/artifact[?commit=…]` route. Wired across `eager` / `lazy` / `artifact-only` bootstrap modes. Configurable timeout via `fetchTimeoutMs` or `OS_ARTIFACT_FETCH_TIMEOUT_MS` (default 60 s). A dedicated `artifact-api` mode (Bearer-authenticated control-plane pull) was removed in #4246 — zero consumers in any repo; the cloud runtime uses its own `ArtifactApiClient`. |
3535

3636
### 🟡 Partially Implemented
3737

@@ -193,9 +193,11 @@ artifact persistence (`artifacts/${projectId}/${commitId}.json`).
193193

194194
- [x] Object-storage abstraction available via `StorageServicePlugin`
195195
- [x] Cloud `artifact-api` reads/writes through `IStorageService`
196-
- [x] `MetadataPlugin` consumes published artifacts via the
197-
`artifactSource: { mode: 'artifact-api' }` source — no direct S3
198-
coupling needed in the metadata layer.
196+
- [x] `MetadataPlugin` consumes published artifacts via
197+
`artifactSource: { mode: 'local-file', path: '<artifact URL>' }` — no
198+
direct S3 coupling needed in the metadata layer. (The dedicated
199+
`artifact-api` mode was removed in #4246 — zero consumers; the cloud
200+
runtime pulls artifacts through its own `ArtifactApiClient` instead.)
199201
- See [Publish, Versioning & Preview](../../content/docs/deployment/publish-and-preview.mdx).
200202

201203
---

packages/metadata/src/metadata.test.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -839,23 +839,113 @@ describe('MetadataPlugin', () => {
839839
expect(manager.loadMany).not.toHaveBeenCalled();
840840
});
841841

842-
it('artifact-only bootstrap rejects the not-yet-implemented artifact-api source', async () => {
842+
// #4246 resolution — the `artifact-api` source is REMOVED, not reserved.
843+
// The mode shipped through v17 with zero consumers in any repo (the cloud
844+
// runtime uses its own ArtifactApiClient; package distribution into a
845+
// running OSS instance goes through @objectstack/cloud-connection), so the
846+
// enforce-or-remove call went to remove. What these tests pin is the
847+
// failure MODE of the removal: a still-configured 'artifact-api' source —
848+
// reachable only from JS callers or config plumbed through `any`, since
849+
// the TS union is now single-member — must fail loudly at start(), never
850+
// degrade into "no source".
851+
it('artifact-only bootstrap rejects the removed artifact-api source with a migration message', async () => {
843852
const { MetadataPlugin } = await import('./plugin.js');
844853
const plugin = new MetadataPlugin({
845854
rootDir: '/tmp/test',
846855
watch: false,
847856
config: { bootstrap: 'artifact-only' },
848-
artifactSource: { mode: 'artifact-api', url: 'https://example.com/artifact' },
857+
artifactSource: { mode: 'artifact-api', url: 'https://example.com' } as any,
858+
});
859+
860+
const manager = (plugin as any).manager;
861+
manager.loadMany = vi.fn().mockResolvedValue([]);
862+
const fetchMock = vi.fn();
863+
const realFetch = globalThis.fetch;
864+
globalThis.fetch = fetchMock as any;
865+
866+
const ctx = createMockPluginContext();
867+
try {
868+
await plugin.init(ctx);
869+
await expect(plugin.start(ctx)).rejects.toThrow(/'artifact-api' source was removed/);
870+
// Rejected before any load path was chosen: no fetch, no FS scan.
871+
expect(fetchMock).not.toHaveBeenCalled();
872+
expect(manager.loadMany).not.toHaveBeenCalled();
873+
} finally {
874+
globalThis.fetch = realFetch;
875+
}
876+
});
877+
878+
// The dangerous degradation the guard exists to prevent: under `eager`,
879+
// treating "unsupported source" as "no source" would fall through to the
880+
// filesystem scan and boot with whatever happens to be on disk instead of
881+
// the artifact the caller named.
882+
it('eager bootstrap rejects the removed mode instead of silently falling back to the filesystem scan', async () => {
883+
const { MetadataPlugin } = await import('./plugin.js');
884+
const plugin = new MetadataPlugin({
885+
rootDir: '/tmp/test',
886+
watch: false,
887+
artifactSource: { mode: 'artifact-api', url: 'https://example.com' } as any,
849888
});
850889

851890
const manager = (plugin as any).manager;
852891
manager.loadMany = vi.fn().mockResolvedValue([]);
853892

854893
const ctx = createMockPluginContext();
855894
await plugin.init(ctx);
856-
await expect(plugin.start(ctx)).rejects.toThrow(/artifact-api/);
895+
await expect(plugin.start(ctx)).rejects.toThrow(/not supported/);
857896
expect(manager.loadMany).not.toHaveBeenCalled();
858897
});
898+
899+
// The migration target named by the rejection message, pinned so it stays
900+
// real: `local-file` with an http(s) URL fetches verbatim and registers
901+
// the envelope-wrapped artifact — the shape the control plane's public
902+
// /pub/v1/environments/:id/artifact route serves.
903+
it('local-file accepts an http(s) URL and registers the fetched artifact envelope', async () => {
904+
const { MetadataPlugin } = await import('./plugin.js');
905+
const url = 'https://cloud.example.com/pub/v1/environments/env_42/artifact?commit=cmt_1';
906+
const envelope = {
907+
schemaVersion: '0.1',
908+
environmentId: 'env_42',
909+
commitId: 'cmt_1',
910+
checksum: 'a'.repeat(64),
911+
metadata: { objects: [{ name: 'artifact_probe', label: 'Artifact Probe', fields: {} }] },
912+
};
913+
const fetchMock = vi.fn().mockResolvedValue({
914+
ok: true,
915+
status: 200,
916+
statusText: 'OK',
917+
text: async () => JSON.stringify(envelope),
918+
});
919+
const realFetch = globalThis.fetch;
920+
globalThis.fetch = fetchMock as any;
921+
922+
const plugin = new MetadataPlugin({
923+
rootDir: '/tmp/test',
924+
watch: false,
925+
environmentId: 'env_42',
926+
config: { bootstrap: 'artifact-only' },
927+
artifactSource: { mode: 'local-file', path: url },
928+
});
929+
const manager = (plugin as any).manager;
930+
manager.loadMany = vi.fn().mockResolvedValue([]);
931+
932+
const ctx = createMockPluginContext();
933+
try {
934+
await plugin.init(ctx);
935+
await plugin.start(ctx);
936+
expect(fetchMock).toHaveBeenCalledTimes(1);
937+
expect(fetchMock.mock.calls[0][0]).toBe(url);
938+
expect(manager.register).toHaveBeenCalledWith(
939+
'object',
940+
'artifact_probe',
941+
expect.objectContaining({ name: 'artifact_probe' }),
942+
{ notify: false },
943+
);
944+
expect(manager.loadMany).not.toHaveBeenCalled();
945+
} finally {
946+
globalThis.fetch = realFetch;
947+
}
948+
});
859949
});
860950
});
861951

0 commit comments

Comments
 (0)