-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathplugin.ts
More file actions
404 lines (363 loc) · 17.2 KB
/
plugin.ts
File metadata and controls
404 lines (363 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { Plugin, PluginContext } from '@objectstack/core';
import { NodeMetadataManager } from './node-metadata-manager.js';
import { MemoryLoader } from './loaders/memory-loader.js';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
import type { MetadataPluginConfig } from '@objectstack/spec/kernel';
import {
SysMetadataObject,
SysMetadataHistoryObject,
} from '@objectstack/platform-objects/metadata';
// `SysMetadataObject` + `SysMetadataHistoryObject` are the customer overlay
// storage substrate (ADR-0005). They must always be auto-provisioned so
// `PUT /api/v1/meta/{view,dashboard}/...` has a place to write. All other
// metadata types (object/view/flow/agent/tool/dashboard/app/...) live as
// JSON inside `sys_metadata` — there are no separate per-type tables. The
// previously shipped `SysObject` / `SysView` / `SysFlow` / `SysAgent` /
// `SysTool` projection objects were removed in 2026-05 (see ADR 0005
// addendum); the projection pipeline was removed at the same time.
const queryableMetadataObjects = [
SysMetadataObject,
SysMetadataHistoryObject,
];
// Map from ObjectStackDefinition field name to MetadataType name
const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
objects: 'object',
objectExtensions: 'object_extension',
apps: 'app',
views: 'view',
pages: 'page',
dashboards: 'dashboard',
reports: 'report',
actions: 'action',
themes: 'theme',
workflows: 'workflow',
approvals: 'approval',
flows: 'flow',
roles: 'role',
permissions: 'permission',
sharingRules: 'sharing_rule',
policies: 'policy',
apis: 'api',
webhooks: 'webhook',
agents: 'agent',
skills: 'skill',
ragPipelines: 'rag_pipeline',
hooks: 'hook',
mappings: 'mapping',
analyticsCubes: 'analytics_cube',
connectors: 'connector',
data: 'dataset',
};
export interface MetadataPluginOptions {
rootDir?: string;
watch?: boolean;
config?: Partial<MetadataPluginConfig>;
/** Organization ID for metadata-scoped consumers; MetadataPlugin itself does not persist runtime metadata. */
organizationId?: string;
/** Project ID used by local artifact envelopes and metadata-scoped consumers. */
projectId?: string;
/**
* When set, MetadataPlugin loads metadata from an artifact instead of scanning
* the filesystem. Only `local-file` is implemented now; `artifact-api` is
* reserved for M3/M4.
*/
artifactSource?:
| { mode: 'local-file'; path: string; fetchTimeoutMs?: number }
| { mode: 'artifact-api'; url: string; token?: string; commitId?: string; fetchTimeoutMs?: number };
/**
* Register the `sys_metadata` + `sys_metadata_history` storage objects
* on this kernel. Default `true` for backward compatibility.
*
* Set to `false` for **per-project** kernels: in cloud / project mode the
* control plane is the sole owner of metadata storage tables — exposing
* them inside each project kernel would leak control-plane schema into
* business-data namespaces.
*/
registerSystemObjects?: boolean;
}
export class MetadataPlugin implements Plugin {
name = 'com.objectstack.metadata';
type = 'standard';
version = '1.0.0';
private manager: NodeMetadataManager;
private options: MetadataPluginOptions;
constructor(options: MetadataPluginOptions = {}) {
this.options = {
watch: true,
...options
};
const rootDir = this.options.rootDir || process.cwd();
// Sealed-runtime carve-out: `bootstrap: 'artifact-only'` MUST NOT touch
// the filesystem at all — that includes chokidar subscriptions. Force
// watch off in that mode regardless of `options.watch`. The other two
// modes ('eager', 'lazy') honor the user's flag; `lazy` + watch is a
// valid combination because chokidar attaches to `rootDir` directly,
// not as a side effect of any priming pass.
const bootstrapMode = this.options.config?.bootstrap ?? 'eager';
const effectiveWatch =
bootstrapMode === 'artifact-only' ? false : (this.options.watch ?? true);
this.manager = new NodeMetadataManager({
rootDir,
watch: effectiveWatch,
formats: ['yaml', 'json', 'typescript', 'javascript']
});
// Initialize with default type registry
this.manager.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
}
init = async (ctx: PluginContext) => {
ctx.logger.info('Initializing Metadata Manager', {
root: this.options.rootDir || process.cwd(),
watch: this.options.watch,
artifactSource: this.options.artifactSource?.mode,
});
// Register Metadata Manager as the primary metadata service provider.
ctx.registerService('metadata', this.manager);
console.log('[MetadataPlugin] Registered metadata service, has getRegisteredTypes:', typeof this.manager.getRegisteredTypes);
// Register metadata system objects via the manifest service (if available).
// MetadataPlugin may init before ObjectQLPlugin, so wrap in try/catch.
// Skipped when `registerSystemObjects: false` (per-project kernels in
// cloud / project mode — sys_* live exclusively in the control plane).
const registerSysObjects = this.options.registerSystemObjects !== false;
if (registerSysObjects) {
try {
const manifestService = ctx.getService<{ register(m: any): void }>('manifest');
// Register the queryable metadata-layer platform objects.
manifestService.register({
id: 'com.objectstack.metadata-objects',
name: 'Metadata Platform Objects',
version: '1.0.0',
type: 'plugin',
scope: 'system',
defaultDatasource: 'cloud',
objects: queryableMetadataObjects,
});
ctx.logger.info('Registered system metadata objects', {
queryable: queryableMetadataObjects.map((object) => object.name),
});
} catch {
// ObjectQL not loaded yet — objects will be discovered via legacy fallback
}
}
ctx.logger.info('MetadataPlugin providing metadata service (primary mode)', {
mode: this.options.artifactSource?.mode ?? 'file-system',
features: ['watch', 'multi-format', 'query', 'overlay', 'type-registry']
});
}
start = async (ctx: PluginContext) => {
const src = this.options.artifactSource;
const mode = this.options.config?.bootstrap ?? 'eager';
ctx.logger.info('[MetadataPlugin] Bootstrapping metadata', {
bootstrap: mode,
artifactSource: src?.mode ?? 'none',
});
if (mode === 'artifact-only') {
// Sealed-runtime mode: ONLY load from a pre-compiled artifact. Never
// touch the filesystem. Required for Edge / serverless / read-only
// production deployments where the running process must not depend
// on local source files.
if (src?.mode === 'local-file') {
await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
} else if (src?.mode === 'artifact-api') {
await this._loadFromArtifactApi(ctx, src);
} else {
throw new Error('[MetadataPlugin] bootstrap=artifact-only requires options.artifactSource to be set');
}
} else if (mode === 'lazy') {
// On-demand mode: skip the eager filesystem priming pass entirely.
// Reads go through MetadataManager.load*/list* which are backed by
// the DatabaseLoader read-through cache and any registered loaders.
// An artifact source, if present, is still honored so projects can
// pin a known set of metadata at boot without paying the FS scan.
if (src?.mode === 'local-file') {
await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
} else if (src?.mode === 'artifact-api') {
await this._loadFromArtifactApi(ctx, src);
} else {
ctx.logger.info('[MetadataPlugin] lazy bootstrap — skipping filesystem priming; metadata loads on demand');
}
} else {
// 'eager' (default): preserve historical behavior.
if (src?.mode === 'local-file') {
await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
} else if (src?.mode === 'artifact-api') {
await this._loadFromArtifactApi(ctx, src);
} else {
await this._loadFromFileSystem(ctx);
}
}
// Bridge realtime service from kernel service registry to MetadataManager.
try {
const realtimeService = ctx.getService('realtime');
if (realtimeService && typeof realtimeService === 'object' && 'publish' in realtimeService) {
ctx.logger.info('[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing');
this.manager.setRealtimeService(realtimeService as any);
}
} catch (e: any) {
ctx.logger.debug('[MetadataPlugin] No realtime service found — metadata events will not be published', {
error: e.message,
});
}
}
/**
* Fetch JSON content from a URL with configurable timeout.
*/
private async _fetchJson(url: string, fetchTimeoutMs?: number, token?: string): Promise<unknown> {
const envTimeout = Number(process.env.OS_ARTIFACT_FETCH_TIMEOUT_MS);
const timeoutMs = fetchTimeoutMs
?? (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : undefined)
?? 60_000;
const controller = new AbortController();
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
try {
const headers: Record<string, string> = { Accept: 'application/json, */*;q=0.5' };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { redirect: 'follow', signal: controller.signal, headers });
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
const content = await res.text();
return JSON.parse(content);
} catch (e: any) {
if (e?.name === 'AbortError') {
throw new Error(
`fetch timed out after ${timeoutMs}ms — set artifactSource.fetchTimeoutMs or OS_ARTIFACT_FETCH_TIMEOUT_MS to extend it (0 disables)`,
);
}
throw e;
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Parse raw artifact JSON (envelope or bare definition) and register all
* metadata items into the MetadataManager.
*/
private async _parseAndRegisterArtifact(ctx: PluginContext, raw: unknown, label: string): Promise<number> {
const { ProjectArtifactSchema } = await import('@objectstack/spec/cloud');
const { ObjectStackDefinitionSchema } = await import('@objectstack/spec');
let metadata: Record<string, unknown[]>;
const obj = raw as any;
if (obj?.schemaVersion && obj?.commitId && obj?.metadata !== undefined) {
const artifact = ProjectArtifactSchema.parse(obj);
metadata = artifact.metadata as Record<string, unknown[]>;
} else if (obj?.success && obj?.data?.metadata) {
// Unwrap cloud API envelope: { success: true, data: { metadata: {...} } }
const artifact = ProjectArtifactSchema.parse(obj.data);
metadata = artifact.metadata as Record<string, unknown[]>;
} else {
const def = ObjectStackDefinitionSchema.parse(obj);
const canonical = JSON.stringify(def, Object.keys(def).sort());
const checksum = createHash('sha256').update(canonical).digest('hex');
const projectId = this.options.projectId ?? 'proj_local';
ProjectArtifactSchema.parse({
schemaVersion: '0.1',
projectId,
commitId: 'local-dev',
checksum,
metadata: def,
});
metadata = def as Record<string, unknown[]>;
}
const memLoader = new MemoryLoader();
const manifestPackageId =
(metadata as any)?.manifest?.id ?? (metadata as any)?.id ?? undefined;
let totalRegistered = 0;
for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
const items = (metadata as any)[field];
if (!Array.isArray(items) || items.length === 0) continue;
for (const item of items) {
const name = (item as any)?.name;
if (!name) continue;
if (manifestPackageId && (item as any)._packageId === undefined) {
(item as any)._packageId = manifestPackageId;
}
await memLoader.save(metaType, name, item);
await this.manager.register(metaType, name, item);
totalRegistered++;
}
}
this.manager.registerLoader(memLoader);
ctx.logger.info('[MetadataPlugin] Artifact metadata loaded', { source: label, totalRegistered });
return totalRegistered;
}
private async _loadFromLocalFile(ctx: PluginContext, filePath: string, fetchTimeoutMs?: number): Promise<void> {
const isUrl = /^https?:\/\//i.test(filePath);
ctx.logger.info(
`[MetadataPlugin] Loading metadata from ${isUrl ? 'remote URL' : 'local artifact file'}`,
{ path: filePath },
);
let raw: unknown;
try {
if (isUrl) {
raw = await this._fetchJson(filePath, fetchTimeoutMs);
} else {
const content = await readFile(filePath, 'utf8');
raw = JSON.parse(content);
}
} catch (e: any) {
throw new Error(`[MetadataPlugin] Cannot read artifact ${isUrl ? 'URL' : 'file'} at "${filePath}": ${e.message}`);
}
await this._parseAndRegisterArtifact(ctx, raw, filePath);
}
/**
* P2: Load metadata from the cloud artifact API endpoint.
*/
private async _loadFromArtifactApi(
ctx: PluginContext,
src: { url: string; token?: string; commitId?: string; fetchTimeoutMs?: number },
): Promise<void> {
const projectId = this.options.projectId;
if (!projectId) {
throw new Error('[MetadataPlugin] artifact-api source requires options.projectId to be set');
}
// Build the artifact URL:
// ${url}/api/v1/cloud/projects/${projectId}/artifact[?commit=${commitId}]
let artifactUrl = src.url.replace(/\/+$/, '');
// If the URL already contains /api/v1, use it as-is; otherwise append default path.
if (!/\/api\/v\d+\/cloud\/projects\//i.test(artifactUrl)) {
artifactUrl = `${artifactUrl}/api/v1/cloud/projects/${projectId}/artifact`;
}
if (src.commitId) {
artifactUrl += `${artifactUrl.includes('?') ? '&' : '?'}commit=${encodeURIComponent(src.commitId)}`;
}
ctx.logger.info('[MetadataPlugin] Loading metadata from artifact API', { url: artifactUrl });
let raw: unknown;
try {
raw = await this._fetchJson(artifactUrl, src.fetchTimeoutMs, src.token);
} catch (e: any) {
throw new Error(`[MetadataPlugin] Cannot load artifact from API "${artifactUrl}": ${e.message}`);
}
await this._parseAndRegisterArtifact(ctx, raw, artifactUrl);
}
private async _loadFromFileSystem(ctx: PluginContext): Promise<void> {
ctx.logger.info('Loading metadata from file system...');
const sortedTypes = [...DEFAULT_METADATA_TYPE_REGISTRY]
.sort((a, b) => a.loadOrder - b.loadOrder);
let totalLoaded = 0;
for (const entry of sortedTypes) {
try {
const items = await this.manager.loadMany(entry.type, {
recursive: true,
patterns: entry.filePatterns,
});
if (items.length > 0) {
for (const item of items) {
const meta = item as any;
if (meta?.name) {
await this.manager.register(entry.type, meta.name, item);
}
}
ctx.logger.info(`Loaded ${items.length} ${entry.type} from file system`);
totalLoaded += items.length;
}
} catch (e: any) {
ctx.logger.debug(`No ${entry.type} metadata found`, { error: e.message });
}
}
ctx.logger.info('Metadata loading complete', {
totalItems: totalLoaded,
registeredTypes: sortedTypes.length,
});
}
}