Skip to content

Commit 72f0c02

Browse files
hotlongCopilot
andcommitted
refactor(objectql): switch metadata overlay isolation from project_id to organization_id
ADR-0005 (revised 2026-05): per-env DBs replace per-project isolation. Each sys_environment owns its own Turso DB, so the legacy project_id discriminator in sys_metadata is redundant. Inside one env, multiple sys_organization rows can coexist (multi-org-per-env) and the only meaningful overlay isolation key is organization_id. Changes: - protocol.saveMetaItem now persists organization_id (from request) and stops writing project_id; existence lookup keyed by (type, name, organization_id, state='active'). - protocol.getMetaItem: org-specific → env-wide (organization_id IS NULL) → registry fallback chain. Backwards-compatible: callers without organizationId behave as env-wide. - protocol.getMetaItems: unions env-wide and org-specific rows; org-specific wins on name collision. - protocol.deleteMetaItem & loadMetaFromDb switched to organization_id scoping. loadMetaFromDb hydrates only env-wide rows so per-org overlays don't leak into the process-wide SchemaRegistry. - ensureOverlayIndex: idempotent migration drops the legacy (type, name, organization_id, project_id, scope) UNIQUE and creates (type, name, organization_id) UNIQUE WHERE state='active' in place. - sys-metadata.object.ts: project_id marked @deprecated; new schema index spec reflects the new uniqueness key. - http-dispatcher: resolves active organization id from session and threads it through saveMetaItem/getMetaItem/getMetaItems. - protocol-meta.test.ts: 4 new tests cover per-org isolation, env-wide fallback, and collision-wins semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0f70978 commit 72f0c02

7 files changed

Lines changed: 246 additions & 93 deletions

File tree

apps/objectos/cloudflare/worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// when shipping a hotfix that lives inside the Container image. The
55
// DO only reloads when worker code changes, so a no-op edit here
66
// guarantees the container restarts with the freshly-pushed image.
7-
// build: 2026-05-20T15:08Z sso-trusted-origins-fix
7+
// build: 2026-05-20T16:00Z org-scoped-overlay-refactor
88

