@objectstack/objectql 1.0.10 → 1.0.12

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/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
- // Nested Map: Type -> Name/ID -> MetadataItem
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
- * Universal Register Method
15
- * @param type The category of metadata (e.g., 'object', 'app', 'plugin')
16
- * @param item The metadata item itself
17
- * @param keyField The property to use as the unique key (default: 'name')
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 registerItem<T>(type: string, item: T, keyField: keyof T = 'name' as keyof T) {
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 key = String(item[keyField]);
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
- this.validate(type, item);
440
+ this.validate(type, item);
29
441
  } catch (e: any) {
30
- console.error(`[Registry] Validation failed for ${type} ${key}: ${e.message}`);
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
- if (collection.has(key)) {
36
- console.warn(`[Registry] Overwriting ${type}: ${key}`);
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(key, item);
39
- console.log(`[Registry] Registered ${type}: ${key}`);
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
- if (type === 'object') {
47
- return ObjectSchema.parse(item);
48
- }
49
- if (type === 'app') {
50
- // AppSchema might rely on Zod, imported via UI protocol
51
- return AppSchema.parse(item);
52
- }
53
- if (type === 'plugin') {
54
- return ManifestSchema.parse(item);
55
- }
56
- // Add more validations as needed
57
- return true;
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 && collection.has(name)) {
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
- return this.metadata.get(type)?.get(name) as T;
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
- return Array.from(this.metadata.get(type)?.values() || []) as T[];
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
- return Array.from(this.metadata.keys());
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
- // Typed Helper Methods (Shortcuts)
548
+ // Package Management
96
549
  // ==========================================
97
550
 
98
- /**
99
- * Object Helpers
100
- */
101
- static registerObject(schema: ServiceObject) {
102
- this.registerItem('object', schema, 'name');
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 getObject(name: string): ServiceObject | undefined {
106
- return this.getItem<ServiceObject>('object', name);
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 getAllObjects(): ServiceObject[] {
110
- return this.listItems<ServiceObject>('object');
604
+ static getPackage(id: string): InstalledPackage | undefined {
605
+ return this.metadata.get('package')?.get(id) as InstalledPackage | undefined;
111
606
  }
112
607
 
113
- /**
114
- * Plugin Helpers
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
- * Kind (Metadata Type) Helpers
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
  }