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
5 changes: 5 additions & 0 deletions .changeset/local-manifest-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/cloud-connection": minor
---

`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`.
2 changes: 1 addition & 1 deletion docs/adr/0003-package-as-first-class-citizen.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0003: Package as First-Class Citizen with Versioned Releases

**Status**: Accepted
**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.
**Date**: 2026-04-20
**Deciders**: ObjectStack Protocol Architects
**Supersedes**: The flat `sys_package_installation (package_id + version string)` model introduced alongside ADR-0002
Expand Down
4 changes: 4 additions & 0 deletions packages/cloud-connection/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js';
export type { MarketplaceProxyPluginConfig } from './marketplace-proxy-plugin.js';
export { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
export type { MarketplaceInstallLocalPluginConfig } from './marketplace-install-local-plugin.js';
// ADR-0007 step ⑤ — the local desired-state ledger, exported as a first-class
// seam so hosts/reconcilers can read the same ledger without going through HTTP.
export { LocalManifestSource, DEFAULT_INSTALLED_PACKAGES_DIR } from './local-manifest-source.js';
export type { InstalledManifestEntry } from './local-manifest-source.js';
export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js';
export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js';
export { RuntimeConfigPlugin } from './runtime-config-plugin.js';
Expand Down
73 changes: 73 additions & 0 deletions packages/cloud-connection/src/local-manifest-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* LocalManifestSource — the local desired-state ledger (cloud ADR-0007 ⑤).
* Pure local file operations: list/read/has/write/remove, corrupt-file
* tolerance, and manifest-id sanitisation.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js';

let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'lms-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

const entry = (manifestId: string, version = '1.0.0'): InstalledManifestEntry => ({
packageId: `pkg_${manifestId}`,
versionId: version,
manifestId,
version,
manifest: { id: manifestId, version },
installedAt: '2026-06-12T00:00:00.000Z',
installedBy: 'user-1',
});

describe('LocalManifestSource', () => {
it('starts empty and lists nothing for a missing directory', () => {
const src = new LocalManifestSource(join(dir, 'does-not-exist-yet'));
expect(src.list()).toEqual([]);
expect(src.read('com.acme.crm')).toBeNull();
expect(src.has('com.acme.crm')).toBe(false);
});

it('write → has/read/list round-trips and upserts by manifestId', () => {
const src = new LocalManifestSource(dir);
src.write(entry('com.acme.crm'));
expect(src.has('com.acme.crm')).toBe(true);
expect(src.read('com.acme.crm')?.version).toBe('1.0.0');

src.write(entry('com.acme.crm', '1.1.0')); // upsert, same file
expect(src.list()).toHaveLength(1);
expect(src.read('com.acme.crm')?.version).toBe('1.1.0');
});

it('remove deletes the entry and reports absence', () => {
const src = new LocalManifestSource(dir);
src.write(entry('com.acme.crm'));
expect(src.remove('com.acme.crm')).toBe(true);
expect(src.remove('com.acme.crm')).toBe(false);
expect(src.list()).toEqual([]);
});

it('skips corrupt ledger files in list() and nulls them in read()', () => {
const src = new LocalManifestSource(dir);
src.write(entry('com.acme.good'));
writeFileSync(join(dir, 'com.acme.bad.json'), '{not json', 'utf8');
expect(src.list().map((e) => e.manifestId)).toEqual(['com.acme.good']);
expect(src.read('com.acme.bad')).toBeNull();
});

it('sanitises hostile manifest ids into safe filenames', () => {
const src = new LocalManifestSource(dir);
src.write(entry('../../etc/passwd'));
// Stored INSIDE the ledger dir, traversal characters replaced.
const files = readdirSync(dir);
expect(files).toHaveLength(1);
expect(files[0]).not.toContain('/');
expect(src.has('../../etc/passwd')).toBe(true);
});
});
113 changes: 113 additions & 0 deletions packages/cloud-connection/src/local-manifest-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* LocalManifestSource — the local desired-state ledger for package installs
* (cloud ADR-0007 step ⑤).
*
* A self-hosted / single-environment runtime OWNS its desired state: the
* answer to "which packages should this runtime load" lives on the runtime's
* own disk, one JSON file per installed manifest under
* `<cwd>/.objectstack/installed-packages/`. This class is that ledger,
* promoted to a first-class named seam.
*
* It is the LOCAL isomorph of what a cloud control plane does for managed
* environments (`sys_package_installation` desired rows → compiled
* artifact): same role — desired-state owner — different authority:
*
* | Deployment | Desired-state owner | Runtime truth |
* |-------------------|--------------------------------------|----------------------|
* | Cloud-managed env | control plane (sys_package_installation → artifact) | env-local artifact cache |
* | Self-hosted env | THIS ledger (LocalManifestSource) | the same ledger (rehydrated at boot) |
*
* Nothing here talks to a network: reads and writes are synchronous local
* file operations, so a runtime boots and serves its installed packages
* with zero cloud dependency ("云崩环境不崩").
*
* Consumed by {@link MarketplaceInstallLocalPlugin} (the HTTP surface that
* mutates the ledger) — exported so hosts and future reconcilers can read
* the same ledger without going through HTTP.
*/

import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';

/** One installed-package entry — desired state + provenance. */
export interface InstalledManifestEntry {
packageId: string;
versionId: string;
manifestId: string;
version: string;
manifest: any;
installedAt: string;
installedBy: string | null;
/** Whether the bundled seed datasets have been loaded into the kernel
* database. True after install (seedNow=true) or an explicit reseed;
* false after a purge. Persisted so the UI can show "Add" vs "Re-seed". */
withSampleData?: boolean;
}

/** Default ledger location, relative to the runtime's working directory. */
export const DEFAULT_INSTALLED_PACKAGES_DIR = '.objectstack/installed-packages';

function safeFilename(manifestId: string): string {
return manifestId.replace(/[^a-zA-Z0-9._-]/g, '_') + '.json';
}

export class LocalManifestSource {
/** Resolved ledger directory. */
readonly dir: string;

constructor(storageDir?: string) {
this.dir = storageDir
? resolve(storageDir)
: resolve(process.cwd(), DEFAULT_INSTALLED_PACKAGES_DIR);
}

/** Every valid entry in the ledger (corrupt files are skipped). */
list(): InstalledManifestEntry[] {
if (!existsSync(this.dir)) return [];
const out: InstalledManifestEntry[] = [];
for (const name of readdirSync(this.dir)) {
if (!name.endsWith('.json')) continue;
try {
const raw = readFileSync(join(this.dir, name), 'utf8');
out.push(JSON.parse(raw));
} catch { /* skip corrupt files */ }
}
return out;
}

/** Read one entry; null when absent or unreadable. */
read(manifestId: string): InstalledManifestEntry | null {
const file = this.fileFor(manifestId);
if (!existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return null;
}
}

/** Whether the ledger holds an entry for this manifest id. */
has(manifestId: string): boolean {
return existsSync(this.fileFor(manifestId));
}

/** Create or replace an entry (upsert by manifestId). */
write(entry: InstalledManifestEntry): void {
mkdirSync(this.dir, { recursive: true });
writeFileSync(this.fileFor(entry.manifestId), JSON.stringify(entry, null, 2), 'utf8');

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
}

/** Remove an entry. Returns false when it was not present. */
remove(manifestId: string): boolean {
const file = this.fileFor(manifestId);
if (!existsSync(file)) return false;
unlinkSync(file);
return true;
}

private fileFor(manifestId: string): string {
return join(this.dir, safeFilename(manifestId));
}
}
86 changes: 25 additions & 61 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,13 @@
* cloud round-trips.
*/

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

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

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

interface InstalledEntry {
packageId: string;
versionId: string;
manifestId: string;
version: string;
manifest: any;
installedAt: string;
installedBy: string | null;
/** Whether the bundled seed datasets have been loaded into the kernel
* database. True after install (seedNow=true) or an explicit reseed;
* false after a purge. Persisted so the UI can show "Add" vs "Re-seed". */
withSampleData?: boolean;
}

function safeFilename(manifestId: string): string {
return manifestId.replace(/[^a-zA-Z0-9._-]/g, '_') + '.json';
}
// Desired-state entry shape — owned by the LocalManifestSource ledger
// (ADR-0007 step ⑤: the ledger is the named local desired-state owner;
// this plugin is its HTTP mutation surface).
type InstalledEntry = InstalledManifestEntry;

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

private readonly cloudUrl: string;
private readonly ledger: LocalManifestSource;
private readonly storageDir: string;

constructor(config: MarketplaceInstallLocalPluginConfig = {}) {
this.cloudUrl = resolveCloudUrl(config.controlPlaneUrl);
this.storageDir = config.storageDir
? resolve(config.storageDir)
: resolve(process.cwd(), DEFAULT_DIR);
this.ledger = new LocalManifestSource(config.storageDir);
this.storageDir = this.ledger.dir;
}

init = async (_ctx: PluginContext): Promise<void> => {
Expand Down Expand Up @@ -325,8 +310,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
withSampleData: false,
};
try {
mkdirSync(this.storageDir, { recursive: true });
writeFileSync(join(this.storageDir, safeFilename(manifestId)), JSON.stringify(entry, null, 2), 'utf8');
this.ledger.write(entry);
} catch (err: any) {
return c.json({
success: false,
Expand Down Expand Up @@ -357,7 +341,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
if (seededSummary.seeded.mode === 'inline' && (seededSummary.seeded.inserted ?? 0) + (seededSummary.seeded.updated ?? 0) > 0) {
entry.withSampleData = true;
try {
writeFileSync(join(this.storageDir, safeFilename(manifestId)), JSON.stringify(entry, null, 2), 'utf8');
this.ledger.write(entry);
} catch { /* non-fatal — entry already on disk */ }
}

Expand Down Expand Up @@ -406,12 +390,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
if (!manifestId) {
return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400);
}
const file = join(this.storageDir, safeFilename(manifestId));
if (!existsSync(file)) {
if (!this.ledger.has(manifestId)) {
return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404);
}
try {
unlinkSync(file);
this.ledger.remove(manifestId);
} catch (err: any) {
return c.json({ success: false, error: { code: 'storage_failed', message: err?.message ?? String(err) } }, 500);
}
Expand All @@ -435,8 +418,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
* (refuse to avoid silently overwriting authored code)
*/
private findConflict = (ctx: PluginContext, manifestId: string): 'none' | 'marketplace' | 'user-code' => {
// First check: do we already have a marketplace install file?
if (existsSync(join(this.storageDir, safeFilename(manifestId)))) {
// First check: do we already have a marketplace install entry?
if (this.ledger.has(manifestId)) {
return 'marketplace';
}
// Then check: is the manifest_id already in the engine's registry?
Expand Down Expand Up @@ -479,16 +462,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
if (!manifestId) {
return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400);
}
const file = join(this.storageDir, safeFilename(manifestId));
if (!existsSync(file)) {
if (!this.ledger.has(manifestId)) {
return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404);
}

let entry: InstalledEntry;
try {
entry = JSON.parse(readFileSync(file, 'utf8'));
} catch (err: any) {
return c.json({ success: false, error: { code: 'storage_failed', message: `Failed to read manifest cache: ${err?.message ?? err}` } }, 500);
const entry: InstalledEntry | null = this.ledger.read(manifestId);
if (!entry) {
return c.json({ success: false, error: { code: 'storage_failed', message: 'Failed to read manifest cache.' } }, 500);
}

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

return c.json({
Expand Down Expand Up @@ -538,16 +517,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
if (!manifestId) {
return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400);
}
const file = join(this.storageDir, safeFilename(manifestId));
if (!existsSync(file)) {
if (!this.ledger.has(manifestId)) {
return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404);
}

let entry: InstalledEntry;
try {
entry = JSON.parse(readFileSync(file, 'utf8'));
} catch (err: any) {
return c.json({ success: false, error: { code: 'storage_failed', message: `Failed to read manifest cache: ${err?.message ?? err}` } }, 500);
const entry: InstalledEntry | null = this.ledger.read(manifestId);
if (!entry) {
return c.json({ success: false, error: { code: 'storage_failed', message: 'Failed to read manifest cache.' } }, 500);
}

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

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

private readAll = (): InstalledEntry[] => {
if (!existsSync(this.storageDir)) return [];
const out: InstalledEntry[] = [];
for (const name of readdirSync(this.storageDir)) {
if (!name.endsWith('.json')) continue;
try {
const raw = readFileSync(join(this.storageDir, name), 'utf8');
out.push(JSON.parse(raw));
} catch { /* skip corrupt files */ }
}
return out;
};
private readAll = (): InstalledEntry[] => this.ledger.list();
}