@objectstack/objectql 1.0.11 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +9 -0
- package/dist/index.d.mts +713 -33
- package/dist/index.d.ts +713 -33
- package/dist/index.js +585 -67
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +580 -67
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -4
- package/src/engine.test.ts +60 -3
- package/src/engine.ts +115 -8
- package/src/index.ts +9 -1
- package/src/plugin.ts +3 -1
- package/src/protocol.ts +63 -22
- package/src/registry.test.ts +456 -25
- package/src/registry.ts +609 -53
package/src/registry.ts
CHANGED
|
@@ -1,60 +1,474 @@
|
|
|
1
|
-
import { ServiceObject, ObjectSchema } from '@objectstack/spec/data';
|
|
2
|
-
import { ObjectStackManifest, ManifestSchema } from '@objectstack/spec/kernel';
|
|
1
|
+
import { ServiceObject, ObjectSchema, ObjectOwnership } from '@objectstack/spec/data';
|
|
2
|
+
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
|
|
3
3
|
import { AppSchema } from '@objectstack/spec/ui';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Reserved namespaces that do not get FQN prefix applied.
|
|
7
|
+
* Objects in these namespaces keep their short names (e.g., "user" not "base__user").
|
|
8
|
+
*/
|
|
9
|
+
export const RESERVED_NAMESPACES = new Set(['base', 'system']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default priorities for ownership types.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_OWNER_PRIORITY = 100;
|
|
15
|
+
export const DEFAULT_EXTENDER_PRIORITY = 200;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Contributor Record
|
|
19
|
+
* Tracks how a package contributes to an object (own or extend).
|
|
20
|
+
*/
|
|
21
|
+
export interface ObjectContributor {
|
|
22
|
+
packageId: string;
|
|
23
|
+
namespace: string;
|
|
24
|
+
ownership: ObjectOwnership;
|
|
25
|
+
priority: number;
|
|
26
|
+
definition: ServiceObject;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compute Fully Qualified Name (FQN) for an object.
|
|
31
|
+
*
|
|
32
|
+
* @param namespace - The package namespace (e.g., "crm", "todo")
|
|
33
|
+
* @param shortName - The object's short name (e.g., "task", "account")
|
|
34
|
+
* @returns FQN string (e.g., "crm__task") or just shortName for reserved namespaces
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* computeFQN('crm', 'account') // => 'crm__account'
|
|
38
|
+
* computeFQN('base', 'user') // => 'user' (reserved, no prefix)
|
|
39
|
+
* computeFQN(undefined, 'task') // => 'task' (legacy, no namespace)
|
|
40
|
+
*/
|
|
41
|
+
export function computeFQN(namespace: string | undefined, shortName: string): string {
|
|
42
|
+
if (!namespace || RESERVED_NAMESPACES.has(namespace)) {
|
|
43
|
+
return shortName;
|
|
44
|
+
}
|
|
45
|
+
return `${namespace}__${shortName}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse FQN back to namespace and short name.
|
|
50
|
+
*
|
|
51
|
+
* @param fqn - Fully qualified name (e.g., "crm__account" or "user")
|
|
52
|
+
* @returns { namespace, shortName } - namespace is undefined for unprefixed names
|
|
53
|
+
*/
|
|
54
|
+
export function parseFQN(fqn: string): { namespace: string | undefined; shortName: string } {
|
|
55
|
+
const idx = fqn.indexOf('__');
|
|
56
|
+
if (idx === -1) {
|
|
57
|
+
return { namespace: undefined, shortName: fqn };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
namespace: fqn.slice(0, idx),
|
|
61
|
+
shortName: fqn.slice(idx + 2),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Deep merge two ServiceObject definitions.
|
|
67
|
+
* Fields are merged additively. Other props: later value wins.
|
|
68
|
+
*/
|
|
69
|
+
function mergeObjectDefinitions(base: ServiceObject, extension: Partial<ServiceObject>): ServiceObject {
|
|
70
|
+
const merged = { ...base };
|
|
71
|
+
|
|
72
|
+
// Merge fields additively
|
|
73
|
+
if (extension.fields) {
|
|
74
|
+
merged.fields = { ...base.fields, ...extension.fields };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Merge validations additively
|
|
78
|
+
if (extension.validations) {
|
|
79
|
+
merged.validations = [...(base.validations || []), ...extension.validations];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Merge indexes additively
|
|
83
|
+
if (extension.indexes) {
|
|
84
|
+
merged.indexes = [...(base.indexes || []), ...extension.indexes];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Override scalar props (last writer wins)
|
|
88
|
+
if (extension.label !== undefined) merged.label = extension.label;
|
|
89
|
+
if (extension.pluralLabel !== undefined) merged.pluralLabel = extension.pluralLabel;
|
|
90
|
+
if (extension.description !== undefined) merged.description = extension.description;
|
|
91
|
+
|
|
92
|
+
return merged;
|
|
93
|
+
}
|
|
94
|
+
|
|
5
95
|
/**
|
|
6
96
|
* Global Schema Registry
|
|
7
97
|
* Unified storage for all metadata types (Objects, Apps, Flows, Layouts, etc.)
|
|
98
|
+
*
|
|
99
|
+
* ## Namespace & Ownership Model
|
|
100
|
+
*
|
|
101
|
+
* Objects use a namespace-based FQN system:
|
|
102
|
+
* - `namespace`: Short identifier from package manifest (e.g., "crm", "todo")
|
|
103
|
+
* - `FQN`: `{namespace}__{short_name}` (e.g., "crm__account")
|
|
104
|
+
* - Reserved namespaces (`base`, `system`) don't get prefixed
|
|
105
|
+
*
|
|
106
|
+
* Ownership modes:
|
|
107
|
+
* - `own`: One package owns the object (creates the table, defines base schema)
|
|
108
|
+
* - `extend`: Multiple packages can extend an object (add fields, merge by priority)
|
|
109
|
+
*
|
|
110
|
+
* ## Package vs App Distinction
|
|
111
|
+
* - **Package**: The unit of installation, stored under type 'package'.
|
|
112
|
+
* Each InstalledPackage wraps a ManifestSchema with lifecycle state.
|
|
113
|
+
* - **App**: A UI navigation shell (AppSchema), registered under type 'apps'.
|
|
114
|
+
* Apps are extracted from packages during registration.
|
|
115
|
+
* - A package may contain 0, 1, or many apps.
|
|
8
116
|
*/
|
|
117
|
+
export type RegistryLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
118
|
+
|
|
9
119
|
export class SchemaRegistry {
|
|
10
|
-
//
|
|
120
|
+
// ==========================================
|
|
121
|
+
// Logging control
|
|
122
|
+
// ==========================================
|
|
123
|
+
|
|
124
|
+
/** Controls verbosity of registry console messages. Default: 'info'. */
|
|
125
|
+
private static _logLevel: RegistryLogLevel = 'info';
|
|
126
|
+
|
|
127
|
+
static get logLevel(): RegistryLogLevel { return this._logLevel; }
|
|
128
|
+
static set logLevel(level: RegistryLogLevel) { this._logLevel = level; }
|
|
129
|
+
|
|
130
|
+
private static log(msg: string): void {
|
|
131
|
+
if (this._logLevel === 'silent' || this._logLevel === 'error' || this._logLevel === 'warn') return;
|
|
132
|
+
console.log(msg);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ==========================================
|
|
136
|
+
// Object-specific storage (Ownership Model)
|
|
137
|
+
// ==========================================
|
|
138
|
+
|
|
139
|
+
/** FQN → Contributor[] (all packages that own/extend this object) */
|
|
140
|
+
private static objectContributors = new Map<string, ObjectContributor[]>();
|
|
141
|
+
|
|
142
|
+
/** FQN → Merged ServiceObject (cached, invalidated on changes) */
|
|
143
|
+
private static mergedObjectCache = new Map<string, ServiceObject>();
|
|
144
|
+
|
|
145
|
+
/** Namespace → PackageId (ensures namespace uniqueness) */
|
|
146
|
+
private static namespaceRegistry = new Map<string, string>();
|
|
147
|
+
|
|
148
|
+
// ==========================================
|
|
149
|
+
// Generic metadata storage (non-object types)
|
|
150
|
+
// ==========================================
|
|
151
|
+
|
|
152
|
+
/** Type → Name/ID → MetadataItem */
|
|
11
153
|
private static metadata = new Map<string, Map<string, any>>();
|
|
12
154
|
|
|
155
|
+
// ==========================================
|
|
156
|
+
// Namespace Management
|
|
157
|
+
// ==========================================
|
|
158
|
+
|
|
13
159
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* @
|
|
160
|
+
* Register a namespace for a package.
|
|
161
|
+
* Enforces namespace uniqueness within the instance.
|
|
162
|
+
*
|
|
163
|
+
* @throws Error if namespace is already registered to a different package
|
|
18
164
|
*/
|
|
19
|
-
static
|
|
165
|
+
static registerNamespace(namespace: string, packageId: string): void {
|
|
166
|
+
if (!namespace) return;
|
|
167
|
+
|
|
168
|
+
const existing = this.namespaceRegistry.get(namespace);
|
|
169
|
+
if (existing && existing !== packageId) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Namespace "${namespace}" is already registered to package "${existing}". ` +
|
|
172
|
+
`Package "${packageId}" cannot use the same namespace.`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.namespaceRegistry.set(namespace, packageId);
|
|
177
|
+
this.log(`[Registry] Registered namespace: ${namespace} → ${packageId}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Unregister a namespace when a package is uninstalled.
|
|
182
|
+
*/
|
|
183
|
+
static unregisterNamespace(namespace: string, packageId: string): void {
|
|
184
|
+
const existing = this.namespaceRegistry.get(namespace);
|
|
185
|
+
if (existing === packageId) {
|
|
186
|
+
this.namespaceRegistry.delete(namespace);
|
|
187
|
+
this.log(`[Registry] Unregistered namespace: ${namespace}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get the package that owns a namespace.
|
|
193
|
+
*/
|
|
194
|
+
static getNamespaceOwner(namespace: string): string | undefined {
|
|
195
|
+
return this.namespaceRegistry.get(namespace);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ==========================================
|
|
199
|
+
// Object Registration (Ownership Model)
|
|
200
|
+
// ==========================================
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Register an object with ownership semantics.
|
|
204
|
+
*
|
|
205
|
+
* @param schema - The object definition
|
|
206
|
+
* @param packageId - The owning package ID
|
|
207
|
+
* @param namespace - The package namespace (for FQN computation)
|
|
208
|
+
* @param ownership - 'own' (single owner) or 'extend' (additive merge)
|
|
209
|
+
* @param priority - Merge priority (lower applied first, higher wins on conflict)
|
|
210
|
+
*
|
|
211
|
+
* @throws Error if trying to 'own' an object that already has an owner
|
|
212
|
+
*/
|
|
213
|
+
static registerObject(
|
|
214
|
+
schema: ServiceObject,
|
|
215
|
+
packageId: string,
|
|
216
|
+
namespace?: string,
|
|
217
|
+
ownership: ObjectOwnership = 'own',
|
|
218
|
+
priority: number = ownership === 'own' ? DEFAULT_OWNER_PRIORITY : DEFAULT_EXTENDER_PRIORITY
|
|
219
|
+
): string {
|
|
220
|
+
const shortName = schema.name;
|
|
221
|
+
const fqn = computeFQN(namespace, shortName);
|
|
222
|
+
|
|
223
|
+
// Ensure namespace is registered
|
|
224
|
+
if (namespace) {
|
|
225
|
+
this.registerNamespace(namespace, packageId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Get or create contributor list
|
|
229
|
+
let contributors = this.objectContributors.get(fqn);
|
|
230
|
+
if (!contributors) {
|
|
231
|
+
contributors = [];
|
|
232
|
+
this.objectContributors.set(fqn, contributors);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Validate ownership rules
|
|
236
|
+
if (ownership === 'own') {
|
|
237
|
+
const existingOwner = contributors.find(c => c.ownership === 'own');
|
|
238
|
+
if (existingOwner && existingOwner.packageId !== packageId) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
|
|
241
|
+
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
// Remove existing owner contribution from same package (re-registration)
|
|
245
|
+
const idx = contributors.findIndex(c => c.packageId === packageId && c.ownership === 'own');
|
|
246
|
+
if (idx !== -1) {
|
|
247
|
+
contributors.splice(idx, 1);
|
|
248
|
+
console.warn(`[Registry] Re-registering owned object: ${fqn} from ${packageId}`);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
// extend mode: remove existing extension from same package
|
|
252
|
+
const idx = contributors.findIndex(c => c.packageId === packageId && c.ownership === 'extend');
|
|
253
|
+
if (idx !== -1) {
|
|
254
|
+
contributors.splice(idx, 1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Add new contributor
|
|
259
|
+
const contributor: ObjectContributor = {
|
|
260
|
+
packageId,
|
|
261
|
+
namespace: namespace || '',
|
|
262
|
+
ownership,
|
|
263
|
+
priority,
|
|
264
|
+
definition: { ...schema, name: fqn }, // Store with FQN as name
|
|
265
|
+
};
|
|
266
|
+
contributors.push(contributor);
|
|
267
|
+
|
|
268
|
+
// Sort by priority (ascending: lower priority applied first)
|
|
269
|
+
contributors.sort((a, b) => a.priority - b.priority);
|
|
270
|
+
|
|
271
|
+
// Invalidate merge cache
|
|
272
|
+
this.mergedObjectCache.delete(fqn);
|
|
273
|
+
|
|
274
|
+
this.log(`[Registry] Registered object: ${fqn} (${ownership}, priority=${priority}) from ${packageId}`);
|
|
275
|
+
return fqn;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Resolve an object by FQN, merging all contributions.
|
|
280
|
+
* Returns the merged object or undefined if not found.
|
|
281
|
+
*/
|
|
282
|
+
static resolveObject(fqn: string): ServiceObject | undefined {
|
|
283
|
+
// Check cache first
|
|
284
|
+
const cached = this.mergedObjectCache.get(fqn);
|
|
285
|
+
if (cached) return cached;
|
|
286
|
+
|
|
287
|
+
const contributors = this.objectContributors.get(fqn);
|
|
288
|
+
if (!contributors || contributors.length === 0) {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Find owner (must exist for a valid object)
|
|
293
|
+
const ownerContrib = contributors.find(c => c.ownership === 'own');
|
|
294
|
+
if (!ownerContrib) {
|
|
295
|
+
console.warn(`[Registry] Object "${fqn}" has extenders but no owner. Skipping.`);
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Start with owner's definition
|
|
300
|
+
let merged = { ...ownerContrib.definition };
|
|
301
|
+
|
|
302
|
+
// Apply extensions in priority order (already sorted)
|
|
303
|
+
for (const contrib of contributors) {
|
|
304
|
+
if (contrib.ownership === 'extend') {
|
|
305
|
+
merged = mergeObjectDefinitions(merged, contrib.definition);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Cache the result
|
|
310
|
+
this.mergedObjectCache.set(fqn, merged);
|
|
311
|
+
return merged;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get object by name (FQN or short name with fallback scan).
|
|
316
|
+
* For compatibility, tries exact match first, then scans for suffix match.
|
|
317
|
+
*/
|
|
318
|
+
static getObject(name: string): ServiceObject | undefined {
|
|
319
|
+
// Direct FQN lookup
|
|
320
|
+
const direct = this.resolveObject(name);
|
|
321
|
+
if (direct) return direct;
|
|
322
|
+
|
|
323
|
+
// Fallback: scan for objects ending with the short name
|
|
324
|
+
// This handles legacy code that doesn't use FQN
|
|
325
|
+
for (const fqn of this.objectContributors.keys()) {
|
|
326
|
+
const { shortName } = parseFQN(fqn);
|
|
327
|
+
if (shortName === name) {
|
|
328
|
+
return this.resolveObject(fqn);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Get all registered objects (merged).
|
|
337
|
+
*
|
|
338
|
+
* @param packageId - Optional filter: only objects contributed by this package
|
|
339
|
+
*/
|
|
340
|
+
static getAllObjects(packageId?: string): ServiceObject[] {
|
|
341
|
+
const results: ServiceObject[] = [];
|
|
342
|
+
|
|
343
|
+
for (const fqn of this.objectContributors.keys()) {
|
|
344
|
+
// If filtering by package, check if this package contributes
|
|
345
|
+
if (packageId) {
|
|
346
|
+
const contributors = this.objectContributors.get(fqn);
|
|
347
|
+
const hasContribution = contributors?.some(c => c.packageId === packageId);
|
|
348
|
+
if (!hasContribution) continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const merged = this.resolveObject(fqn);
|
|
352
|
+
if (merged) {
|
|
353
|
+
// Tag with contributor info for UI
|
|
354
|
+
(merged as any)._packageId = this.getObjectOwner(fqn)?.packageId;
|
|
355
|
+
results.push(merged);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return results;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get all contributors for an object.
|
|
364
|
+
*/
|
|
365
|
+
static getObjectContributors(fqn: string): ObjectContributor[] {
|
|
366
|
+
return this.objectContributors.get(fqn) || [];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Get the owner contributor for an object.
|
|
371
|
+
*/
|
|
372
|
+
static getObjectOwner(fqn: string): ObjectContributor | undefined {
|
|
373
|
+
const contributors = this.objectContributors.get(fqn);
|
|
374
|
+
return contributors?.find(c => c.ownership === 'own');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Unregister all objects contributed by a package.
|
|
379
|
+
*
|
|
380
|
+
* @throws Error if trying to uninstall an owner that has extenders
|
|
381
|
+
*/
|
|
382
|
+
static unregisterObjectsByPackage(packageId: string, force: boolean = false): void {
|
|
383
|
+
for (const [fqn, contributors] of this.objectContributors.entries()) {
|
|
384
|
+
// Find this package's contributions
|
|
385
|
+
const packageContribs = contributors.filter(c => c.packageId === packageId);
|
|
386
|
+
|
|
387
|
+
for (const contrib of packageContribs) {
|
|
388
|
+
if (contrib.ownership === 'own' && !force) {
|
|
389
|
+
// Check if there are extenders from other packages
|
|
390
|
+
const otherExtenders = contributors.filter(
|
|
391
|
+
c => c.packageId !== packageId && c.ownership === 'extend'
|
|
392
|
+
);
|
|
393
|
+
if (otherExtenders.length > 0) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`Cannot uninstall package "${packageId}": object "${fqn}" is extended by ` +
|
|
396
|
+
`${otherExtenders.map(c => c.packageId).join(', ')}. Uninstall extenders first.`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Remove contribution
|
|
402
|
+
const idx = contributors.indexOf(contrib);
|
|
403
|
+
if (idx !== -1) {
|
|
404
|
+
contributors.splice(idx, 1);
|
|
405
|
+
this.log(`[Registry] Removed ${contrib.ownership} contribution to ${fqn} from ${packageId}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Clean up empty contributor lists
|
|
410
|
+
if (contributors.length === 0) {
|
|
411
|
+
this.objectContributors.delete(fqn);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Invalidate cache
|
|
415
|
+
this.mergedObjectCache.delete(fqn);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ==========================================
|
|
420
|
+
// Generic Metadata (Non-Object Types)
|
|
421
|
+
// ==========================================
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Universal Register Method for non-object metadata.
|
|
425
|
+
*/
|
|
426
|
+
static registerItem<T>(type: string, item: T, keyField: keyof T = 'name' as keyof T, packageId?: string) {
|
|
20
427
|
if (!this.metadata.has(type)) {
|
|
21
428
|
this.metadata.set(type, new Map());
|
|
22
429
|
}
|
|
23
430
|
const collection = this.metadata.get(type)!;
|
|
24
|
-
const
|
|
431
|
+
const baseName = String(item[keyField]);
|
|
432
|
+
|
|
433
|
+
// Tag item with owning package for scoped queries
|
|
434
|
+
if (packageId) {
|
|
435
|
+
(item as any)._packageId = packageId;
|
|
436
|
+
}
|
|
25
437
|
|
|
26
438
|
// Validation Hook
|
|
27
439
|
try {
|
|
28
|
-
|
|
440
|
+
this.validate(type, item);
|
|
29
441
|
} catch (e: any) {
|
|
30
|
-
|
|
31
|
-
// For now, warn but don't crash, allowing partial/legacy loads
|
|
32
|
-
// throw e;
|
|
442
|
+
console.error(`[Registry] Validation failed for ${type} ${baseName}: ${e.message}`);
|
|
33
443
|
}
|
|
34
444
|
|
|
35
|
-
|
|
36
|
-
|
|
445
|
+
// Use composite key (packageId:name) when packageId is provided
|
|
446
|
+
const storageKey = packageId ? `${packageId}:${baseName}` : baseName;
|
|
447
|
+
|
|
448
|
+
if (collection.has(storageKey)) {
|
|
449
|
+
console.warn(`[Registry] Overwriting ${type}: ${storageKey}`);
|
|
37
450
|
}
|
|
38
|
-
collection.set(
|
|
39
|
-
|
|
451
|
+
collection.set(storageKey, item);
|
|
452
|
+
this.log(`[Registry] Registered ${type}: ${storageKey}`);
|
|
40
453
|
}
|
|
41
454
|
|
|
42
455
|
/**
|
|
43
456
|
* Validate Metadata against Spec Zod Schemas
|
|
44
457
|
*/
|
|
45
458
|
static validate(type: string, item: any) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
459
|
+
if (type === 'object') {
|
|
460
|
+
return ObjectSchema.parse(item);
|
|
461
|
+
}
|
|
462
|
+
if (type === 'apps') {
|
|
463
|
+
return AppSchema.parse(item);
|
|
464
|
+
}
|
|
465
|
+
if (type === 'package') {
|
|
466
|
+
return InstalledPackageSchema.parse(item);
|
|
467
|
+
}
|
|
468
|
+
if (type === 'plugin') {
|
|
469
|
+
return ManifestSchema.parse(item);
|
|
470
|
+
}
|
|
471
|
+
return true;
|
|
58
472
|
}
|
|
59
473
|
|
|
60
474
|
/**
|
|
@@ -62,57 +476,183 @@ export class SchemaRegistry {
|
|
|
62
476
|
*/
|
|
63
477
|
static unregisterItem(type: string, name: string) {
|
|
64
478
|
const collection = this.metadata.get(type);
|
|
65
|
-
if (collection
|
|
66
|
-
collection.delete(name);
|
|
67
|
-
console.log(`[Registry] Unregistered ${type}: ${name}`);
|
|
68
|
-
} else {
|
|
479
|
+
if (!collection) {
|
|
69
480
|
console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (collection.has(name)) {
|
|
484
|
+
collection.delete(name);
|
|
485
|
+
this.log(`[Registry] Unregistered ${type}: ${name}`);
|
|
486
|
+
return;
|
|
70
487
|
}
|
|
488
|
+
// Scan composite keys
|
|
489
|
+
for (const key of collection.keys()) {
|
|
490
|
+
if (key.endsWith(`:${name}`)) {
|
|
491
|
+
collection.delete(key);
|
|
492
|
+
this.log(`[Registry] Unregistered ${type}: ${key}`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`);
|
|
71
497
|
}
|
|
72
498
|
|
|
73
499
|
/**
|
|
74
500
|
* Universal Get Method
|
|
75
501
|
*/
|
|
76
502
|
static getItem<T>(type: string, name: string): T | undefined {
|
|
77
|
-
|
|
503
|
+
// Special handling for 'object' and 'objects' types - use objectContributors
|
|
504
|
+
if (type === 'object' || type === 'objects') {
|
|
505
|
+
return this.getObject(name) as unknown as T | undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const collection = this.metadata.get(type);
|
|
509
|
+
if (!collection) return undefined;
|
|
510
|
+
const direct = collection.get(name);
|
|
511
|
+
if (direct) return direct as T;
|
|
512
|
+
// Scan for composite keys
|
|
513
|
+
for (const [key, item] of collection) {
|
|
514
|
+
if (key.endsWith(`:${name}`)) return item as T;
|
|
515
|
+
}
|
|
516
|
+
return undefined;
|
|
78
517
|
}
|
|
79
518
|
|
|
80
519
|
/**
|
|
81
520
|
* Universal List Method
|
|
82
521
|
*/
|
|
83
|
-
static listItems<T>(type: string): T[] {
|
|
84
|
-
|
|
522
|
+
static listItems<T>(type: string, packageId?: string): T[] {
|
|
523
|
+
// Special handling for 'object' and 'objects' types - use objectContributors
|
|
524
|
+
if (type === 'object' || type === 'objects') {
|
|
525
|
+
return this.getAllObjects(packageId) as unknown as T[];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const items = Array.from(this.metadata.get(type)?.values() || []) as T[];
|
|
529
|
+
if (packageId) {
|
|
530
|
+
return items.filter((item: any) => item._packageId === packageId);
|
|
531
|
+
}
|
|
532
|
+
return items;
|
|
85
533
|
}
|
|
86
534
|
|
|
87
535
|
/**
|
|
88
536
|
* Get all registered metadata types (Kinds)
|
|
89
537
|
*/
|
|
90
538
|
static getRegisteredTypes(): string[] {
|
|
91
|
-
|
|
539
|
+
const types = Array.from(this.metadata.keys());
|
|
540
|
+
// Always include 'object' even if stored separately
|
|
541
|
+
if (!types.includes('object') && this.objectContributors.size > 0) {
|
|
542
|
+
types.push('object');
|
|
543
|
+
}
|
|
544
|
+
return types;
|
|
92
545
|
}
|
|
93
546
|
|
|
94
547
|
// ==========================================
|
|
95
|
-
//
|
|
548
|
+
// Package Management
|
|
96
549
|
// ==========================================
|
|
97
550
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
551
|
+
static installPackage(manifest: ObjectStackManifest, settings?: Record<string, any>): InstalledPackage {
|
|
552
|
+
const now = new Date().toISOString();
|
|
553
|
+
const pkg: InstalledPackage = {
|
|
554
|
+
manifest,
|
|
555
|
+
status: 'installed',
|
|
556
|
+
enabled: true,
|
|
557
|
+
installedAt: now,
|
|
558
|
+
updatedAt: now,
|
|
559
|
+
settings,
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// Register namespace if present
|
|
563
|
+
if (manifest.namespace) {
|
|
564
|
+
this.registerNamespace(manifest.namespace, manifest.id);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!this.metadata.has('package')) {
|
|
568
|
+
this.metadata.set('package', new Map());
|
|
569
|
+
}
|
|
570
|
+
const collection = this.metadata.get('package')!;
|
|
571
|
+
if (collection.has(manifest.id)) {
|
|
572
|
+
console.warn(`[Registry] Overwriting package: ${manifest.id}`);
|
|
573
|
+
}
|
|
574
|
+
collection.set(manifest.id, pkg);
|
|
575
|
+
this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`);
|
|
576
|
+
return pkg;
|
|
103
577
|
}
|
|
104
578
|
|
|
105
|
-
static
|
|
106
|
-
|
|
579
|
+
static uninstallPackage(id: string): boolean {
|
|
580
|
+
const pkg = this.getPackage(id);
|
|
581
|
+
if (!pkg) {
|
|
582
|
+
console.warn(`[Registry] Package not found for uninstall: ${id}`);
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Unregister namespace
|
|
587
|
+
if (pkg.manifest.namespace) {
|
|
588
|
+
this.unregisterNamespace(pkg.manifest.namespace, id);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Unregister objects (will throw if extenders exist)
|
|
592
|
+
this.unregisterObjectsByPackage(id);
|
|
593
|
+
|
|
594
|
+
// Remove package record
|
|
595
|
+
const collection = this.metadata.get('package');
|
|
596
|
+
if (collection) {
|
|
597
|
+
collection.delete(id);
|
|
598
|
+
this.log(`[Registry] Uninstalled package: ${id}`);
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
107
602
|
}
|
|
108
603
|
|
|
109
|
-
static
|
|
110
|
-
return this.
|
|
604
|
+
static getPackage(id: string): InstalledPackage | undefined {
|
|
605
|
+
return this.metadata.get('package')?.get(id) as InstalledPackage | undefined;
|
|
111
606
|
}
|
|
112
607
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
608
|
+
static getAllPackages(): InstalledPackage[] {
|
|
609
|
+
return this.listItems<InstalledPackage>('package');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
static enablePackage(id: string): InstalledPackage | undefined {
|
|
613
|
+
const pkg = this.getPackage(id);
|
|
614
|
+
if (pkg) {
|
|
615
|
+
pkg.enabled = true;
|
|
616
|
+
pkg.status = 'installed';
|
|
617
|
+
pkg.statusChangedAt = new Date().toISOString();
|
|
618
|
+
pkg.updatedAt = new Date().toISOString();
|
|
619
|
+
this.log(`[Registry] Enabled package: ${id}`);
|
|
620
|
+
}
|
|
621
|
+
return pkg;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
static disablePackage(id: string): InstalledPackage | undefined {
|
|
625
|
+
const pkg = this.getPackage(id);
|
|
626
|
+
if (pkg) {
|
|
627
|
+
pkg.enabled = false;
|
|
628
|
+
pkg.status = 'disabled';
|
|
629
|
+
pkg.statusChangedAt = new Date().toISOString();
|
|
630
|
+
pkg.updatedAt = new Date().toISOString();
|
|
631
|
+
this.log(`[Registry] Disabled package: ${id}`);
|
|
632
|
+
}
|
|
633
|
+
return pkg;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ==========================================
|
|
637
|
+
// App Helpers
|
|
638
|
+
// ==========================================
|
|
639
|
+
|
|
640
|
+
static registerApp(app: any, packageId?: string) {
|
|
641
|
+
this.registerItem('apps', app, 'name', packageId);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
static getApp(name: string): any {
|
|
645
|
+
return this.getItem('apps', name);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
static getAllApps(): any[] {
|
|
649
|
+
return this.listItems('apps');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ==========================================
|
|
653
|
+
// Plugin Helpers
|
|
654
|
+
// ==========================================
|
|
655
|
+
|
|
116
656
|
static registerPlugin(manifest: ObjectStackManifest) {
|
|
117
657
|
this.registerItem('plugin', manifest, 'id');
|
|
118
658
|
}
|
|
@@ -121,9 +661,10 @@ export class SchemaRegistry {
|
|
|
121
661
|
return this.listItems<ObjectStackManifest>('plugin');
|
|
122
662
|
}
|
|
123
663
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
664
|
+
// ==========================================
|
|
665
|
+
// Kind Helpers
|
|
666
|
+
// ==========================================
|
|
667
|
+
|
|
127
668
|
static registerKind(kind: { id: string, globs: string[] }) {
|
|
128
669
|
this.registerItem('kind', kind, 'id');
|
|
129
670
|
}
|
|
@@ -131,4 +672,19 @@ export class SchemaRegistry {
|
|
|
131
672
|
static getAllKinds(): { id: string, globs: string[] }[] {
|
|
132
673
|
return this.listItems('kind');
|
|
133
674
|
}
|
|
675
|
+
|
|
676
|
+
// ==========================================
|
|
677
|
+
// Reset (for testing)
|
|
678
|
+
// ==========================================
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Clear all registry state. Use only for testing.
|
|
682
|
+
*/
|
|
683
|
+
static reset(): void {
|
|
684
|
+
this.objectContributors.clear();
|
|
685
|
+
this.mergedObjectCache.clear();
|
|
686
|
+
this.namespaceRegistry.clear();
|
|
687
|
+
this.metadata.clear();
|
|
688
|
+
this.log('[Registry] Reset complete');
|
|
689
|
+
}
|
|
134
690
|
}
|