Skip to content

Commit efcd68c

Browse files
authored
fix(cli,service-storage): stop re-pointing the storage adapter every boot — and mean it when it warns (#4096) (#4166)
Every `os dev` / `os serve` boot printed "storage adapter swapped (LocalStorageAdapter → LocalStorageAdapter). Existing files were NOT migrated and may be unreachable through the new adapter." The warning was telling the truth. `serve` constructed the plugin with `{ driver: 'local', root }` and `StorageServicePluginOptions` declares neither key, so both were dropped silently: the plugin applied its own `./storage` default, OS_STORAGE_ROOT changed nothing, and uploads landed in a directory nobody named. The `storage` settings namespace then corrected the root on its first read — its manifest default is `./.objectstack/data/uploads` — genuinely moving the backing store, every boot. Three defects, three fixes: - serve passes options the plugin reads: `{ adapter: 'local', local: { rootDir } }`. Extracted as `resolveStorageCapabilityArg` so the option SHAPE is pinned — a mismatch like this type-checks fine as an argument and does nothing at runtime. - A swap is skipped when nothing changed: the plugin records what the running adapter points at and compares resolved CONFIGURATIONS, rather than rebuilding whenever the settings namespace held any value at all. - The warning means what it says — it fires when the BACKING STORE moved, not when the adapter object was replaced. A credential rotation swaps so the new key takes effect and logs at info. A swap from a caller that resolved no target still warns; ignorance must not silence it. Path spellings are normalised, so the platform writing one default two ways is not read as a migration between a directory and itself. The docs already described the fixed behaviour: environment-variables.mdx says OS_STORAGE_ROOT is used by serve's storage wiring, and backup-restore.mdx tells operators to back up `.objectstack/data/uploads` — a directory that was never written to, so the documented backup procedure captured none of the uploaded files. No doc changes needed; the fix makes those pages true. Verified on examples/app-todo: boot diagnostics went from four warnings to three, the storage line is gone, and `./storage` is no longer created. 19 unit cases on the resolver and the swap/warn split, 4 plugin-level, 7 on the CLI option shape; all confirmed to fail against the pre-fix code. Closes #4096
1 parent 7733604 commit efcd68c

7 files changed

Lines changed: 668 additions & 13 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
**The storage adapter stops being rebuilt and re-pointed on every boot, and the
7+
"files may be unreachable" warning stops firing at a healthy server (#4096).**
8+
9+
Every `os dev` / `os serve` boot printed:
10+
11+
```
12+
WARN StorageServicePlugin: storage adapter swapped (LocalStorageAdapter →
13+
LocalStorageAdapter). Existing files were NOT migrated and may be unreachable
14+
through the new adapter.
15+
```
16+
17+
The warning was telling the truth. `serve` constructed the plugin with
18+
`{ driver: 'local', root }` — and `StorageServicePluginOptions` declares
19+
neither key. Both were dropped silently, so the plugin applied its own
20+
`./storage` default, `OS_STORAGE_ROOT` changed nothing, and uploads landed in a
21+
directory nobody named. The `storage` settings namespace then corrected the root
22+
on its first read (its manifest default is `./.objectstack/data/uploads`),
23+
genuinely moving the backing store — every boot, forever.
24+
25+
Three fixes, because there were three defects:
26+
27+
- **`serve` now passes options the plugin reads** — `{ adapter: 'local',
28+
local: { rootDir } }`. `OS_STORAGE_ROOT` takes effect, and local uploads land
29+
under `.objectstack/data/uploads` from the first byte instead of `./storage`.
30+
Extracted as `resolveStorageCapabilityArg` so the option SHAPE is pinned by
31+
tests: a mismatch like this type-checks fine and does nothing at runtime.
32+
- **A swap is skipped when nothing changed.** The plugin records what the
33+
running adapter points at and compares resolved configurations, instead of
34+
rebuilding whenever the settings namespace held any value at all — which is
35+
every boot once that namespace has persisted its own defaults.
36+
- **The warning now means what it says.** It fires when the BACKING STORE moved
37+
(kind change, different root, different bucket/region/endpoint), not merely
38+
when the adapter object was replaced. A credential rotation swaps the adapter
39+
so the new key takes effect and logs at info: same bucket, nothing stranded.
40+
A swap from a caller that resolved no target still warns — ignorance must not
41+
silence it.
42+
43+
Path spellings are normalised, so the platform writing the same default two ways
44+
(`./.objectstack/data/uploads` in the settings manifest,
45+
`.objectstack/data/uploads` in the CLI) is no longer read as a migration between
46+
a directory and itself.
47+
48+
Verified on `examples/app-todo`: the boot-diagnostics block went from four
49+
warnings to three, with the storage line gone and `./storage` no longer created.
50+
19 unit cases cover the target resolver and the swap/warn split (including the
51+
refusals), 4 plugin-level cases pin what a boot does and says, and 7 pin the CLI
52+
option shape.
53+
54+
`config.storage` authored with the `driver`/`root` dialect is still forwarded
55+
verbatim and still not read by the plugin — the same mismatch one layer up.
56+
Correcting it means deciding whether the plugin accepts that dialect or the
57+
config schema is wrong, so it is filed rather than papered over with a lenient
58+
alias here (AGENTS.md Prime Directive #12).
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* framework#4096 — what `StorageServicePlugin` is actually constructed with.
5+
*
6+
* The fallback used to be `{ driver: 'local', root }`, and
7+
* `StorageServicePluginOptions` declares neither key. Both were dropped
8+
* silently, so the plugin applied its own `./storage` default,
9+
* `OS_STORAGE_ROOT` changed nothing, and uploads landed somewhere the operator
10+
* never named. The `storage` settings namespace then corrected the root on its
11+
* first read — its manifest default is `./.objectstack/data/uploads` — which
12+
* swapped the adapter and warned "existing files were NOT migrated" on every
13+
* boot of a healthy server.
14+
*
15+
* The warning was telling the truth; the configuration was wrong. These pin the
16+
* option SHAPE, because a shape mismatch is exactly the failure a passing type
17+
* check does not catch when the receiving interface has no index signature and
18+
* the value is built as a plain object literal.
19+
*/
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { resolveStorageCapabilityArg } from './serve.js';
23+
24+
describe('resolveStorageCapabilityArg', () => {
25+
it('builds options StorageServicePlugin actually reads', () => {
26+
// `adapter` + `local.rootDir` — NOT `driver` + `root`.
27+
expect(resolveStorageCapabilityArg(undefined).options).toEqual({
28+
adapter: 'local',
29+
local: { rootDir: '.objectstack/data/uploads' },
30+
});
31+
});
32+
33+
it('never emits the keys the plugin ignores', () => {
34+
// The regression guard proper: `{driver, root}` type-checks fine as an
35+
// argument and does nothing at runtime.
36+
const { options } = resolveStorageCapabilityArg(undefined);
37+
expect(options).not.toHaveProperty('driver');
38+
expect(options).not.toHaveProperty('root');
39+
});
40+
41+
it('honours OS_STORAGE_ROOT, which the old shape discarded', () => {
42+
const { options, localRoot } = resolveStorageCapabilityArg(undefined, '/srv/uploads');
43+
expect(options).toEqual({ adapter: 'local', local: { rootDir: '/srv/uploads' } });
44+
expect(localRoot).toBe('/srv/uploads');
45+
});
46+
47+
it('ignores a blank or whitespace-only env root', () => {
48+
for (const blank of ['', ' ']) {
49+
expect(resolveStorageCapabilityArg(undefined, blank).options).toEqual({
50+
adapter: 'local',
51+
local: { rootDir: '.objectstack/data/uploads' },
52+
});
53+
}
54+
});
55+
56+
it('reports the local root so only the fallback triggers the production warning', () => {
57+
// A host that configured its own backend must not be told it is on local disk.
58+
expect(resolveStorageCapabilityArg(undefined).localRoot).toBe('.objectstack/data/uploads');
59+
expect(resolveStorageCapabilityArg({ adapter: 's3', s3: { bucket: 'b', region: 'r' } }).localRoot)
60+
.toBeUndefined();
61+
});
62+
63+
it('forwards a host-configured storage block verbatim', () => {
64+
const cfg = { adapter: 's3', s3: { bucket: 'b', region: 'r' } };
65+
expect(resolveStorageCapabilityArg(cfg).options).toBe(cfg);
66+
// The `driver` dialect is still forwarded untouched — the plugin does not
67+
// read it either, but rewriting it here would fossilize the wrong contract
68+
// rather than fix it. Tracked separately.
69+
const legacy = { driver: 's3', bucket: 'b' };
70+
expect(resolveStorageCapabilityArg(legacy).options).toBe(legacy);
71+
});
72+
73+
it('falls back when the block names no backend at all', () => {
74+
// `config.storage = { presignedTtl: 60 }` configures no backend, so the
75+
// local default still applies rather than being replaced by a partial block.
76+
expect(resolveStorageCapabilityArg({ presignedTtl: 60 }).options).toEqual({
77+
adapter: 'local',
78+
local: { rootDir: '.objectstack/data/uploads' },
79+
});
80+
});
81+
});

packages/cli/src/commands/serve.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2189,17 +2189,15 @@ export default class Serve extends Command {
21892189
// In production mode we emit a single loud warning so the
21902190
// operator knows to point storage at S3 / GCS / Azure before
21912191
// shipping (data on a single pod is volatile / non-replicated).
2192-
const cfgStorage = (config as any).storage;
2193-
if (cfgStorage && (cfgStorage.driver || cfgStorage.adapter)) {
2194-
arg = cfgStorage;
2195-
} else {
2196-
const root = process.env.OS_STORAGE_ROOT || '.objectstack/data/uploads';
2197-
arg = { driver: 'local', root };
2198-
if (!isDev) {
2199-
console.warn(chalk.yellow(
2200-
` ⚠ StorageServicePlugin using local driver (${root}) — switch to S3/GCS/Azure for production (set config.storage or OS_STORAGE_*).`,
2201-
));
2202-
}
2192+
const storageArg = resolveStorageCapabilityArg(
2193+
(config as any).storage,
2194+
process.env.OS_STORAGE_ROOT,
2195+
);
2196+
arg = storageArg.options;
2197+
if (storageArg.localRoot && !isDev) {
2198+
console.warn(chalk.yellow(
2199+
` ⚠ StorageServicePlugin using local driver (${storageArg.localRoot}) — switch to S3/GCS/Azure for production (set config.storage or OS_STORAGE_*).`,
2200+
));
22032201
}
22042202
}
22052203
await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor());
@@ -2648,6 +2646,48 @@ export default class Serve extends Command {
26482646
}
26492647
}
26502648

2649+
/**
2650+
* Constructor options for `StorageServicePlugin`, plus the local root to name in
2651+
* the production warning (absent when the host configured a backend itself).
2652+
*/
2653+
export interface StorageCapabilityArg {
2654+
options: Record<string, unknown>;
2655+
localRoot?: string;
2656+
}
2657+
2658+
/**
2659+
* Resolve what `StorageServicePlugin` is constructed with (#4096).
2660+
*
2661+
* Storage is in the default capability slate, so a host that configures nothing
2662+
* still gets local disk under `.objectstack/data/uploads/` and avatars /
2663+
* attachments / report files work out of the box.
2664+
*
2665+
* The fallback used to be `{ driver: 'local', root }` — neither of which
2666+
* `StorageServicePluginOptions` declares. Both were dropped on the floor, so the
2667+
* plugin applied its OWN default (`./storage`), `OS_STORAGE_ROOT` changed
2668+
* nothing, and uploads landed somewhere the operator never named. The `storage`
2669+
* settings namespace then corrected the root on its first read (its manifest
2670+
* default IS `./.objectstack/data/uploads`), which swapped the adapter and
2671+
* warned about stranded files — on every boot of a healthy server.
2672+
*
2673+
* A caller-supplied `config.storage` is still forwarded verbatim, including the
2674+
* `driver`/`root` dialect, which the plugin does not read either. That is the
2675+
* same mismatch one layer up and is tracked separately: correcting it means
2676+
* deciding whether the plugin accepts that dialect or the config schema is
2677+
* wrong, and a lenient alias here would fossilize the wrong contract
2678+
* (AGENTS.md Prime Directive #12).
2679+
*/
2680+
export function resolveStorageCapabilityArg(
2681+
cfgStorage: any,
2682+
envRoot?: string,
2683+
): StorageCapabilityArg {
2684+
if (cfgStorage && (cfgStorage.driver || cfgStorage.adapter)) {
2685+
return { options: cfgStorage };
2686+
}
2687+
const rootDir = envRoot?.trim() || '.objectstack/data/uploads';
2688+
return { options: { adapter: 'local', local: { rootDir } }, localRoot: rootDir };
2689+
}
2690+
26512691
/**
26522692
* Best-effort driver introspection.
26532693
*

packages/services/service-storage/src/storage-service-plugin.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,16 @@ import { SwappableStorageService } from './swappable-storage-service';
1818
function makeCtx() {
1919
const services = new Map<string, any>();
2020
const hooks: Array<() => Promise<void> | void> = [];
21+
// Captured so tests can assert on what a boot SAID, not just what it built —
22+
// #4096 is entirely about a warning that should not have been emitted.
23+
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
2124
const ctx: any = {
22-
logger: { info: () => {}, warn: () => {}, error: () => {} },
25+
logger: {
26+
info: (m: string) => { logs.info.push(String(m)); },
27+
warn: (m: string) => { logs.warn.push(String(m)); },
28+
error: (m: string) => { logs.error.push(String(m)); },
29+
},
30+
_logs: logs,
2331
registerService: (name: string, svc: any) => { services.set(name, svc); },
2432
getService: <T>(name: string): T => {
2533
const s = services.get(name);
@@ -121,6 +129,103 @@ describe('StorageServicePlugin: settings live-wire', () => {
121129
expect(proxy.getInner()).toBe(before);
122130
});
123131

132+
// ── #4096: the swap decision, and what it says out loud ──────────────────
133+
//
134+
// `hasAny` is true on every boot once the settings service has persisted its
135+
// own defaults, so this used to rebuild and swap the adapter unconditionally
136+
// and warn that "existing files were NOT migrated" — about a swap from an
137+
// adapter to an identically-configured one, on a healthy server, forever.
138+
139+
it('neither swaps nor warns when persisted settings match the running adapter', async () => {
140+
const dir = await fs.mkdtemp(join(tmpdir(), 'oss-same-'));
141+
const plugin = new StorageServicePlugin({
142+
adapter: 'local',
143+
local: { rootDir: dir },
144+
registerRoutes: false,
145+
});
146+
const ctx = makeCtx();
147+
// Exactly what the settings namespace holds after it persists its defaults:
148+
// values present (so `hasAny` is true) and identical to what is running.
149+
ctx.registerService('settings', makeFakeSettings({ adapter: 'local', local_root: dir }));
150+
151+
await plugin.init(ctx);
152+
await plugin.start(ctx);
153+
const proxy = ctx.getService('file-storage') as SwappableStorageService;
154+
const before = proxy.getInner();
155+
156+
await ctx._flushReady();
157+
158+
expect(proxy.getInner()).toBe(before);
159+
expect(ctx._logs.warn.join('\n')).not.toContain('adapter swapped');
160+
});
161+
162+
it('treats an implicit local root and its explicit default as the same store', async () => {
163+
// The real boot shape: the host leaves `local.rootDir` unset while the
164+
// settings namespace persists the schema default, so the two spellings of
165+
// one location must not read as a move.
166+
const plugin = new StorageServicePlugin({ adapter: 'local', registerRoutes: false });
167+
const ctx = makeCtx();
168+
ctx.registerService('settings', makeFakeSettings({ adapter: 'local', local_root: './storage' }));
169+
170+
await plugin.init(ctx);
171+
await plugin.start(ctx);
172+
const proxy = ctx.getService('file-storage') as SwappableStorageService;
173+
const before = proxy.getInner();
174+
175+
await ctx._flushReady();
176+
177+
expect(proxy.getInner()).toBe(before);
178+
expect(ctx._logs.warn.join('\n')).not.toContain('adapter swapped');
179+
});
180+
181+
it('still warns when the backing store really moves', async () => {
182+
// The guard has to survive the fix: Local → another root strands whatever
183+
// the old one held, and that is the whole point of the message.
184+
const dirA = await fs.mkdtemp(join(tmpdir(), 'oss-move-a-'));
185+
const dirB = await fs.mkdtemp(join(tmpdir(), 'oss-move-b-'));
186+
const plugin = new StorageServicePlugin({
187+
adapter: 'local',
188+
local: { rootDir: dirA },
189+
registerRoutes: false,
190+
});
191+
const ctx = makeCtx();
192+
ctx.registerService('settings', makeFakeSettings({ adapter: 'local', local_root: dirB }));
193+
194+
await plugin.init(ctx);
195+
await plugin.start(ctx);
196+
await ctx._flushReady();
197+
198+
const warned = ctx._logs.warn.join('\n');
199+
expect(warned).toContain('adapter swapped');
200+
expect(warned).toContain('were NOT migrated');
201+
});
202+
203+
it('warns again on a later real move, having stayed quiet for the no-op', async () => {
204+
// The verdict is per-swap state, so a quiet boot must not disarm the next
205+
// genuine migration.
206+
const dir = await fs.mkdtemp(join(tmpdir(), 'oss-seq-'));
207+
const moved = await fs.mkdtemp(join(tmpdir(), 'oss-seq-moved-'));
208+
const plugin = new StorageServicePlugin({
209+
adapter: 'local',
210+
local: { rootDir: dir },
211+
registerRoutes: false,
212+
});
213+
const ctx = makeCtx();
214+
const settings = makeFakeSettings({ adapter: 'local', local_root: dir });
215+
ctx.registerService('settings', settings);
216+
217+
await plugin.init(ctx);
218+
await plugin.start(ctx);
219+
await ctx._flushReady();
220+
expect(ctx._logs.warn.join('\n')).not.toContain('adapter swapped');
221+
222+
settings.values = { adapter: 'local', local_root: moved };
223+
settings._emit('storage');
224+
await new Promise((r) => setTimeout(r, 20));
225+
226+
expect(ctx._logs.warn.join('\n')).toContain('adapter swapped');
227+
});
228+
124229
it('registers a working storage/test action handler that round-trips a probe blob', async () => {
125230
const dir = await fs.mkdtemp(join(tmpdir(), 'oss-probe-'));
126231
const plugin = new StorageServicePlugin({

0 commit comments

Comments
 (0)