|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `installPackage` stores a RECORD, never the caller's live object. |
| 5 | + * |
| 6 | + * ## What was wrong |
| 7 | + * |
| 8 | + * `installPackage(manifest)` kept the argument verbatim as `pkg.manifest`. For a |
| 9 | + * code-defined stack that argument is the live `defineStack()` object, and its |
| 10 | + * `plugins: [new ConnectorRestPlugin(), …]` entries hold the engine once they |
| 11 | + * initialise. Since the engine grew `actionActivation -> store -> engine` that |
| 12 | + * reference closes a CYCLE, so `JSON.stringify` of the registry item threw and |
| 13 | + * every read door that serialises a package answered `500 INTERNAL_ERROR` on a |
| 14 | + * stock showcase boot — `GET /packages`, `GET /packages/:id`, |
| 15 | + * `GET /meta/package/:id`, while `GET /meta/package/<a plugin-less package>` |
| 16 | + * stayed 200. |
| 17 | + * |
| 18 | + * ## Why the assertions are shaped this way |
| 19 | + * |
| 20 | + * ⚠️ "the manifest still round-trips" passes on the old code for every package |
| 21 | + * that has no plugins, which is 25 of the 26 a showcase boot installs. So the |
| 22 | + * cases below pin the MECHANISM: a live instance reaching the record, a plain |
| 23 | + * reference cycle, and a member that is not data at all — each asserted on |
| 24 | + * `JSON.stringify(registry.getPackage(id))`, the exact expression the doors run. |
| 25 | + * |
| 26 | + * ⚠️ Timing is part of the defect and is pinned too. Measured on the failing |
| 27 | + * boot: the same showcase manifest serialised CLEANLY during boot and only |
| 28 | + * became cyclic after plugin init, so a check that ran at install time would |
| 29 | + * have called the record healthy. The `becomes cyclic only after init` case |
| 30 | + * below reproduces that ordering — the projection must not depend on when it is |
| 31 | + * asked. |
| 32 | + */ |
| 33 | + |
| 34 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 35 | +import { SchemaRegistry } from './registry'; |
| 36 | + |
| 37 | +/** |
| 38 | + * A plugin instance in the shape that broke: a class instance the host |
| 39 | + * constructs in `objectstack.config.ts` and hands to `defineStack({ plugins })`, |
| 40 | + * which takes the engine when it initialises. |
| 41 | + */ |
| 42 | +class FakeConnectorPlugin { |
| 43 | + name = 'connector-rest'; |
| 44 | + engine: unknown; |
| 45 | + init(engine: unknown) { |
| 46 | + this.engine = engine; |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +/** The engine's own `actionActivation -> store -> engine` cycle, reproduced. */ |
| 51 | +function makeCyclicEngine(): Record<string, unknown> { |
| 52 | + const engine: Record<string, unknown> = { name: '_ObjectQL' }; |
| 53 | + const store: Record<string, unknown> = { name: 'ObjectStoreActionActivationStore', engine }; |
| 54 | + engine.actionActivation = { name: 'ActionActivationProjection', store }; |
| 55 | + return engine; |
| 56 | +} |
| 57 | + |
| 58 | +function baseManifest(overrides: Record<string, unknown> = {}): any { |
| 59 | + return { |
| 60 | + id: 'com.example.showcase', |
| 61 | + name: 'Showcase', |
| 62 | + namespace: 'showcase', |
| 63 | + version: '1.2.3', |
| 64 | + type: 'app', |
| 65 | + scope: 'user', |
| 66 | + description: 'Kitchen-sink example', |
| 67 | + dependencies: ['com.objectstack.plugin-auth'], |
| 68 | + objects: [{ name: 'invoice', fields: { total: { type: 'currency' } } }], |
| 69 | + apps: [{ name: 'showcase', label: 'Showcase' }], |
| 70 | + ...overrides, |
| 71 | + }; |
| 72 | +} |
| 73 | + |
| 74 | +describe('SchemaRegistry.installPackage — the record is serializable', () => { |
| 75 | + let registry: SchemaRegistry; |
| 76 | + |
| 77 | + beforeEach(() => { |
| 78 | + registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); |
| 79 | + registry.logLevel = 'silent'; |
| 80 | + }); |
| 81 | + |
| 82 | + it('survives a plugin instance that closes a cycle through the engine', () => { |
| 83 | + const plugin = new FakeConnectorPlugin(); |
| 84 | + plugin.init(makeCyclicEngine()); |
| 85 | + registry.installPackage(baseManifest({ plugins: [plugin] })); |
| 86 | + |
| 87 | + // The expression every read door runs. |
| 88 | + expect(() => JSON.stringify(registry.getPackage('com.example.showcase'))).not.toThrow(); |
| 89 | + // The instance itself is gone from the record — this is a projection, not a |
| 90 | + // replacement value that still points at the engine. |
| 91 | + expect(JSON.stringify(registry.getPackage('com.example.showcase'))).not.toContain('_ObjectQL'); |
| 92 | + }); |
| 93 | + |
| 94 | + it('survives a manifest that becomes cyclic only AFTER install', () => { |
| 95 | + // The measured ordering: at install the plugin holds no engine and the |
| 96 | + // manifest serialises fine; the cycle appears when the plugin initialises. |
| 97 | + // A projection taken at install must still hold, because it copied out of |
| 98 | + // the live object rather than aliasing it. |
| 99 | + const plugin = new FakeConnectorPlugin(); |
| 100 | + const manifest = baseManifest({ plugins: [plugin] }); |
| 101 | + expect(() => JSON.stringify(manifest)).not.toThrow(); |
| 102 | + |
| 103 | + registry.installPackage(manifest); |
| 104 | + plugin.init(makeCyclicEngine()); |
| 105 | + |
| 106 | + expect(() => JSON.stringify(registry.getPackage('com.example.showcase'))).not.toThrow(); |
| 107 | + }); |
| 108 | + |
| 109 | + it('survives a reference cycle among PLAIN data in the manifest', () => { |
| 110 | + const cyclic: Record<string, unknown> = { name: 'self' }; |
| 111 | + cyclic.self = cyclic; |
| 112 | + registry.installPackage(baseManifest({ data: { node: cyclic } })); |
| 113 | + |
| 114 | + const record = registry.getPackage('com.example.showcase')!; |
| 115 | + expect(() => JSON.stringify(record)).not.toThrow(); |
| 116 | + // The back-edge is dropped; everything ahead of it survives. |
| 117 | + expect((record.manifest as any).data.node.name).toBe('self'); |
| 118 | + expect((record.manifest as any).data.node.self).toBeUndefined(); |
| 119 | + }); |
| 120 | + |
| 121 | + it('keeps the declarative half of the manifest byte-for-byte', () => { |
| 122 | + const manifest = baseManifest({ plugins: [new FakeConnectorPlugin()] }); |
| 123 | + registry.installPackage(manifest); |
| 124 | + const stored = registry.getPackage('com.example.showcase')!.manifest as any; |
| 125 | + |
| 126 | + for (const key of [ |
| 127 | + 'id', 'name', 'namespace', 'version', 'type', 'scope', 'description', |
| 128 | + 'dependencies', 'objects', 'apps', |
| 129 | + ]) { |
| 130 | + expect(stored[key]).toEqual((manifest as any)[key]); |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + it('drops members that are not data, and keeps a Date as data', () => { |
| 135 | + const publishedAt = new Date('2026-09-02T00:00:00.000Z'); |
| 136 | + registry.installPackage(baseManifest({ |
| 137 | + onEnable: () => undefined, |
| 138 | + registryHandle: new Map([['a', 1]]), |
| 139 | + publishedAt, |
| 140 | + })); |
| 141 | + const stored = registry.getPackage('com.example.showcase')!.manifest as any; |
| 142 | + |
| 143 | + expect(stored.onEnable).toBeUndefined(); |
| 144 | + expect(stored.registryHandle).toBeUndefined(); |
| 145 | + expect(new Date(stored.publishedAt).toISOString()).toBe(publishedAt.toISOString()); |
| 146 | + }); |
| 147 | + |
| 148 | + it('does not mutate the caller’s manifest — the kernel keeps the live object', () => { |
| 149 | + // `ObjectQL.registerApp` reads `manifest.plugins[]` from ITS OWN parameter |
| 150 | + // to register nested plugins, and hands the same object to `installPackage`. |
| 151 | + // Projecting must therefore copy, never strip in place. |
| 152 | + const plugin = new FakeConnectorPlugin(); |
| 153 | + const manifest = baseManifest({ plugins: [plugin] }); |
| 154 | + registry.installPackage(manifest); |
| 155 | + |
| 156 | + expect(manifest.plugins).toHaveLength(1); |
| 157 | + expect(manifest.plugins[0]).toBe(plugin); |
| 158 | + expect(registry.getPackage('com.example.showcase')!.manifest).not.toBe(manifest); |
| 159 | + }); |
| 160 | + |
| 161 | + it('projects on REINSTALL too (rebuild / HMR overwrite)', () => { |
| 162 | + registry.installPackage(baseManifest()); |
| 163 | + const plugin = new FakeConnectorPlugin(); |
| 164 | + plugin.init(makeCyclicEngine()); |
| 165 | + registry.installPackage(baseManifest({ plugins: [plugin] })); |
| 166 | + |
| 167 | + expect(() => JSON.stringify(registry.getPackage('com.example.showcase'))).not.toThrow(); |
| 168 | + }); |
| 169 | + |
| 170 | + it('keeps the whole LIST serializable when one package carries the cycle', () => { |
| 171 | + // The list door's failure mode: one unserializable item took out every |
| 172 | + // caller's whole listing, not just the offending package. |
| 173 | + const plugin = new FakeConnectorPlugin(); |
| 174 | + plugin.init(makeCyclicEngine()); |
| 175 | + registry.installPackage(baseManifest({ plugins: [plugin] })); |
| 176 | + registry.installPackage({ |
| 177 | + id: 'com.objectstack.setup', name: 'Setup', namespace: 'setup', version: '9.3.0', |
| 178 | + } as any); |
| 179 | + |
| 180 | + expect(() => JSON.stringify(registry.getAllPackages())).not.toThrow(); |
| 181 | + expect(registry.getAllPackages()).toHaveLength(2); |
| 182 | + }); |
| 183 | + |
| 184 | + it('still records the namespace and the lifecycle state it always did', () => { |
| 185 | + // The projection must not cost the record its non-manifest half. |
| 186 | + registry.installPackage(baseManifest({ plugins: [new FakeConnectorPlugin()] })); |
| 187 | + const record = registry.getPackage('com.example.showcase')!; |
| 188 | + |
| 189 | + expect(record.status).toBe('installed'); |
| 190 | + expect(record.enabled).toBe(true); |
| 191 | + expect(typeof record.installedAt).toBe('string'); |
| 192 | + expect(registry.getNamespaceOwners('showcase')).toEqual(['com.example.showcase']); |
| 193 | + }); |
| 194 | +}); |
0 commit comments