Skip to content

Commit f48a00b

Browse files
committed
Fix storage plugin initialization for multi-project artifact publishing
1 parent 13747e3 commit f48a00b

4 files changed

Lines changed: 97 additions & 34 deletions

File tree

apps/cloud/wrangler.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ compatibility_flags = ["nodejs_compat"]
4545
# rebuild + re-push to ship a new version, then run `wrangler deploy`.
4646
[[containers]]
4747
class_name = "CloudContainer"
48-
image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectstack-cloud:publish-diag"
48+
image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectstack-cloud:direct-storage"
4949
max_instances = 3
5050
instance_type = "standard-1"
5151

packages/services/service-cloud/src/cloud-stack.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { createControlPlanePlugins } from './control-plane-preset.js';
2121
import { createStudioRuntimeConfigPlugin, createTemplatesRoutePlugin } from './multi-project-plugins.js';
2222
import { createCloudArtifactApiPlugin } from './cloud-artifact-api-plugin.js';
2323
import { resolveDefaultDataDir } from './data-dir.js';
24-
import { resolveStoragePluginFromEnv } from './storage-env.js';
24+
import { resolveStoragePluginFromEnv, resolveStorageFromEnv } from './storage-env.js';
2525

2626
type IDataDriver = Contracts.IDataDriver;
2727

@@ -161,6 +161,12 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
161161
];
162162
});
163163

