Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/18877-install-preserves-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

Installing a package no longer reverts an operator's most recent enable/disable. The install contract is now 「缺省 = 保持,有旗 = 设置」: an install that was not asked to move the lifecycle state does not move it (#18877).

`SchemaRegistry.initialDisabledPackageIds` is a boot hydration input — filled once, before any registration, from the durable disable file, and never updated by `enablePackage` / `disablePackage`. It was nevertheless consulted by every `installPackage` call, so once an id was in the boot seed set, every re-install within that boot re-landed it DISABLED whatever the operator had most recently done. Since the durable write started following the row the door returns (#18752), that stopped being memory-only:

```text
boot 1 operator disables the package → disk lists the id
boot 2 seeded from disk; the package installs disabled
PATCH /packages/:id/enable → 200, registry true, disk CLEARED
install(m, { overwrite: true }) (no flag) → the seed still listed the id
→ row disabled, disk written DISABLED
boot 3 the operator's enable is gone, with no error anywhere
```

Reachable with nothing exotic: disable → restart → enable in Studio → an SDK upgrade with `overwrite`.

- **`installPackage` reads the ROW first.** An existing row keeps its own `enabled`, `status` and `statusChangedAt`; the boot seed decides only for an id that has no row yet (boot hydration and a genuinely fresh install). A fresh id the seed never named still lands enabled, the declared default.
- **`enableOnInstall` now sets the state in BOTH directions.** `true` ⇒ `enablePackage`, `false` ⇒ `disablePackage`, and an ABSENT flag makes no lifecycle call at all — previously only `false` was read, and the `true` case was carried by the re-install restamping every row enabled. The bare (unwrapped) body form still honours nothing: no schema declares the key there.
- **`DELETE /packages/:id` clears both records.** The id leaves the boot seed set with its row, and its durable disable entry is cleared, so the next install of that id is a fresh install. Previously the durable record was immortal — a delete left a disable behind that named a package that no longer existed.
- ⚠️ **Behaviour change for a flag-absent re-install of an EXISTING row.** It used to return the package to the declared default (enabled); it now preserves what the row says. An upgrade flow that relied on a re-install to clear a disable must now send `enableOnInstall: true` — the same key, the same door, now honoured in that direction. A fresh install is unaffected.

Maintainer decision batch #157 item 5, letter C. Item 4 of that ruling re-rules the #18058 F1 pin 「flag-absent re-install clears the durable disable」 to 「preserves」; F1b stands unchanged.

Clause-②: no
176 changes: 176 additions & 0 deletions packages/objectql/src/registry-install-preserves-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#18877] `SchemaRegistry.installPackage` — 「缺省 = 保持」, the engine half of
* the install contract ruled in maintainer decision batch #157 item 5, letter C.
*
* ## The rule, in one sentence
*
* An EXISTING row keeps its own `enabled` / `status` / `statusChangedAt`, and
* the boot seed decides only for an id that has no row yet. `installPackage`
* takes no lifecycle argument at all, so at this layer every install is 「缺省」
* — 「有旗」 is spelled one layer up, where `enableOnInstall` reaches
* `enablePackage` / `disablePackage` (`packages/runtime/src/domains/packages.ts`).
*
* ## Why the seed had to stop being consulted every time
*
* `initialDisabledPackageIds` is a BOOT HYDRATION input. It is filled once,
* before any registration, from the durable disable file, and no lifecycle verb
* updates it — so it goes stale the moment an operator enables anything. Read
* on every install, it let the most recent explicit lifecycle action be
* reverted by an unrelated re-install, durably and with no error anywhere
* (`packages/runtime/src/domains/packages-install-preserves-lifecycle.test.ts`
* pins that scenario through the real door, disk and restart included).
*
* Reading the ROW first is what makes the stale answer unreachable: by the time
* an operator can have acted on a package, a row for it exists.
*
* ⛔ These cases deliberately do NOT go through the HTTP door. The door's own
* pins cannot tell 「the registry preserved the row」 from 「the door re-applied
* the same state」, because on every arm the two produce the same three
* records. This file is the only place the registry's own answer is visible.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { SchemaRegistry } from './registry.js';

const manifest = (id: string, namespace?: string) =>
({ id, name: `Pkg ${id}`, version: '1.0.0', ...(namespace ? { namespace } : {}) }) as any;

describe('#18877 — installPackage preserves an existing row\'s lifecycle state', () => {
let registry: SchemaRegistry;

beforeEach(() => {
registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
(registry as any).logLevel = 'silent';
});

it('a re-install carries `enabled` / `status` / `statusChangedAt` over from the existing row', () => {
registry.installPackage(manifest('com.acme.carry'));
const disabled = registry.disablePackage('com.acme.carry');
const stampedAt = disabled?.statusChangedAt;
expect(typeof stampedAt, 'precondition: the disable stamped a time').toBe('string');

const again = registry.installPackage(manifest('com.acme.carry'));

expect(again.enabled, 'the row the registry RETURNED').toBe(false);
expect(again.status).toBe('disabled');
// A re-install is not a lifecycle move, so the stamp is carried, not
// refreshed — otherwise «when did an operator last move this package»
// would answer «at the last upgrade».
expect(again.statusChangedAt).toBe(stampedAt);
expect(registry.getPackage('com.acme.carry')?.enabled).toBe(false);
});

it('⛔ the control leg — a re-install does not disable an ENABLED row either', () => {
// Without this, «preserve» is satisfiable by a registry that simply
// stopped enabling anything.
registry.installPackage(manifest('com.acme.stayon'));
const again = registry.installPackage(manifest('com.acme.stayon'));

expect(again.enabled).toBe(true);
expect(again.status).toBe('installed');
expect(again.statusChangedAt, 'an always-enabled row never carried a stamp').toBeUndefined();
});

it('the boot seed still decides for an id that has NO row yet', () => {
registry.setInitialDisabledPackageIds(['com.acme.seeded']);

const first = registry.installPackage(manifest('com.acme.seeded'));

expect(first.enabled, 'boot hydration must still replay a persisted disable').toBe(false);
expect(first.status).toBe('disabled');
expect(typeof first.statusChangedAt).toBe('string');
});

it('⭐ but the seed cannot outrank a row: enable, then re-install, and the enable stands', () => {
// The card's own defect, at this layer. Before #18877 the second
// install read the seed again and re-landed the package disabled.
registry.setInitialDisabledPackageIds(['com.acme.seeded.enabled']);
registry.installPackage(manifest('com.acme.seeded.enabled'));
registry.enablePackage('com.acme.seeded.enabled');

const again = registry.installPackage(manifest('com.acme.seeded.enabled'));

expect(again.enabled, 'the most recent explicit lifecycle action stands').toBe(true);
expect(again.status).toBe('installed');
expect(registry.getPackage('com.acme.seeded.enabled')?.enabled).toBe(true);
});

it('an id the seed never named still lands at the declared default', () => {
registry.setInitialDisabledPackageIds(['com.acme.someone.else']);
const fresh = registry.installPackage(manifest('com.acme.unseeded'));
expect(fresh.enabled).toBe(true);
expect(fresh.status).toBe('installed');
});

it('the non-lifecycle facets of a re-install are UNCHANGED — manifest and `updatedAt` still move', () => {
// ⛔ The scope leg: 「preserve」 is about lifecycle state only. A
// re-install is still an install, and the row it writes is still the
// new manifest's.
registry.installPackage({ ...manifest('com.acme.scope'), version: '1.0.0' } as any);
registry.disablePackage('com.acme.scope');

const again = registry.installPackage({ ...manifest('com.acme.scope'), version: '2.0.0' } as any);

expect(again.manifest.version, 'the re-install really replaced the manifest').toBe('2.0.0');
expect(again.enabled, 'and did so without touching the lifecycle state').toBe(false);
});
});

describe('#18877 item 3 — uninstallPackage forgets the id from the boot seed', () => {
let registry: SchemaRegistry;

beforeEach(() => {
registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
(registry as any).logLevel = 'silent';
});

it('a package deleted and re-installed in the same boot is a FRESH install', () => {
registry.setInitialDisabledPackageIds(['com.acme.gone']);
expect(registry.installPackage(manifest('com.acme.gone')).enabled, 'precondition: the seed applied').toBe(false);

expect(registry.uninstallPackage('com.acme.gone')).toBe(true);

const reinstalled = registry.installPackage(manifest('com.acme.gone'));
expect(
reinstalled.enabled,
'a row that no longer exists has no lifecycle state — and the seed must not supply one',
).toBe(true);
expect(reinstalled.status).toBe('installed');
});

it('⛔ and it forgets only the id it was given', () => {
registry.setInitialDisabledPackageIds(['com.acme.gone2', 'com.acme.neighbour']);
registry.installPackage(manifest('com.acme.gone2'));
registry.uninstallPackage('com.acme.gone2');

const neighbour = registry.installPackage(manifest('com.acme.neighbour'));
expect(neighbour.enabled, 'the neighbour\'s persisted disable is untouched').toBe(false);
});

it('⛔ a refused uninstall forgets nothing — every mutation stays downstream of the refusal', () => {
// ADR-0029: a package whose object another package `extend`s cannot be
// uninstalled. The seed delete must not be the one step that happens
// anyway, or a refused uninstall would silently re-enable the package
// on its next registration.
registry.setInitialDisabledPackageIds(['com.acme.extended']);
registry.installPackage(manifest('com.acme.extended', 'ext'));
registry.registerObject({ name: 'account', fields: {} } as any, 'com.acme.extended', 'ext', 'own');
registry.registerObject({ name: 'account', fields: {} } as any, 'com.acme.extender', undefined, 'extend');

expect(() => registry.uninstallPackage('com.acme.extended')).toThrow(
/extended by com\.acme\.extender/,
);

// The row survived the refusal — and so did the seed entry, which is
// only observable once the row is gone for real.
expect(registry.getPackage('com.acme.extended'), 'the refused uninstall removed nothing').toBeDefined();
registry.unregisterObjectsByPackage('com.acme.extender');
expect(registry.uninstallPackage('com.acme.extended'), 'now it really goes').toBe(true);
expect(
registry.installPackage(manifest('com.acme.extended', 'ext')).enabled,
'and only THEN is the id forgotten',
).toBe(true);
});
});
81 changes: 69 additions & 12 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1980,19 +1980,28 @@ export class SchemaRegistry {
private appNavContributions = new Map<string, Array<{ packageId?: string; group?: string; priority: number; items: any[] }>>();

/**
* Package ids that must be installed in a DISABLED state. Seeded once at
* boot (from persisted state) BEFORE any package registration so that every
* registration path — boot artifact, marketplace rehydrate, local import —
* honors persisted disable state uniformly without a fragile post-boot
* re-application hook. See {@link setInitialDisabledPackageIds} and
* {@link installPackage}.
* Package ids that must be installed in a DISABLED state **when they have no
* row yet**. Seeded once at boot (from persisted state) BEFORE any package
* registration so that every registration path — boot artifact, marketplace
* rehydrate, local import — honors persisted disable state uniformly without
* a fragile post-boot re-application hook.
*
* ⚠️ [#18877] It is a BOOT HYDRATION input, not a standing verdict: nothing
* updates it after the seed, so once a row exists the row is the truth and
* this set is not consulted. Reading it on every install is what let a
* flag-absent re-install durably revert an operator's later `enable`.
*
* See {@link setInitialDisabledPackageIds}, {@link installPackage} and
* {@link uninstallPackage} (which forgets the id with the row).
*/
private initialDisabledPackageIds = new Set<string>();

/**
* Seed the set of package ids that should be installed disabled. Call this
* before package registration begins; later `installPackage` calls for these
* ids land in the `disabled` state. Replaces any previously seeded set.
* before package registration begins; the FIRST `installPackage` call for
* each of these ids lands it in the `disabled` state, and every later call
* for that id reads the row it created instead (#18877). Replaces any
* previously seeded set.
*/
setInitialDisabledPackageIds(ids: Iterable<string>): void {
this.initialDisabledPackageIds = new Set(ids);
Expand Down Expand Up @@ -4169,18 +4178,56 @@ export class SchemaRegistry {
this.refuseCoOwnedObjectNameCollision(manifest, selfId, coOwners);

const now = new Date().toISOString();
const disabled = this.initialDisabledPackageIds.has(manifest.id);

// [#18877] 「缺省 = 保持,有旗 = 设置」 — an install that was not asked to move
// the lifecycle state does not move it. The install verb carries no
// lifecycle argument at all, so at THIS layer every install is
// 「缺省」 and the whole rule is: an EXISTING row keeps its own
// `enabled` / `status` / `statusChangedAt`, and the boot seed decides only
// for an id that has no row yet. The door above is where 「有旗」 is spelled
// (`enableOnInstall` ⇒ `enablePackage` / `disablePackage`,
// `packages/runtime/src/domains/packages.ts`).
//
// ⚠️ The seed set is a BOOT HYDRATION input, not a standing verdict. It is
// filled once, before any registration, from the durable disable file
// (`AppPlugin.seedPersistedDisabledPackages`) and no lifecycle verb updates
// it. Consulting it on every install is what made the most recent explicit
// operator action revertible by an unrelated re-install: disable → restart
// → `PATCH /packages/:id/enable` (200, registry true, disk cleared) →
// flag-absent `install(m, { overwrite: true })` → the seed still listed the
// id → the row landed disabled again, and since #18752 the disk follows the
// row, so the enable was gone after the next restart with no error
// anywhere. Reading the ROW first is what makes the seed's answer
// unreachable once a row exists — and a row always exists by the time an
// operator can have acted on it.
const existing = this.getPackage(manifest.id);
const seeded = this.initialDisabledPackageIds.has(manifest.id);
const lifecycle: Pick<InstalledPackage, 'status' | 'enabled' | 'statusChangedAt'> = existing
? {
status: existing.status,
enabled: existing.enabled,
// Carried, never restamped: `statusChangedAt` answers "when did an
// operator last move this package", and a re-install is not such a
// move. A row that never carried one still does not.
...(existing.statusChangedAt !== undefined
? { statusChangedAt: existing.statusChangedAt }
: {}),
}
: {
status: seeded ? 'disabled' : 'installed',
enabled: !seeded,
...(seeded ? { statusChangedAt: now } : {}),
};

const pkg: InstalledPackage = {
// The RECORD's manifest, not the caller's live object — see
// {@link toRecordManifest}. Every other read below (`manifest.id`,
// `manifest.namespace`) deliberately keeps reading the ARGUMENT: the
// projection is what the registry hands out, never what it decides with.
manifest: toRecordManifest(manifest),
status: disabled ? 'disabled' : 'installed',
enabled: !disabled,
...lifecycle,
installedAt: now,
updatedAt: now,
...(disabled ? { statusChangedAt: now } : {}),
settings,
};

Expand Down Expand Up @@ -4295,6 +4342,16 @@ export class SchemaRegistry {
// (ADR-0029 extenders): a refused uninstall must remove nothing at all.
this.unregisterItemsByPackage(id);

// [#18877] The boot seed forgets the id together with the row. A package
// that no longer exists has no lifecycle state to preserve, so the next
// install of this id must be a FRESH install — and without this line the
// seed would outlive the row and re-disable it, which is exactly the
// 「听不见的持久回退」 this card removed from the install path. Runs with the
// record removal below rather than at the top: every mutation here is
// downstream of `unregisterObjectsByPackage`, the one step that can REFUSE
// (ADR-0029), so a refused uninstall still removes nothing at all.
this.initialDisabledPackageIds.delete(id);

// Remove package record
const collection = this.metadata.get('package');
if (collection) {
Expand Down
Loading
Loading