99
/**
1010
* Cloudflare Containers entrypoint for ObjectOS.

packages/cli/src/commands/serve.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,17 @@ export default class Serve extends Command {
301301
if (requires.includes('auth') && !requires.includes('email')) {
302302
requires.push('email');
303303
}
304+
// Default capability slate — every preset except `minimal` gets the
305+
// foundational services (queue + job + cache + settings + email +
306+
// storage). Opt out with `objectstack serve --preset minimal`.
307+
// Keeping `auth → email` above as a defensive rule for users who
308+
// explicitly opt into `minimal` but still enable auth.
309+
const ALWAYS_CAPS = ['queue', 'job', 'cache', 'settings', 'email', 'storage'];
310+
if (presetName !== 'minimal') {
311+
for (const cap of ALWAYS_CAPS) {
312+
if (!requires.includes(cap)) requires.push(cap);
313+
}
314+
}
304315
// The email + approvals + reports services schedule background work
305316
// (durable retries, SLA escalation, scheduled digests). Auto-pull
306317
// 'job' and 'queue' so plugins can opt into durable scheduling.

packages/objectql/src/plugin.integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
880880
// Assert — sys_metadata should have been queried
881881
const metaQuery = findCalls.find((c) => c.object === 'sys_metadata');
882882
expect(metaQuery).toBeDefined();
883-
expect(metaQuery!.query.where).toEqual({ state: 'active', project_id: null });
883+
expect(metaQuery!.query.where).toEqual({ state: 'active', organization_id: null });
884884

885885
// Assert — items should be restored into the registry
886886
const registry = (kernel.getService('objectql') as any).registry;

packages/objectql/src/protocol-meta.test.ts

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,95 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
4646
// saveMetaItem — dual-write (registry + database)
4747
// ═══════════════════════════════════════════════════════════════
4848

49+
// ═══════════════════════════════════════════════════════════════
50+
// ADR-0005 (revised 2026-05): per-organization overlay isolation
51+
// ═══════════════════════════════════════════════════════════════
52+
53+
describe('per-organization overlay isolation', () => {
54+
it('saveMetaItem persists organization_id when provided', async () => {
55+
mockEngine.findOne.mockResolvedValue(null);
56+
await protocol.saveMetaItem({
57+
type: 'app',
58+
name: 'test_app',
59+
item: sampleApp,
60+
organizationId: 'org_alpha',
61+
});
62+
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', {
63+
where: { type: 'app', name: 'test_app', organization_id: 'org_alpha', state: 'active' },
64+
});
65+
expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
66+
organization_id: 'org_alpha',
67+
scope: 'platform',
68+
}));
69+
});
70+
71+
it('getMetaItem returns org-specific overlay when both org and env-wide rows exist', async () => {
72+
// findOverlay calls: first attempts org=org_alpha (returns row),
73+
// env-wide fallback should be skipped.
74+
mockEngine.findOne.mockImplementation((_table: string, opts: any) => {
75+
if (opts?.where?.organization_id === 'org_alpha') {
76+
return Promise.resolve({
77+
type: 'app', name: 'test_app', state: 'active',
78+
metadata: JSON.stringify({ ...sampleApp, label: 'Org Alpha' }),
79+
});
80+
}
81+
if (opts?.where?.organization_id === null) {
82+
return Promise.resolve({
83+
type: 'app', name: 'test_app', state: 'active',
84+
metadata: JSON.stringify({ ...sampleApp, label: 'Env Default' }),
85+
});
86+
}
87+
return Promise.resolve(null);
88+
});
89+
90+
const result = await protocol.getMetaItem({
91+
type: 'app', name: 'test_app', organizationId: 'org_alpha',
92+
});
93+
expect((result.item as any).label).toBe('Org Alpha');
94+
});
95+
96+
it('getMetaItem falls through to env-wide overlay when no org-specific row exists', async () => {
97+
mockEngine.findOne.mockImplementation((_table: string, opts: any) => {
98+
if (opts?.where?.organization_id === null) {
99+
return Promise.resolve({
100+
type: 'app', name: 'test_app', state: 'active',
101+
metadata: JSON.stringify({ ...sampleApp, label: 'Env Default' }),
102+
});
103+
}
104+
return Promise.resolve(null);
105+
});
106+
const result = await protocol.getMetaItem({
107+
type: 'app', name: 'test_app', organizationId: 'org_alpha',
108+
});
109+
expect((result.item as any).label).toBe('Env Default');
110+
});
111+
112+
it('getMetaItems unions env-wide and org-specific rows (org wins on collision)', async () => {
113+
mockEngine.find.mockImplementation((_table: string, opts: any) => {
114+
if (opts?.where?.organization_id === 'org_alpha') {
115+
return Promise.resolve([
116+
{ type: 'app', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Org Alpha' }) },
117+
{ type: 'app', name: 'alpha_only', state: 'active', metadata: JSON.stringify({ name: 'alpha_only', label: 'Alpha Only' }) },
118+
]);
119+
}
120+
if (opts?.where?.organization_id === null) {
121+
return Promise.resolve([
122+
{ type: 'app', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Env Default' }) },
123+
{ type: 'app', name: 'env_only', state: 'active', metadata: JSON.stringify({ name: 'env_only', label: 'Env Only' }) },
124+
]);
125+
}
126+
return Promise.resolve([]);
127+
});
128+
const result = await protocol.getMetaItems({
129+
type: 'app', organizationId: 'org_alpha',
130+
});
131+
const names = (result.items as any[]).map((i) => i.name).sort();
132+
expect(names).toEqual(['alpha_only', 'env_only', 'shared']);
133+
const shared = (result.items as any[]).find((i) => i.name === 'shared');
134+
expect(shared.label).toBe('Org Alpha');
135+
});
136+
});
137+
49138
describe('saveMetaItem', () => {
50139
it('should throw when item data is missing', async () => {
51140
await expect(
@@ -82,7 +171,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
82171
await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
83172

84173
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', {
85-
where: { type: 'app', name: 'test_app', project_id: null }
174+
where: { type: 'app', name: 'test_app', organization_id: null, state: 'active' }
86175
});
87176
expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
88177
name: 'test_app',
@@ -113,8 +202,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
113202
const result = await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
114203

115204
expect(result.success).toBe(true);
116-
// control-plane (no projectId) returns the legacy message
117-
expect(result.message).toBe('Saved to database and registry');
205+
// env-wide (no organizationId) overlay save
206+
expect(result.message).toMatch(/Saved customization overlay/);
118207
});
119208

120209
it('should fail-fast with 500 when DB findOne is unavailable (ADR-0005)', async () => {
@@ -324,7 +413,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
324413

325414
expect(result.item).toEqual(sampleApp);
326415
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', {
327-
where: { type: 'app', name: 'test_app', state: 'active', project_id: null }
416+
where: { type: 'app', name: 'test_app', state: 'active', organization_id: null }
328417
});
329418
});
330419

@@ -425,7 +514,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
425514
// DB *is* queried (always-merge semantics) so seeded metadata
426515
// surfaces even when the registry already has unrelated items.
427516
expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', {
428-
where: { type: 'app', state: 'active', project_id: null }
517+
where: { type: 'app', state: 'active', organization_id: null }
429518
});
430519
});
431520

@@ -444,7 +533,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
444533
expect(result.items).toHaveLength(1);
445534
expect(result.items[0]).toEqual(sampleApp);
446535
expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', {
447-
where: { type: 'app', state: 'active', project_id: null }
536+
where: { type: 'app', state: 'active', organization_id: null }
448537
});
449538
});
450539

@@ -561,7 +650,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
561650
await protocol.loadMetaFromDb();
562651

563652
expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', {
564-
where: { state: 'active', project_id: null }
653+
where: { state: 'active', organization_id: null }
565654
});
566655
});
567656

0 commit comments

Comments
 (0)