164+
// Resolve storage ONCE so we can wire both StorageServicePlugin (kernel
165+
// service registration + REST routes) AND pass the raw adapter directly to
166+
// MultiProjectPlugin (avoids cross-plugin kernel.getService lookup which
167+
// is unreliable through the proxy wrapper used here).
168+
const storageEnv = await resolveStorageFromEnv();
169+
164170
const multiProjectPluginProxy: any = {
165171
name: 'com.objectstack.multi-project',
166172
version: '0.0.0',
@@ -176,6 +182,8 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
176182
maxSize: Number(process.env.OS_KERNEL_CACHE_SIZE ?? kernelCacheSize ?? 32),
177183
ttlMs: Number(process.env.OS_KERNEL_TTL_MS ?? kernelTtlMs ?? 15 * 60 * 1000),
178184
cacheTTLMs: Number(process.env.OS_ENV_CACHE_TTL_MS ?? envCacheTtlMs ?? 5 * 60 * 1000),
185+
storage: storageEnv.storage,
186+
storageAdapterName: storageEnv.adapterName,
179187
});
180188
if (this._impl.init) await this._impl.init(ctx);
181189
} catch (err: any) {
@@ -206,7 +214,7 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
206214
// Falls back to local-FS adapter (rooted at OS_STORAGE_LOCAL_DIR or
207215
// <data-dir>/storage). On serverless without S3 env vars, the cloud-
208216
// artifact plugin will warn — set OS_STORAGE_ADAPTER=s3 in production.
209-
...(await resolveStoragePluginFromEnv()),
217+
...(storageEnv.plugin ? [storageEnv.plugin] : []),
210218
multiProjectPluginProxy,
211219
createStudioRuntimeConfigPlugin({ apiPrefix }),
212220
createTemplatesRoutePlugin(templateList, { apiPrefix }),

packages/services/service-cloud/src/multi-project-plugin.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export interface MultiProjectPluginConfig {
5151
cacheTTLMs?: number;
5252
maxSize?: number;
5353
ttlMs?: number;
54+
/** Direct storage adapter for artifact publishing. Bypasses kernel.getService('file-storage'). */
55+
storage?: any;
56+
/** Adapter name (e.g. 's3', 'local') — used when persisting sys_project_revision rows. */
57+
storageAdapterName?: string;
5458
}
5559

5660
interface ExtractedItem {
@@ -287,6 +291,7 @@ function createTemplateSeeder(
287291
bundleHasObjects: Array.isArray(bundle?.objects) ? bundle.objects.length : 0,
288292
bundleHasApps: Array.isArray(bundle?.apps) ? bundle.apps.length : 0,
289293
bundleHasViews: Array.isArray(bundle?.views) ? bundle.views.length : 0,
294+
lastServiceKeys: (publishCtx as any)._lastServiceKeys ?? null,
290295
};
291296
if (!storage) {
292297
publishStatus.error = 'no-file-storage-service';
@@ -335,10 +340,12 @@ function createTemplateSeeder(
335340
: (cur?.metadata ?? {});
336341
await (publishCtx.controlDriver as any).update(
337342
'sys_project',
338-
{ where: { id: projectId } },
343+
projectId,
339344
{ metadata: JSON.stringify({ ...meta, publishStatus, publishStatusAt: new Date().toISOString() }) },
340345
);
341-
} catch { /* best-effort diagnostic */ }
346+
} catch (diagErr: any) {
347+
console.error('[MultiProjectPlugin] Failed to write publishStatus diagnostic:', diagErr?.message ?? diagErr);
348+
}
342349
};
343350

344351
return {
@@ -352,15 +359,45 @@ function createTemplateSeeder(
352359
},
353360

354361
async seed({ projectId, templateId }) {
362+
const writeDiag = async (stage: string, extra: any = {}) => {
363+
try {
364+
const cur = await (publishCtx.controlDriver as any).findOne('sys_project', { where: { id: projectId } });
365+
const meta = cur && typeof cur.metadata === 'string' ? JSON.parse(cur.metadata) : (cur?.metadata ?? {});
366+
const seedDiag = { ...(meta.seedDiag ?? {}), [stage]: { at: new Date().toISOString(), ...extra } };
367+
await (publishCtx.controlDriver as any).update('sys_project', projectId, {
368+
metadata: JSON.stringify({ ...meta, seedDiag }),
369+
});
370+
} catch (e: any) { console.error('[MultiProjectPlugin] writeDiag failed', stage, e?.message); }
371+
};
372+
await writeDiag('seedCalled', { templateId, templates: Object.keys(templates) });
355373
const template = templates[templateId];
356374
if (!template) {
375+
await writeDiag('seedNotFound', { templateId });
357376
throw new Error(
358377
`Unknown template: '${templateId}'. Available: [${Object.keys(templates).join(', ')}]`,
359378
);
360379
}
361-
362-
const bundle = await template.load();
363-
await seedBundleForProject(projectId, bundle);
380+
let bundle: any;
381+
try {
382+
bundle = await template.load();
383+
await writeDiag('templateLoaded', {
384+
objects: Array.isArray(bundle?.objects) ? bundle.objects.length : 'n/a',
385+
apps: Array.isArray(bundle?.apps) ? bundle.apps.length : 'n/a',
386+
views: Array.isArray(bundle?.views) ? bundle.views.length : 'n/a',
387+
data: Array.isArray(bundle?.data) ? bundle.data.length : 'n/a',
388+
bundleKeys: bundle ? Object.keys(bundle) : null,
389+
});
390+
} catch (e: any) {
391+
await writeDiag('templateLoadError', { error: e?.message, stack: e?.stack?.split('\n').slice(0,5) });
392+
throw e;
393+
}
394+
try {
395+
await seedBundleForProject(projectId, bundle);
396+
await writeDiag('seedBundleDone');
397+
} catch (e: any) {
398+
await writeDiag('seedBundleError', { error: e?.message, stack: e?.stack?.split('\n').slice(0,5) });
399+
throw e;
400+
}
364401
},
365402

366403
async seedBundle({ projectId, bundle }) {
@@ -515,28 +552,34 @@ export class MultiProjectPlugin implements Plugin {
515552
});
516553
this.kernelManager = kernelManager;
517554

555+
const directStorage = this.config.storage ?? null;
556+
const directStorageAdapter = this.config.storageAdapterName
557+
?? (process.env.OS_STORAGE_ADAPTER ?? 'local').toLowerCase();
518558
const seeder = createTemplateSeeder(
519559
kernelManager,
520560
this.config.templates ?? {},
521561
envRegistry,
522562
{
523563
controlDriver: this.config.controlDriver,
524-
storageAdapter: (process.env.OS_STORAGE_ADAPTER ?? 'local').toLowerCase(),
564+
storageAdapter: directStorageAdapter,
525565
keyPrefix: process.env.OS_STORAGE_KEY_PREFIX ?? 'artifacts',
526566
getStorage: async () => {
527-
// Resolve once-per-call so the storage plugin (registered
528-
// in parallel) is available even when MultiProjectPlugin
529-
// initialised first. Try sync getService first; fall back
530-
// to async getServiceAsync (StorageServicePlugin may register
531-
// via async lifecycle on cold start).
567+
// 1. Direct storage adapter (preferred — set by cloud-stack
568+
// via resolveStorageFromEnv to bypass kernel.getService
569+
// lookup which is unreliable through the proxy wrapper).
570+
if (directStorage && typeof directStorage.upload === 'function') {
571+
return directStorage;
572+
}
573+
// 2. Fallback to kernel service lookup (for any stack that
574+
// forgot to pass `storage` in MultiProjectPluginConfig).
532575
try {
533576
const sync = (hostKernel as any).getService?.('file-storage');
534577
if (sync && typeof sync.upload === 'function') return sync;
535-
} catch { /* fall through */ }
578+
} catch { /* ignore */ }
536579
try {
537580
const async = await (hostKernel as any).getServiceAsync?.('file-storage');
538581
if (async && typeof async.upload === 'function') return async;
539-
} catch { /* not registered yet */ }
582+
} catch { /* ignore */ }
540583
return null;
541584
},
542585
},

packages/services/service-cloud/src/storage-env.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,22 @@
2626
* fall back to its local-FS path with a startup warning.
2727
*/
2828
export async function resolveStoragePluginFromEnv(): Promise<any[]> {
29+
const r = await resolveStorageFromEnv();
30+
return r.plugin ? [r.plugin] : [];
31+
}
32+
33+
/**
34+
* Same as {@link resolveStoragePluginFromEnv} but also returns the underlying
35+
* storage adapter instance so callers can pass it to other plugins (e.g.
36+
* MultiProjectPlugin's getStorage hook) without going through kernel service
37+
* lookup. This avoids ordering / proxy issues where the host kernel's service
38+
* registry doesn't surface 'file-storage' to closures captured during init.
39+
*/
40+
export async function resolveStorageFromEnv(): Promise<{ plugin: any | null; storage: any | null; adapterName: string }> {
2941
const adapter = (process.env.OS_STORAGE_ADAPTER ?? 'local').trim().toLowerCase();
3042

3143
if (adapter === 'none' || adapter === 'disabled' || adapter === 'off') {
32-
return [];
44+
return { plugin: null, storage: null, adapterName: 'none' };
3345
}
3446

3547
if (adapter === 's3') {
@@ -42,24 +54,24 @@ export async function resolveStoragePluginFromEnv(): Promise<any[]> {
4254
'or set OS_STORAGE_ADAPTER=local for local development.',
4355
);
4456
}
45-
const { StorageServicePlugin } = await import('@objectstack/service-storage');
46-
return [new StorageServicePlugin({
47-
adapter: 's3',
48-
s3: {
49-
bucket,
50-
region,
51-
endpoint: process.env.OS_S3_ENDPOINT?.trim() || undefined,
52-
accessKeyId: process.env.OS_S3_ACCESS_KEY_ID?.trim() || undefined,
53-
secretAccessKey: process.env.OS_S3_SECRET_ACCESS_KEY?.trim() || undefined,
54-
forcePathStyle: /^(1|true|yes)$/i.test(process.env.OS_S3_FORCE_PATH_STYLE ?? ''),
55-
},
56-
})];
57+
const { StorageServicePlugin, S3StorageAdapter } = await import('@objectstack/service-storage');
58+
const s3Opts = {
59+
bucket,
60+
region,
61+
endpoint: process.env.OS_S3_ENDPOINT?.trim() || undefined,
62+
accessKeyId: process.env.OS_S3_ACCESS_KEY_ID?.trim() || undefined,
63+
secretAccessKey: process.env.OS_S3_SECRET_ACCESS_KEY?.trim() || undefined,
64+
forcePathStyle: /^(1|true|yes)$/i.test(process.env.OS_S3_FORCE_PATH_STYLE ?? ''),
65+
};
66+
const storage = new S3StorageAdapter(s3Opts);
67+
const plugin = new StorageServicePlugin({ adapter: 's3', s3: s3Opts });
68+
return { plugin, storage, adapterName: 's3' };
5769
}
5870

5971
// 'local' (default)
60-
const { StorageServicePlugin } = await import('@objectstack/service-storage');
61-
return [new StorageServicePlugin({
62-
adapter: 'local',
63-
local: { rootDir: process.env.OS_STORAGE_LOCAL_DIR?.trim() || './storage' },
64-
})];
72+
const { StorageServicePlugin, LocalStorageAdapter } = await import('@objectstack/service-storage');
73+
const rootDir = process.env.OS_STORAGE_LOCAL_DIR?.trim() || './storage';
74+
const storage = new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
75+
const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir } });
76+
return { plugin, storage, adapterName: 'local' };
6577
}

0 commit comments

Comments
 (0)