Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/eighty-hoops-repeat.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
---
'@objectstack/service-datasource': patch
---

Ledger the mounted datasource-admin routes, at the spelling they are mounted under.

The ten admin CRUD routes under `/api/v1/datasources` — list, read, create, patch,
remove, connection probe, driver catalog and schema introspection — carried no
route-ledger entry in any of the three ledgers the platform keeps. They are mounted
the "third way" `service-storage` and `service-i18n` grew their own ledgers for:
`objectstack serve` builds a small plugin that resolves the `http.server` service and
registers straight on `IHttpServer`, so neither `RouteManager` nor
`RestServer.getRoutes()` ever sees them.

The five `datasources` rows the REST ledger does carry are the **federation** family
(`/api/v1/datasources/:name/external/…`), which is a different, separately mounted
family in `@objectstack/rest` — not the admin family misspelled. Both are live, and
no route is renamed here: the new ledger is written at the live admin spelling, and
its conformance guard derives what it expects from the registrar rather than from a
literal in the test, so an eleventh route fails the guard instead of silently
reopening the gap.

No runtime behaviour changes — this adds a package-internal ledger and its guard.
38 changes: 33 additions & 5 deletions packages/client/src/service-route-ledger-coverage.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,28 +7,31 @@
*
* - `packages/services/service-storage/src/storage-route-ledger.conformance.test.ts`
* - `packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts`
* - `packages/services/service-datasource/src/datasource-route-ledger.conformance.test.ts`
*
* Same contract as the two ledger guards next door
* (`route-ledger-coverage.test.ts`, `rest-route-ledger-coverage.test.ts`):
* every ledger entry that names a client method must resolve to a real
* function on an instantiated client.
*
* Both ledgers are imported as relative SOURCE files deliberately: they are
* pure data (no imports), and a client→service package edge for them would be
* backwards — the services are where the routes are declared, so the ledgers
* All three ledgers are imported as relative SOURCE files deliberately: they
* are pure data (no imports), and a client→service package edge for them would
* be backwards — the services are where the routes are declared, so the ledgers
* live there, and each package verifies its own half. That is the tranche-1
* lesson (`no runtime→client package edge`) applied verbatim: CI's per-package
* test tasks build only their own dependency closure, so a real package edge
* here would be unbuildable.
*
* With these two, all THREE server surfaces the SDK reaches are ledgered —
* dispatcher (#3563), REST (#3587), autonomous service mounts (#3636).
* With these, all THREE server surfaces the SDK reaches are ledgered —
* dispatcher (#3563), REST (#3587), autonomous service mounts (#3636, joined by
* the datasource-admin family in #7744).
*/

import { describe, it, expect } from 'vitest';
import { ObjectStackClient } from './index';
import { STORAGE_ROUTE_LEDGER } from '../../services/service-storage/src/storage-route-ledger';
import { I18N_ROUTE_LEDGER } from '../../services/service-i18n/src/i18n-route-ledger';
import { DATASOURCE_ROUTE_LEDGER } from '../../services/service-datasource/src/datasource-route-ledger';

describe('service route ledgers ↔ @objectstack/client surface', () => {
const client = new ObjectStackClient({ baseUrl: 'http://localhost:9' });
Expand DownExpand Up@@ -58,6 +61,31 @@ describe('service route ledgers ↔ @objectstack/client surface', () => {
).toEqual([]);
});

it('every datasource-admin ledger entry naming a client method resolves to a real function', () => {
const broken = brokenIn(DATASOURCE_ROUTE_LEDGER);
expect(
broken,
`datasource-admin ledger entries claiming a client method that does not exist: ${broken.join('; ')}`,
).toEqual([]);
});

it('the datasource-admin family is audited as reaching NO client method', () => {
// Said as a measurement rather than left implicit, because the assertion
// above holds vacuously while every row is `server-only` — and a guard
// that can only ever pass is the "declared but unverified" shape these
// ledgers exist to remove. What is measured here is the #7744 audit's
// actual finding: `client.datasources` reaches the FEDERATION family in
// packages/rest (ledgered there, as `datasources.external.*`), and no
// method reaches the Setup/console lifecycle family. The day the SDK
// gains a datasource-lifecycle method, this expectation is the one that
// fails and sends the author to the ledger row that must name it.
const claimed = DATASOURCE_ROUTE_LEDGER.map((e) => e.client).filter((c) => c != null);
expect(claimed).toEqual([]);

const datasources = (client as unknown as { datasources: Record<string, unknown> }).datasources;
expect(Object.keys(datasources)).toEqual(['external']);
});

it('the whole `storage` namespace is backed by a ledger row', () => {
// The reverse direction, scoped to the namespace this tranche audited:
// a client method reaching a storage route that no ledger row backs is
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Datasource-admin route-ledger conformance (#7744) — the guard that keeps the
* autonomously-mounted datasource-admin surface and `@objectstack/client` from
* drifting apart silently, mirroring the storage (#3636) and i18n (#3636)
* guards.
*
* Directions made loud here:
*
* 1. A route `registerDatasourceAdminRoutes` mounts with no ledger entry — a
* new route landed without a reviewed SDK disposition. This is the
* direction that was open when #7744 was filed: all ten routes were in it.
* 2. A ledger entry for a route the registrar no longer mounts — the ledger
* went stale.
*
* ENUMERATION IS DERIVED, NOT TRANSCRIBED, and that is the whole point of the
* file. The registrar runs against a capturing mock `IHttpServer` and its
* registration calls ARE the route set — the same seam the two tranche-3
* ledgers use. No path literal in this file is compared against the mount: a
* hand-copied expectation would go green on a ledger that agrees with the test
* and disagrees with the server, which is exactly the class of gap #7744
* reported (the REST ledger spelled the family `/external/tables` while the
* mount said `/remote-tables`, and nothing was comparing either to a mount).
* Add an eleventh route and this file fails until it is ledgered.
*
* The third direction — "every `sdk` row names a client method that exists" —
* lives in `packages/client/src/service-route-ledger-coverage.test.ts`, next to
* the SDK it introspects: a service→client package edge would be backwards.
*/

import { describe, it, expect, vi } from 'vitest';
import { registerDatasourceAdminRoutes } from './admin-routes.js';
import { DATASOURCE_ROUTE_LEDGER } from './datasource-route-ledger.js';

/** Minimal IHttpServer mock that records registrations. */
function createMockServer() {
return {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
use: vi.fn(),
listen: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
}

/**
* `VERB /path` keys for every route the registrar mounts at the DEFAULT base.
*
* The context is never consulted during registration — every handler resolves
* its service per request through `resolve()`, which is what lets the family
* answer 503 rather than record a boot-time verdict about a service that may
* still register (AGENTS.md, "never record a verdict the boot can still
* contradict"). So a context that answers `undefined` for everything still
* enumerates the full surface.
*/
function enumerateDatasourceAdminRoutes(): Set<string> {
const server = createMockServer();
const ctx = { getService: () => undefined, logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } };
registerDatasourceAdminRoutes(server as never, ctx as never, '/api/v1');
const keys = new Set<string>();
for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) {
for (const call of server[verb].mock.calls) {
keys.add(`${verb.toUpperCase()} ${String(call[0])}`);
}
}
return keys;
}

const ledgerKeys = (): Set<string> => new Set(DATASOURCE_ROUTE_LEDGER.map((e) => e.route));

describe('datasource-admin route ledger ↔ registerDatasourceAdminRoutes enumeration', () => {
it('every mounted datasource-admin route has a ledger entry', () => {
const ledger = ledgerKeys();
const missing = [...enumerateDatasourceAdminRoutes()].filter((k) => !ledger.has(k));
expect(
missing,
`Datasource-admin routes with no datasource-route-ledger entry: ${missing.join(', ')}. ` +
'A new route needs a reviewed disposition in datasource-route-ledger.ts (#7744).',
).toEqual([]);
});

it('every ledger entry is really mounted by the registrar', () => {
const live = enumerateDatasourceAdminRoutes();
const stale = [...ledgerKeys()].filter((k) => !live.has(k));
expect(
stale,
`datasource-route-ledger entries the registrar no longer mounts: ${stale.join(', ')}. ` +
'Remove or reclassify them so the ledger stays truthful.',
).toEqual([]);
});

it('no route is ledgered twice', () => {
const seen = new Set<string>();
const dupes = DATASOURCE_ROUTE_LEDGER.map((e) => e.route).filter((r) => !seen.add(r));
expect(dupes, `duplicate datasource-route-ledger rows: ${dupes.join(', ')}`).toEqual([]);
});

it('the ledger is compared against a real enumeration, not an empty one', () => {
// Absence must be loud (AGENTS.md, Route & surface ownership §3). Both
// set-difference assertions above pass vacuously if the registrar ever
// stops registering — a refactor that moves the mount elsewhere, or a mock
// whose recorded calls stop being readable, would leave this file green
// while guarding nothing. So assert the enumeration produced something,
// and that the two sides are the same size rather than merely non-conflicting.
const live = enumerateDatasourceAdminRoutes();
expect(live.size).toBeGreaterThan(0);
expect(live.size).toBe(ledgerKeys().size);
});

it('carries the LIVE admin spelling of the introspection route, not the federation one', () => {
// The #7744 regression pin, stated as the card states it. `/remote-tables`
// is what `admin-routes.ts` mounts; `/external/tables` is the separate
// federation route in packages/rest, ledgered there. Reading the value out
// of the ENUMERATION rather than asserting a literal is what keeps this an
// assertion about the mount: if the mount were ever renamed, the row this
// resolves would follow it — while the first two assertions above would
// still catch the rename as a ledger diff.
const live = enumerateDatasourceAdminRoutes();
const introspection = [...live].filter((k) => k.includes('remote-tables'));
expect(introspection).not.toEqual([]);
for (const key of introspection) {
expect(
ledgerKeys().has(key),
`${key} is mounted but unledgered — the #7744 gap, reopened.`,
).toBe(true);
}
// And the federation spelling is NOT this ledger's business: it belongs to
// packages/rest, whose own conformance guard would fail if it moved here.
expect([...ledgerKeys()].filter((k) => k.includes('/external/'))).toEqual([]);
});
});

describe('datasource-admin route ledger hygiene', () => {
it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => {
const sdkWithout = DATASOURCE_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route);
expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]);

const bareNonSdk = DATASOURCE_ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route);
expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]);
});

it('gap and mismatch counts only shrink — update the ledger (and these numbers) when closing them', () => {
// Ratchet, not aspiration. The family audited at #7744 as ten reviewed
// `server-only` rows: no client method and no CLI command reaches any of
// them, and they are mounted by `objectstack serve` rather than by
// `@objectstack/rest`. Zero is therefore the measured state, not an
// aspiration — and a `gap` row here would be a product decision to give
// the SDK a datasource-lifecycle surface, which needs its own review.
const gaps = DATASOURCE_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
expect(gaps).toBeLessThanOrEqual(0);

const mismatches = DATASOURCE_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length;
expect(mismatches).toBeLessThanOrEqual(0);
});
});
Loading
Loading