Skip to content

Commit 17ffc74

Browse files
os-zhuangclaude
andauthored
feat(cloud-connection): LocalManifestSource — local desired-state ledger as a first-class seam (cloud ADR-0007 ⑤) (#1761)
The install-local disk ledger (.objectstack/installed-packages/) is promoted to a named, exported LocalManifestSource: the desired-state owner for self-hosted runtimes, isomorphic to what the cloud control plane's sys_package_installation does for managed environments. MarketplaceInstallLocalPlugin delegates all ledger I/O to it — behavior identical (404/500 semantics preserved). ADR-0003 gains the cloud ADR-0007 revision note: installation rows are desired state, never read as runtime truth; observed_* is a reported projection. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7c409f0 commit 17ffc74

6 files changed

Lines changed: 221 additions & 62 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
---
4+
5+
`LocalManifestSource` — the install-local disk ledger promoted to a first-class, exported desired-state owner for self-hosted runtimes (cloud ADR-0007 step ⑤). `MarketplaceInstallLocalPlugin` now delegates all ledger reads/writes to it; behavior unchanged. Also exports `InstalledManifestEntry` and `DEFAULT_INSTALLED_PACKAGES_DIR`.

docs/adr/0003-package-as-first-class-citizen.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0003: Package as First-Class Citizen with Versioned Releases
22

3-
**Status**: Accepted
3+
**Status**: Accepted**revised by cloud ADR-0007 (2026-06-12)**: `sys_package_installation` is hereby redefined as **desired state owned by the management plane**, never read as "what is actually installed" on any runtime path. Runtime truth lives with the environment itself (env-local artifact cache for cloud-managed environments; the `LocalManifestSource` ledger in `@objectstack/cloud-connection` for self-hosted runtimes). `observed_status` / `last_reconciled_at` on the installation row are a reported projection for drift visibility, not truth. The original "installation state lives only in the control plane, environment DBs hold zero system tables" framing of ADR-0002/0003 is superseded to that extent — driven by the hard constraint that environments must boot and serve with the cloud down.
44
**Date**: 2026-04-20
55
**Deciders**: ObjectStack Protocol Architects
66
**Supersedes**: The flat `sys_package_installation (package_id + version string)` model introduced alongside ADR-0002

packages/cloud-connection/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js';
3636
export type { MarketplaceProxyPluginConfig } from './marketplace-proxy-plugin.js';
3737
export { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
3838
export type { MarketplaceInstallLocalPluginConfig } from './marketplace-install-local-plugin.js';
39+
// ADR-0007 step ⑤ — the local desired-state ledger, exported as a first-class
40+
// seam so hosts/reconcilers can read the same ledger without going through HTTP.
41+
export { LocalManifestSource, DEFAULT_INSTALLED_PACKAGES_DIR } from './local-manifest-source.js';
42+
export type { InstalledManifestEntry } from './local-manifest-source.js';
3943
export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js';
4044
export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js';
4145
export { RuntimeConfigPlugin } from './runtime-config-plugin.js';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* LocalManifestSource — the local desired-state ledger (cloud ADR-0007 ⑤).
5+
* Pure local file operations: list/read/has/write/remove, corrupt-file
6+
* tolerance, and manifest-id sanitisation.
7+
*/
8+
9+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10+
import { mkdtempSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
11+
import { join } from 'node:path';
12+
import { tmpdir } from 'node:os';
13+
import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js';
14+
15+
let dir: string;
16+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'lms-')); });
17+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
18+
19+
const entry = (manifestId: string, version = '1.0.0'): InstalledManifestEntry => ({
20+
packageId: `pkg_${manifestId}`,
21+
versionId: version,
22+
manifestId,
23+
version,
24+
manifest: { id: manifestId, version },
25+
installedAt: '2026-06-12T00:00:00.000Z',
26+
installedBy: 'user-1',
27+
});
28+
29+
describe('LocalManifestSource', () => {
30+
it('starts empty and lists nothing for a missing directory', () => {
31+
const src = new LocalManifestSource(join(dir, 'does-not-exist-yet'));
32+
expect(src.list()).toEqual([]);
33+
expect(src.read('com.acme.crm')).toBeNull();
34+
expect(src.has('com.acme.crm')).toBe(false);
35+
});
36+
37+
it('write → has/read/list round-trips and upserts by manifestId', () => {
38+
const src = new LocalManifestSource(dir);
39+
src.write(entry('com.acme.crm'));
40+
expect(src.has('com.acme.crm')).toBe(true);
41+
expect(src.read('com.acme.crm')?.version).toBe('1.0.0');
42+
43+
src.write(entry('com.acme.crm', '1.1.0')); // upsert, same file
44+
expect(src.list()).toHaveLength(1);
45+
expect(src.read('com.acme.crm')?.version).toBe('1.1.0');
46+
});
47+
48+
it('remove deletes the entry and reports absence', () => {
49+
const src = new LocalManifestSource(dir);
50+
src.write(entry('com.acme.crm'));
51+
expect(src.remove('com.acme.crm')).toBe(true);
52+
expect(src.remove('com.acme.crm')).toBe(false);
53+
expect(src.list()).toEqual([]);
54+
});
55+
56+
it('skips corrupt ledger files in list() and nulls them in read()', () => {
57+
const src = new LocalManifestSource(dir);
58+
src.write(entry('com.acme.good'));
59+
writeFileSync(join(dir, 'com.acme.bad.json'), '{not json', 'utf8');
60+
expect(src.list().map((e) => e.manifestId)).toEqual(['com.acme.good']);
61+
expect(src.read('com.acme.bad')).toBeNull();
62+
});
63+
64+
it('sanitises hostile manifest ids into safe filenames', () => {
65+
const src = new LocalManifestSource(dir);
66+
src.write(entry('../../etc/passwd'));
67+
// Stored INSIDE the ledger dir, traversal characters replaced.
68+
const files = readdirSync(dir);
69+
expect(files).toHaveLength(1);
70+
expect(files[0]).not.toContain('/');
71+
expect(src.has('../../etc/passwd')).toBe(true);
72+
});
73+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* LocalManifestSource — the local desired-state ledger for package installs
5+
* (cloud ADR-0007 step ⑤).
6+
*
7+
* A self-hosted / single-environment runtime OWNS its desired state: the
8+
* answer to "which packages should this runtime load" lives on the runtime's
9+
* own disk, one JSON file per installed manifest under
10+
* `<cwd>/.objectstack/installed-packages/`. This class is that ledger,
11+
* promoted to a first-class named seam.
12+
*
13+
* It is the LOCAL isomorph of what a cloud control plane does for managed
14+
* environments (`sys_package_installation` desired rows → compiled
15+
* artifact): same role — desired-state owner — different authority:
16+
*
17+
* | Deployment | Desired-state owner | Runtime truth |
18+
* |-------------------|--------------------------------------|----------------------|
19+
* | Cloud-managed env | control plane (sys_package_installation → artifact) | env-local artifact cache |
20+
* | Self-hosted env | THIS ledger (LocalManifestSource) | the same ledger (rehydrated at boot) |
21+
*
22+
* Nothing here talks to a network: reads and writes are synchronous local
23+
* file operations, so a runtime boots and serves its installed packages
24+
* with zero cloud dependency ("云崩环境不崩").
25+
*
26+
* Consumed by {@link MarketplaceInstallLocalPlugin} (the HTTP surface that
27+
* mutates the ledger) — exported so hosts and future reconcilers can read
28+
* the same ledger without going through HTTP.
29+
*/
30+
31+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
32+
import { join, resolve } from 'node:path';
33+
34+
/** One installed-package entry — desired state + provenance. */
35+
export interface InstalledManifestEntry {
36+
packageId: string;
37+
versionId: string;
38+
manifestId: string;
39+
version: string;
40+
manifest: any;
41+
installedAt: string;
42+
installedBy: string | null;
43+
/** Whether the bundled seed datasets have been loaded into the kernel
44+
* database. True after install (seedNow=true) or an explicit reseed;
45+
* false after a purge. Persisted so the UI can show "Add" vs "Re-seed". */
46+
withSampleData?: boolean;
47+
}
48+
49+
/** Default ledger location, relative to the runtime's working directory. */
50+
export const DEFAULT_INSTALLED_PACKAGES_DIR = '.objectstack/installed-packages';
51+
52+
function safeFilename(manifestId: string): string {
53+
return manifestId.replace(/[^a-zA-Z0-9._-]/g, '_') + '.json';
54+
}
55+
56+
export class LocalManifestSource {
57+
/** Resolved ledger directory. */
58+
readonly dir: string;
59+
60+
constructor(storageDir?: string) {
61+
this.dir = storageDir
62+
? resolve(storageDir)
63+
: resolve(process.cwd(), DEFAULT_INSTALLED_PACKAGES_DIR);
64+
}
65+
66+
/** Every valid entry in the ledger (corrupt files are skipped). */
67+
list(): InstalledManifestEntry[] {
68+
if (!existsSync(this.dir)) return [];
69+
const out: InstalledManifestEntry[] = [];
70+
for (const name of readdirSync(this.dir)) {
71+
if (!name.endsWith('.json')) continue;
72+
try {
73+
const raw = readFileSync(join(this.dir, name), 'utf8');
74+
out.push(JSON.parse(raw));
75+
} catch { /* skip corrupt files */ }
76+
}
77+
return out;
78+
}
79+
80+
/** Read one entry; null when absent or unreadable. */
81+
read(manifestId: string): InstalledManifestEntry | null {
82+
const file = this.fileFor(manifestId);
83+
if (!existsSync(file)) return null;
84+
try {
85+
return JSON.parse(readFileSync(file, 'utf8'));
86+
} catch {
87+
return null;
88+
}
89+
}
90+
91+
/** Whether the ledger holds an entry for this manifest id. */
92+
has(manifestId: string): boolean {
93+
return existsSync(this.fileFor(manifestId));
94+
}
95+
96+
/** Create or replace an entry (upsert by manifestId). */
97+
write(entry: InstalledManifestEntry): void {
98+
mkdirSync(this.dir, { recursive: true });
99+
writeFileSync(this.fileFor(entry.manifestId), JSON.stringify(entry, null, 2), 'utf8');
100+
}
101+
102+
/** Remove an entry. Returns false when it was not present. */
103+
remove(manifestId: string): boolean {
104+
const file = this.fileFor(manifestId);
105+
if (!existsSync(file)) return false;
106+
unlinkSync(file);
107+
return true;
108+
}
109+
110+
private fileFor(manifestId: string): string {
111+
return join(this.dir, safeFilename(manifestId));
112+
}
113+
}

packages/cloud-connection/src/marketplace-install-local-plugin.ts

Lines changed: 25 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,13 @@
4141
* cloud round-trips.
4242
*/
4343

44-
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
45-
import { join, resolve } from 'node:path';
4644
import type { Plugin, PluginContext } from '@objectstack/core';
4745
import { readEnvWithDeprecation } from '@objectstack/types';
4846
import { resolveCloudUrl } from './cloud-url.js';
4947
import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js';
48+
import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js';
5049

5150
const ROUTE_BASE = '/api/v1/marketplace/install-local';
52-
const DEFAULT_DIR = '.objectstack/installed-packages';
5351

5452
export interface MarketplaceInstallLocalPluginConfig {
5553
/** Cloud control-plane base URL. When unset, falls back to OS_CLOUD_URL
@@ -62,36 +60,23 @@ export interface MarketplaceInstallLocalPluginConfig {
6260
storageDir?: string;
6361
}
6462

65-
interface InstalledEntry {
66-
packageId: string;
67-
versionId: string;
68-
manifestId: string;
69-
version: string;
70-
manifest: any;
71-
installedAt: string;
72-
installedBy: string | null;
73-
/** Whether the bundled seed datasets have been loaded into the kernel
74-
* database. True after install (seedNow=true) or an explicit reseed;
75-
* false after a purge. Persisted so the UI can show "Add" vs "Re-seed". */
76-
withSampleData?: boolean;
77-
}
78-
79-
function safeFilename(manifestId: string): string {
80-
return manifestId.replace(/[^a-zA-Z0-9._-]/g, '_') + '.json';
81-
}
63+
// Desired-state entry shape — owned by the LocalManifestSource ledger
64+
// (ADR-0007 step ⑤: the ledger is the named local desired-state owner;
65+
// this plugin is its HTTP mutation surface).
66+
type InstalledEntry = InstalledManifestEntry;
8267

8368
export class MarketplaceInstallLocalPlugin implements Plugin {
8469
readonly name = 'com.objectstack.runtime.marketplace-install-local';
8570
readonly version = '1.0.0';
8671

8772
private readonly cloudUrl: string;
73+
private readonly ledger: LocalManifestSource;
8874
private readonly storageDir: string;
8975

9076
constructor(config: MarketplaceInstallLocalPluginConfig = {}) {
9177
this.cloudUrl = resolveCloudUrl(config.controlPlaneUrl);
92-
this.storageDir = config.storageDir
93-
? resolve(config.storageDir)
94-
: resolve(process.cwd(), DEFAULT_DIR);
78+
this.ledger = new LocalManifestSource(config.storageDir);
79+
this.storageDir = this.ledger.dir;
9580
}
9681

9782
init = async (_ctx: PluginContext): Promise<void> => {
@@ -325,8 +310,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
325310
withSampleData: false,
326311
};
327312
try {
328-
mkdirSync(this.storageDir, { recursive: true });
329-
writeFileSync(join(this.storageDir, safeFilename(manifestId)), JSON.stringify(entry, null, 2), 'utf8');
313+
this.ledger.write(entry);
330314
} catch (err: any) {
331315
return c.json({
332316
success: false,
@@ -357,7 +341,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
357341
if (seededSummary.seeded.mode === 'inline' && (seededSummary.seeded.inserted ?? 0) + (seededSummary.seeded.updated ?? 0) > 0) {
358342
entry.withSampleData = true;
359343
try {
360-
writeFileSync(join(this.storageDir, safeFilename(manifestId)), JSON.stringify(entry, null, 2), 'utf8');
344+
this.ledger.write(entry);
361345
} catch { /* non-fatal — entry already on disk */ }
362346
}
363347

@@ -406,12 +390,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
406390
if (!manifestId) {
407391
return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400);
408392
}
409-
const file = join(this.storageDir, safeFilename(manifestId));
410-
if (!existsSync(file)) {
393+
if (!this.ledger.has(manifestId)) {
411394
return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404);
412395
}
413396
try {
414-
unlinkSync(file);
397+
this.ledger.remove(manifestId);
415398
} catch (err: any) {
416399
return c.json({ success: false, error: { code: 'storage_failed', message: err?.message ?? String(err) } }, 500);
417400
}
@@ -435,8 +418,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
435418
* (refuse to avoid silently overwriting authored code)
436419
*/
437420
private findConflict = (ctx: PluginContext, manifestId: string): 'none' | 'marketplace' | 'user-code' => {
438-
// First check: do we already have a marketplace install file?
439-
if (existsSync(join(this.storageDir, safeFilename(manifestId)))) {
421+
// First check: do we already have a marketplace install entry?
422+
if (this.ledger.has(manifestId)) {
440423
return 'marketplace';
441424
}
442425
// Then check: is the manifest_id already in the engine's registry?
@@ -479,16 +462,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
479462
if (!manifestId) {
480463
return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400);
481464
}
482-
const file = join(this.storageDir, safeFilename(manifestId));
483-
if (!existsSync(file)) {
465+
if (!this.ledger.has(manifestId)) {
484466
return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404);
485467
}
486-
487-
let entry: InstalledEntry;
488-
try {
489-
entry = JSON.parse(readFileSync(file, 'utf8'));
490-
} catch (err: any) {
491-
return c.json({ success: false, error: { code: 'storage_failed', message: `Failed to read manifest cache: ${err?.message ?? err}` } }, 500);
468+
const entry: InstalledEntry | null = this.ledger.read(manifestId);
469+
if (!entry) {
470+
return c.json({ success: false, error: { code: 'storage_failed', message: 'Failed to read manifest cache.' } }, 500);
492471
}
493472

494473
const summary = await this.applySideEffects(ctx, entry.manifest, { seedNow: true, c });
@@ -505,7 +484,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
505484
// Persist flag flip
506485
try {
507486
entry.withSampleData = true;
508-
writeFileSync(file, JSON.stringify(entry, null, 2), 'utf8');
487+
this.ledger.write(entry);
509488
} catch { /* non-fatal */ }
510489

511490
return c.json({
@@ -538,16 +517,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
538517
if (!manifestId) {
539518
return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400);
540519
}
541-
const file = join(this.storageDir, safeFilename(manifestId));
542-
if (!existsSync(file)) {
520+
if (!this.ledger.has(manifestId)) {
543521
return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404);
544522
}
545-
546-
let entry: InstalledEntry;
547-
try {
548-
entry = JSON.parse(readFileSync(file, 'utf8'));
549-
} catch (err: any) {
550-
return c.json({ success: false, error: { code: 'storage_failed', message: `Failed to read manifest cache: ${err?.message ?? err}` } }, 500);
523+
const entry: InstalledEntry | null = this.ledger.read(manifestId);
524+
if (!entry) {
525+
return c.json({ success: false, error: { code: 'storage_failed', message: 'Failed to read manifest cache.' } }, 500);
551526
}
552527

553528
const datasets = Array.isArray(entry.manifest?.data)
@@ -594,7 +569,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
594569
// Flip flag so UI reflects the empty baseline
595570
try {
596571
entry.withSampleData = false;
597-
writeFileSync(file, JSON.stringify(entry, null, 2), 'utf8');
572+
this.ledger.write(entry);
598573
} catch { /* non-fatal */ }
599574

600575
ctx.logger?.info?.(`[MarketplaceInstallLocal] purged ${manifestId}: deleted=${deleted} skipped=${skipped} errors=${errors}`);
@@ -814,16 +789,5 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
814789
return null;
815790
};
816791

817-
private readAll = (): InstalledEntry[] => {
818-
if (!existsSync(this.storageDir)) return [];
819-
const out: InstalledEntry[] = [];
820-
for (const name of readdirSync(this.storageDir)) {
821-
if (!name.endsWith('.json')) continue;
822-
try {
823-
const raw = readFileSync(join(this.storageDir, name), 'utf8');
824-
out.push(JSON.parse(raw));
825-
} catch { /* skip corrupt files */ }
826-
}
827-
return out;
828-
};
792+
private readAll = (): InstalledEntry[] => this.ledger.list();
829793
}

0 commit comments

Comments
 (0)