Skip to content

Commit 63e3b7f

Browse files
committed
feat(runtime): enhance memory driver to support per-project JSON file paths for persistence
1 parent 72d3b99 commit 63e3b7f

6 files changed

Lines changed: 127 additions & 47 deletions

File tree

apps/studio/src/components/app-sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export function AppSidebar({
175175
// Prefer a project-scoped client when a projectId is present in the URL
176176
// (e.g. under /projects/$projectId/...). Falls back to the unscoped client
177177
// during login / organization pages that have no project context yet.
178-
const scopedClient = useScopedClient(params.projectId);
178+
const scopedClient = useScopedClient(projectId ?? params.projectId);
179179
const client = scopedClient ?? unscopedClient;
180180

181181
// Extract current selection from URL params

packages/rest/src/rest-api-plugin.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
4-
import { RestServer } from './rest-server.js';
4+
import { RestServer, RestKernelManager } from './rest-server.js';
55
import { ObjectStackProtocol, RestServerConfig } from '@objectstack/spec/api';
66
import { registerPackageRoutes } from './package-routes.js';
77
import type { PackageService } from '@objectstack/service-package';
88

99
export interface RestApiPluginConfig {
1010
serverServiceName?: string;
1111
protocolServiceName?: string;
12+
/**
13+
* Optional override for the kernel-manager service name. When the service
14+
* is registered (by @objectstack/runtime's MultiProjectPlugin), scoped
15+
* routes resolve per-project protocols at request time.
16+
*/
17+
kernelManagerServiceName?: string;
1218
api?: RestServerConfig;
1319
}
1420

@@ -47,7 +53,18 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
4753
} catch (e) {
4854
// Ignore missing service
4955
}
50-
56+
57+
// Optional — only present when MultiProjectPlugin is mounted. When
58+
// available, RestServer will resolve a per-project protocol at
59+
// request time for scoped (`/projects/:projectId/...`) routes.
60+
let kernelManager: RestKernelManager | undefined;
61+
const kernelManagerService = config.kernelManagerServiceName || 'kernel-manager';
62+
try {
63+
kernelManager = ctx.getService<RestKernelManager>(kernelManagerService);
64+
} catch (e) {
65+
// Single-kernel deployment — fall back to the control protocol
66+
}
67+
5168
if (!server) {
5269
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
5370
return;
@@ -61,7 +78,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
6178
ctx.logger.info('Hydrating REST API from Protocol...');
6279

6380
try {
64-
const restServer = new RestServer(server, protocol, config.api as any);
81+
const restServer = new RestServer(server, protocol, config.api as any, kernelManager);
6582
restServer.registerRoutes();
6683

6784
ctx.logger.info('REST API successfully registered');

packages/rest/src/rest-server.ts

Lines changed: 66 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ import { RouteManager } from './route-manager.js';
55
import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api';
66
import { ObjectStackProtocol } from '@objectstack/spec/api';
77

8+
/**
9+
* Structural subset of `KernelManager` that RestServer needs in order to
10+
* resolve a per-project protocol at request time. Typed locally to avoid
11+
* an @objectstack/runtime → @objectstack/rest → @objectstack/runtime
12+
* package cycle.
13+
*/
14+
export interface RestKernelManager {
15+
getOrCreate(projectId: string): Promise<{
16+
getServiceAsync<T = unknown>(name: string): Promise<T>;
17+
}>;
18+
}
19+
820
/**
921
* Normalized REST Server Configuration
1022
* All nested properties are required after normalization
@@ -97,15 +109,31 @@ export class RestServer {
97109
private protocol: ObjectStackProtocol;
98110
private config: NormalizedRestServerConfig;
99111
private routeManager: RouteManager;
100-
112+
private kernelManager?: RestKernelManager;
113+
101114
constructor(
102-
server: IHttpServer,
103-
protocol: ObjectStackProtocol,
104-
config: RestServerConfig = {}
115+
server: IHttpServer,
116+
protocol: ObjectStackProtocol,
117+
config: RestServerConfig = {},
118+
kernelManager?: RestKernelManager,
105119
) {
106120
this.protocol = protocol;
107121
this.config = this.normalizeConfig(config);
108122
this.routeManager = new RouteManager(server);
123+
this.kernelManager = kernelManager;
124+
}
125+
126+
/**
127+
* Resolve the protocol for a given request. When `projectId` is present
128+
* and a KernelManager is wired, fetch the per-project kernel's
129+
* `protocol` service so metadata / data / UI reads hit the project's
130+
* own registry and datastore. Otherwise fall back to the control-kernel
131+
* protocol captured at boot.
132+
*/
133+
private async resolveProtocol(projectId?: string): Promise<ObjectStackProtocol> {
134+
if (!projectId || !this.kernelManager) return this.protocol;
135+
const kernel = await this.kernelManager.getOrCreate(projectId);
136+
return kernel.getServiceAsync<ObjectStackProtocol>('protocol');
109137
}
110138

111139
/**
@@ -333,9 +361,8 @@ export class RestServer {
333361
handler: async (req: any, res: any) => {
334362
try {
335363
const projectId = isScoped ? req.params?.projectId : undefined;
336-
const types = await this.protocol.getMetaTypes(
337-
projectId ? ({ projectId } as any) : undefined,
338-
);
364+
const p = await this.resolveProtocol(projectId);
365+
const types = await p.getMetaTypes();
339366
res.json(types);
340367
} catch (error: any) {
341368
res.status(500).json({ error: error.message });
@@ -357,10 +384,10 @@ export class RestServer {
357384
try {
358385
const packageId = req.query?.package || undefined;
359386
const projectId = isScoped ? req.params?.projectId : undefined;
360-
const items = await this.protocol.getMetaItems({
387+
const p = await this.resolveProtocol(projectId);
388+
const items = await p.getMetaItems({
361389
type: req.params.type,
362390
packageId,
363-
...(projectId ? { projectId } : {}),
364391
} as any);
365392
res.json(items);
366393
} catch (error: any) {
@@ -382,18 +409,18 @@ export class RestServer {
382409
handler: async (req: any, res: any) => {
383410
try {
384411
const projectId = isScoped ? req.params?.projectId : undefined;
412+
const p = await this.resolveProtocol(projectId);
385413
// Check if cached version is available
386-
if (metadata.enableCache && this.protocol.getMetaItemCached) {
414+
if (metadata.enableCache && p.getMetaItemCached) {
387415
const cacheRequest = {
388416
ifNoneMatch: req.headers['if-none-match'] as string,
389417
ifModifiedSince: req.headers['if-modified-since'] as string,
390418
};
391419

392-
const result = await this.protocol.getMetaItemCached({
420+
const result = await p.getMetaItemCached({
393421
type: req.params.type,
394422
name: req.params.name,
395423
cacheRequest,
396-
...(projectId ? { projectId } : {}),
397424
} as any);
398425

399426
if (result.notModified) {
@@ -423,11 +450,10 @@ export class RestServer {
423450
} else {
424451
// Non-cached version
425452
const packageId = req.query?.package || undefined;
426-
const item = await this.protocol.getMetaItem({
453+
const item = await p.getMetaItem({
427454
type: req.params.type,
428455
name: req.params.name,
429456
packageId,
430-
...(projectId ? { projectId } : {}),
431457
} as any);
432458
res.json(item);
433459
}
@@ -450,17 +476,17 @@ export class RestServer {
450476
path: `${metaPath}/:type/:name`,
451477
handler: async (req: any, res: any) => {
452478
try {
453-
if (!this.protocol.saveMetaItem) {
479+
const projectId = isScoped ? req.params?.projectId : undefined;
480+
const p = await this.resolveProtocol(projectId);
481+
if (!p.saveMetaItem) {
454482
res.status(501).json({ error: 'Save operation not supported by protocol implementation' });
455483
return;
456484
}
457485

458-
const projectId = isScoped ? req.params?.projectId : undefined;
459-
const result = await this.protocol.saveMetaItem({
486+
const result = await p.saveMetaItem({
460487
type: req.params.type,
461488
name: req.params.name,
462489
item: req.body,
463-
...(projectId ? { projectId } : {}),
464490
} as any);
465491
res.json(result);
466492
} catch (error: any) {
@@ -487,12 +513,12 @@ export class RestServer {
487513
path: `${uiPath}/view/:object/:type`,
488514
handler: async (req: any, res: any) => {
489515
try {
490-
if (this.protocol.getUiView) {
491-
const projectId = isScoped ? req.params?.projectId : undefined;
492-
const view = await this.protocol.getUiView({
516+
const projectId = isScoped ? req.params?.projectId : undefined;
517+
const p = await this.resolveProtocol(projectId);
518+
if (p.getUiView) {
519+
const view = await p.getUiView({
493520
object: req.params.object,
494521
type: req.params.type as any,
495-
...(projectId ? { projectId } : {}),
496522
} as any);
497523
res.json(view);
498524
} else {
@@ -527,10 +553,10 @@ export class RestServer {
527553
handler: async (req: any, res: any) => {
528554
try {
529555
const projectId = isScoped ? req.params?.projectId : undefined;
530-
const result = await this.protocol.findData({
556+
const p = await this.resolveProtocol(projectId);
557+
const result = await p.findData({
531558
object: req.params.object,
532559
query: req.query,
533-
...(projectId ? { projectId } : {}),
534560
} as any);
535561
res.json(result);
536562
} catch (error: any) {
@@ -552,13 +578,13 @@ export class RestServer {
552578
handler: async (req: any, res: any) => {
553579
try {
554580
const projectId = isScoped ? req.params?.projectId : undefined;
581+
const p = await this.resolveProtocol(projectId);
555582
const { select, expand } = req.query || {};
556-
const result = await this.protocol.getData({
583+
const result = await p.getData({
557584
object: req.params.object,
558585
id: req.params.id,
559586
...(select != null ? { select } : {}),
560587
...(expand != null ? { expand } : {}),
561-
...(projectId ? { projectId } : {}),
562588
} as any);
563589
res.json(result);
564590
} catch (error: any) {
@@ -580,10 +606,10 @@ export class RestServer {
580606
handler: async (req: any, res: any) => {
581607
try {
582608
const projectId = isScoped ? req.params?.projectId : undefined;
583-
const result = await this.protocol.createData({
609+
const p = await this.resolveProtocol(projectId);
610+
const result = await p.createData({
584611
object: req.params.object,
585612
data: req.body,
586-
...(projectId ? { projectId } : {}),
587613
} as any);
588614
res.status(201).json(result);
589615
} catch (error: any) {
@@ -605,11 +631,11 @@ export class RestServer {
605631
handler: async (req: any, res: any) => {
606632
try {
607633
const projectId = isScoped ? req.params?.projectId : undefined;
608-
const result = await this.protocol.updateData({
634+
const p = await this.resolveProtocol(projectId);
635+
const result = await p.updateData({
609636
object: req.params.object,
610637
id: req.params.id,
611638
data: req.body,
612-
...(projectId ? { projectId } : {}),
613639
} as any);
614640
res.json(result);
615641
} catch (error: any) {
@@ -631,10 +657,10 @@ export class RestServer {
631657
handler: async (req: any, res: any) => {
632658
try {
633659
const projectId = isScoped ? req.params?.projectId : undefined;
634-
const result = await this.protocol.deleteData({
660+
const p = await this.resolveProtocol(projectId);
661+
const result = await p.deleteData({
635662
object: req.params.object,
636663
id: req.params.id,
637-
...(projectId ? { projectId } : {}),
638664
} as any);
639665
res.json(result);
640666
} catch (error: any) {
@@ -667,10 +693,10 @@ export class RestServer {
667693
handler: async (req: any, res: any) => {
668694
try {
669695
const projectId = isScoped ? req.params?.projectId : undefined;
670-
const result = await this.protocol.batchData!({
696+
const p = await this.resolveProtocol(projectId);
697+
const result = await p.batchData!({
671698
object: req.params.object,
672699
request: req.body,
673-
...(projectId ? { projectId } : {}),
674700
} as any);
675701
res.json(result);
676702
} catch (error: any) {
@@ -692,10 +718,10 @@ export class RestServer {
692718
handler: async (req: any, res: any) => {
693719
try {
694720
const projectId = isScoped ? req.params?.projectId : undefined;
695-
const result = await this.protocol.createManyData!({
721+
const p = await this.resolveProtocol(projectId);
722+
const result = await p.createManyData!({
696723
object: req.params.object,
697724
records: req.body || [],
698-
...(projectId ? { projectId } : {}),
699725
} as any);
700726
res.status(201).json(result);
701727
} catch (error: any) {
@@ -717,10 +743,10 @@ export class RestServer {
717743
handler: async (req: any, res: any) => {
718744
try {
719745
const projectId = isScoped ? req.params?.projectId : undefined;
720-
const result = await this.protocol.updateManyData!({
746+
const p = await this.resolveProtocol(projectId);
747+
const result = await p.updateManyData!({
721748
object: req.params.object,
722749
...req.body,
723-
...(projectId ? { projectId } : {}),
724750
} as any);
725751
res.json(result);
726752
} catch (error: any) {
@@ -742,10 +768,10 @@ export class RestServer {
742768
handler: async (req: any, res: any) => {
743769
try {
744770
const projectId = isScoped ? req.params?.projectId : undefined;
745-
const result = await this.protocol.deleteManyData!({
771+
const p = await this.resolveProtocol(projectId);
772+
const result = await p.deleteManyData!({
746773
object: req.params.object,
747774
...req.body,
748-
...(projectId ? { projectId } : {}),
749775
} as any);
750776
res.json(result);
751777
} catch (error: any) {

packages/runtime/src/environment-registry.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,18 @@ export class DefaultEnvironmentDriverRegistry implements EnvironmentDriverRegist
271271
switch (driverType) {
272272
case 'memory': {
273273
const { InMemoryDriver } = await import('@objectstack/driver-memory');
274+
// Derive a per-project JSON path from the `memory://<dbName>` URL
275+
// so each project owns its own persistence file instead of every
276+
// memory-driver project sharing a single `memory-driver.json`.
277+
// Mirrors DefaultProjectKernelFactory.createDriver so both paths
278+
// (cache warm-up here + factory fallback) land on the same file.
279+
const { resolve: resolvePath } = await import('node:path');
280+
const dbName = databaseUrl.replace(/^memory:\/\//, '').trim();
281+
const filePath = dbName
282+
? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`)
283+
: undefined;
274284
return new InMemoryDriver({
275-
persistence: 'file',
285+
persistence: filePath ? { type: 'file', path: filePath } : 'file',
276286
}) as unknown as IDataDriver;
277287
}
278288

packages/runtime/src/project-kernel-factory.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,19 @@ export class DefaultProjectKernelFactory implements ProjectKernelFactory {
206206
switch (driverType) {
207207
case 'memory': {
208208
const { InMemoryDriver } = await import('@objectstack/driver-memory');
209-
return new InMemoryDriver({ persistence: 'file' }) as unknown as IDataDriver;
209+
// Derive a per-project JSON file path from the `memory://<dbName>`
210+
// URL so each project gets its own `.objectstack/data/projects/<dbName>.json`
211+
// snapshot instead of every memory-driver project clobbering a single
212+
// shared `memory-driver.json`. Falls back to the adapter's default
213+
// path if the URL does not carry a usable name.
214+
const { resolve: resolvePath } = await import('node:path');
215+
const dbName = databaseUrl.replace(/^memory:\/\//, '').trim();
216+
const filePath = dbName
217+
? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`)
218+
: undefined;
219+
return new InMemoryDriver({
220+
persistence: filePath ? { type: 'file', path: filePath } : 'file',
221+
}) as unknown as IDataDriver;
210222
}
211223
case 'sqlite':
212224
case 'sql': {

0 commit comments

Comments
 (0)