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
16 changes: 16 additions & 0 deletions .changeset/32534-function-bare-name-follows-holders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
'@objectstack/runtime': patch
---

fix(objectql, runtime): a function's bare-name record follows its holders (ADR-0150 D4)

Clause-②: yes (widening)
Metadata-protocol: none

The engine's function registry and the registry's one-holder rule each keep a record of who holds a function name, and two of those records outlived the holding:

- **A re-bind takes the old owner's bare-name claim off with the function.** The hook binder records a claim for every function a package's bind brings (`SchemaRegistry#claimBareNameFunctions`), and only `uninstallPackage` released it. When a re-bind of a multi-package artifact (`bindAppArtifactHandlers`) moved a function to another owner, or dropped the package body that held it, the old owner's claim stayed. After the cold-boot seal, a bind of that name by a package of the old owner's namespace was then refused naming a holder that no longer registers it. FROM: the gaining owner's whole group refused at bind (`NAMESPACE_CONFLICT`, logged at error; its hooks never fire and the moved function is held by nobody), and a co-owner's bind of a dropped body's function refused the same way. TO: the group binds and the function is held by its new owner; the co-owner's bind is admitted. The new `SchemaRegistry#releaseBareNameFunctions(owner, stillHolds)` releases the owner's package's claim on each function name it claims that `stillHolds` says it no longer holds, and nothing else; the binder calls it right after the engine's removal and asks the engine's holder set. So a name the package still holds under another owner spelling (its bare id) keeps its claim and is refused as before, and every other holder's claims stay. A function the old owner's `app:<id>` registrations held that the re-bind does not register again leaves with the removal and is released too, whichever way it was bound under that owner: an earlier bind of the artifact that the last bind no longer named (it stayed registered, replaced by name), or a bind under that owner outside the artifact's binder. FROM: its claim stayed, and after the seal a bind of it by a package that meets the old owner was refused naming the old owner. TO: admitted. Before the seal, a released claim is no clash for the seal to report.
- **The last remaining holder answers the bare function read.** `ObjectQL#unregisterFunctionsByPackage` deleted the bare entry of a name only when the leaving package had registered it last, and left the other holders in the holder set. FROM: two packages hold `f`, the last-registered leaves, and `resolveFunction('f')` / `resolveFunctionEntry('f')` answer nothing while `functionHoldersOf('f')` still names the other package. TO: when exactly one holder of the name remains, the bare read answers that holder's entry, a package's or the platform's (owner-less) registration. Two or more remaining holders get no pick: two packages' name stays refused (409 naming both). The platform beside one package is left as it is today: after such a leave it answers nothing. That is today's behaviour, not the rule: ADR-0150 D4 refuses a caller with no package when the platform and a package both hold the name, and #32907 aligns the bare read with it.

Unchanged: the first-registered holder leaving, a one-holder name leaving (nothing answers), an owner-less registration (it never leaves with a package), and every reader that resolves by package (`functionHoldersOf`: the flow `script` and screen nodes, the hook binder). Nothing to migrate.
6 changes: 6 additions & 0 deletions packages/objectql/src/engine-hooks-and-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,12 @@ export class HooksAndFunctionsFacet {
for (const [name, holders] of this.functionHolders) {
for (const [holder, entry] of holders) if (entry.packageId === packageId) holders.delete(holder);
if (holders.size === 0) this.functionHolders.delete(name);
// [#32534] The bare entry left with the last registration while ONE
// holder of the name remains: that holder's entry is the bare answer, as
// a one-holder name's always is. Two or more remaining holders get no
// pick (the bare read of two packages' name stays refused, naming them),
// and an owner-less registration never leaves with a package.
else if (holders.size === 1 && !this.functions.has(name)) this.functions.set(name, [...holders.values()][0]);
}
if (removed > 0) {
this.logger.debug('Unregistered functions by package', { packageId, removed });
Expand Down
7 changes: 6 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,12 @@ export class ObjectQL implements IObjectQLEngine {
return HooksAndFunctionsFacet.functionHoldersOf.call(hooksAndFunctionsHost.of(this), name);
}

/** Remove all functions registered under a given `packageId`. */
/**
* Remove all functions registered under a given `packageId`, from the bare
* map and the holder set ({@link functionHoldersOf}); answers how many bare
* entries left. [#32534] When the last registration of a name leaves and
* exactly one holder of it remains, the bare read answers that holder.
*/
unregisterFunctionsByPackage(packageId: string): number {
// [#29986 codemod] The body moved to `engine-hooks-and-functions.ts` (see `HooksAndFunctionsHost`).
return HooksAndFunctionsFacet.unregisterFunctionsByPackage.call(hooksAndFunctionsHost.of(this), packageId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,103 @@ describe('function — the engine function registry, through the hook binder\'s
expect(await caught(() => bindFunctions(ql, 'com.acme.beta', ['shared_fn']))).toBeUndefined();
});

// [#32534] The release the runtime's re-bind makes when a function leaves its
// owner (moved to another owner, or with a dropped package body): after the
// engine's removal, on every name the owner's package no longer holds there.
/** Whether the engine still holds `name` for `packageId`: the question the runtime's release asks. */
const heldBy = (ql: ObjectQL, packageId: string) => (name: string) => ql.functionHoldersOf(name).packageItem(packageId) !== undefined;

it('[#32534] after the seal, releasing a package\'s claims on the names it no longer holds lets a package of its namespace bind them; a name it still holds and the other holders\' claims stay (base: no release, the bind refused naming the old owner)', async () => {
const { ql, registry, register } = await boot();
expect(await register(pkg('com.acme.alpha'))).toBeUndefined();
expect(await register(rival('function', 'com.acme.beta'))).toBeUndefined();
expect(await register(rival('function', 'com.acme.gamma'))).toBeUndefined();
bindFunctions(ql, 'com.acme.alpha', ['moving_fn', 'kept_fn']);
bindFunctions(ql, 'com.acme.gamma', ['gamma_fn']);
// The re-bind's removal, then what alpha keeps registered again (`releaseMovedFunctions`).
ql.unregisterFunctionsByPackage('app:com.acme.alpha');
ql.registerFunction('kept_fn', fn('alpha'), { packageId: 'app:com.acme.alpha' });

registry.releaseBareNameFunctions('app:com.acme.alpha', heldBy(ql, 'com.acme.alpha'));

expect(await caught(() => bindFunctions(ql, 'com.acme.beta', ['moving_fn']))).toBeUndefined();
expect(ql.functionHoldersOf('moving_fn').packageHolders).toEqual(['com.acme.beta']);
// CONTROLs — the name alpha still holds, and another package's claim, still refuse.
expectRefusal(await caught(() => bindFunctions(ql, 'com.acme.beta', ['kept_fn'])), 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' });
expectRefusal(await caught(() => bindFunctions(ql, 'com.acme.beta', ['gamma_fn'])), 'com.acme.beta', { kind: 'package', packageId: 'com.acme.gamma' });
// CONTROL — an owner-less release, or one whose package still holds every name, releases nothing.
registry.releaseBareNameFunctions(undefined, () => false);
registry.releaseBareNameFunctions('app:com.acme.alpha', () => true);
expectRefusal(await caught(() => bindFunctions(ql, 'com.acme.beta', ['kept_fn'])), 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' });
});

it('[#32534] CONTROL — a name the package still holds under another owner spelling (its bare id) keeps its claim after the removal of its `app:` registrations, so its co-owner is still refused naming it, as on main', async () => {
const { ql, registry, register } = await boot();
expect(await register(pkg('com.acme.alpha'))).toBeUndefined();
expect(await register(rival('function', 'com.acme.beta'))).toBeUndefined();
bindFunctions(ql, 'com.acme.alpha', ['moving_fn']);
// A host's own registration of the function for alpha, under the package's bare id.
ql.registerFunction('moving_fn', fn('host'), { packageId: 'com.acme.alpha' });
ql.unregisterFunctionsByPackage('app:com.acme.alpha');
expect(ql.functionHoldersOf('moving_fn').packageHolders, 'precondition: the removal by owner string left it').toEqual(['com.acme.alpha']);

registry.releaseBareNameFunctions('app:com.acme.alpha', heldBy(ql, 'com.acme.alpha'));

expectRefusal(await caught(() => bindFunctions(ql, 'com.acme.beta', ['moving_fn'])), 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' });
expect(ql.functionHoldersOf('moving_fn').packageHolders).toEqual(['com.acme.alpha']);
});

it('[#32534] a name an earlier bind claimed, removed with the owner\'s registrations, is released with the rest, not only the last bind\'s names', async () => {
const { ql, registry, register } = await boot();
expect(await register(pkg('com.acme.alpha'))).toBeUndefined();
expect(await register(rival('function', 'com.acme.beta'))).toBeUndefined();
bindFunctions(ql, 'com.acme.alpha', ['old_fn']);
bindFunctions(ql, 'com.acme.alpha', ['new_fn']);
ql.unregisterFunctionsByPackage('app:com.acme.alpha');

registry.releaseBareNameFunctions('app:com.acme.alpha', heldBy(ql, 'com.acme.alpha'));

expect(await caught(() => bindFunctions(ql, 'com.acme.beta', ['old_fn', 'new_fn']))).toBeUndefined();
expect(ql.functionHoldersOf('old_fn').packageHolders).toEqual(['com.acme.beta']);
});

it('[#32534] CONTROL — two namespaces\' packages claim one name after the seal; releasing one owner\'s claim leaves the other\'s, so a package of the remaining holder\'s namespace is still refused naming it (ABLATION: a release that drops the name\'s whole claimant set admits it)', async () => {
const { ql, registry, register } = await boot();
expect(await register(pkg('com.acme.alpha'))).toBeUndefined();
expect(await register(pkg('com.acme.beta'))).toBeUndefined();
expect(await register(coOwner('com.acme.alpha', 'com.acme.alpha2'))).toBeUndefined();
expect(await register(coOwner('com.acme.beta', 'com.acme.beta2'))).toBeUndefined();
// Two namespaces: the function's rule meets neither, so both claim the one name.
bindFunctions(ql, 'com.acme.alpha', ['shared_fn']);
bindFunctions(ql, 'com.acme.beta', ['shared_fn']);
ql.unregisterFunctionsByPackage('app:com.acme.alpha');

registry.releaseBareNameFunctions('app:com.acme.alpha', heldBy(ql, 'com.acme.alpha'));

// beta's claim on the same name stays: beta's co-owner is refused naming beta.
expectRefusal(await caught(() => bindFunctions(ql, 'com.acme.beta2', ['shared_fn'])), 'com.acme.beta2', { kind: 'package', packageId: 'com.acme.beta' });
expect(ql.functionHoldersOf('shared_fn').packageHolders).toEqual(['com.acme.beta']);
// alpha's claim is gone: alpha's co-owner binds the name.
expect(await caught(() => bindFunctions(ql, 'com.acme.alpha2', ['shared_fn']))).toBeUndefined();
});

it('[#32534] a claim released before the seal is no clash for the cold boot to report (CONTROL: the ABLATION above, with no release, reports it)', async () => {
const registry = new SchemaRegistry();
registry.logLevel = 'silent';
registry.claimBareNameFunctions('app:com.acme.alpha', ['shared_fn', 'alpha_fn']);
// alpha no longer holds `shared_fn`; it still holds `alpha_fn`.
registry.releaseBareNameFunctions('app:com.acme.alpha', (name) => name !== 'shared_fn');
registry.claimBareNameFunctions('app:com.acme.beta', ['shared_fn']);
expect(registry.sealBareNameHolders()).toEqual([]);
// CONTROL — after the seal, each name refuses its one remaining holder's rival: alpha keeps `alpha_fn`.
const err = await caught(() => registry.claimBareNameFunctions('app:com.acme.gamma', ['shared_fn', 'alpha_fn']));
expect(err?.code).toBe(NAMESPACE_CONFLICT_CODE);
expect(err!.conflicts!.map((c) => `${c.name}: ${(c.existingHolder as Holder).packageId}`)).toEqual([
'shared_fn: com.acme.beta',
'alpha_fn: com.acme.alpha',
]);
});

describe('the cold boot of a deployment whose packages already bind one function name', () => {
// [#31700] Two packages of one namespace: the function has narrowed to it.
const atBoot = () => [pkg('com.acme.alpha'), rival('function', 'com.acme.beta')];
Expand Down Expand Up @@ -705,3 +802,98 @@ describe('the refusal\'s reason names the consumers of the types it lists', () =
}
});
});

// [#32534, ADR-0150 D4] The engine keeps two function maps: the bare map (one
// entry per name, the last registration) and the holder set
// (`functionHoldersOf`). When the last registration leaves and ONE holder of
// the name remains, the bare read answers that holder; two remaining holders
// get no pick.
describe('[#32534] the bare engine function read after a holder leaves', () => {
/** Two namespaces' packages each bind `shared_fn`, alpha first, after the seal. */
const twoNamespaces = async (extra: string[] = []) => {
const booted = await boot();
expect(await booted.register(pkg('com.acme.alpha'))).toBeUndefined();
expect(await booted.register(pkg('com.acme.beta'))).toBeUndefined();
for (const id of extra) expect(await booted.register(pkg(id))).toBeUndefined();
bindFunctions(booted.ql, 'com.acme.alpha', ['shared_fn']);
bindFunctions(booted.ql, 'com.acme.beta', ['shared_fn']);
return booted;
};

it('two package holders, the last-registered leaves: the remaining holder answers (FROM nothing TO its entry)', async () => {
const { ql } = await twoNamespaces();
const alphas = ql.functionHoldersOf('shared_fn').packageItem('com.acme.alpha');
expect((await caught(() => ql.resolveFunctionEntry('shared_fn')))?.status, 'precondition: two holders, refused').toBe(409);

expect(ql.unregisterFunctionsByPackage('app:com.acme.beta')).toBe(1);

expect(ql.functionHoldersOf('shared_fn').packageHolders).toEqual(['com.acme.alpha']);
expect(ql.resolveFunctionEntry('shared_fn')).toBe(alphas);
expect(ql.resolveFunction('shared_fn')).toBe(alphas!.handler);
// …and when it leaves too, nothing (CONTROL: no stale entry stays behind).
expect(ql.unregisterFunctionsByPackage('app:com.acme.alpha')).toBe(1);
expect(ql.resolveFunctionEntry('shared_fn')).toBeUndefined();
});

it('CONTROL — the first-registered leaves: the last-registered answers, unchanged', async () => {
const { ql } = await twoNamespaces();
expect(ql.unregisterFunctionsByPackage('app:com.acme.alpha')).toBe(0);
expect(ql.resolveFunctionEntry('shared_fn')?.packageId).toBe('app:com.acme.beta');
});

it('CONTROL — two package holders remain: the bare read stays refused, naming both, unchanged', async () => {
const { ql } = await twoNamespaces(['com.acme.gamma']);
bindFunctions(ql, 'com.acme.gamma', ['shared_fn']);
ql.unregisterFunctionsByPackage('app:com.acme.gamma');
const err = await caught(() => ql.resolveFunctionEntry('shared_fn'));
expect(err?.name).toBe('PackageScopedNameRefusedError');
expect(err?.status).toBe(409);
expect(err!.message).toContain('package "com.acme.alpha"');
expect(err!.message).toContain('package "com.acme.beta"');
});

it('CONTROL — the one holder of a name leaves: nothing answers, unchanged', async () => {
const { ql, register } = await boot();
expect(await register(pkg('com.acme.alpha'))).toBeUndefined();
bindFunctions(ql, 'com.acme.alpha', ['alpha_fn']);
expect(ql.unregisterFunctionsByPackage('app:com.acme.alpha')).toBe(1);
expect(ql.resolveFunctionEntry('alpha_fn')).toBeUndefined();
expect(ql.functionHoldersOf('alpha_fn').packageHolders).toEqual([]);
});

it('the platform\'s (owner-less) registration under a package\'s, the package leaves: the platform, the one holder left, answers (FROM nothing TO its entry)', async () => {
const { ql } = await boot();
const platform = fn('platform');
ql.registerFunction('platform_fn', platform);
ql.registerFunction('platform_fn', fn('beta'), { packageId: 'app:com.acme.beta' });
ql.unregisterFunctionsByPackage('app:com.acme.beta');
expect(ql.resolveFunction('platform_fn')).toBe(platform);
expect(ql.resolveFunctionEntry('platform_fn')?.packageId).toBeUndefined();
});

it('CONTROL — an owner-less registration over a package\'s never leaves with the package and still answers, unchanged', async () => {
const { ql } = await boot();
const platform = fn('platform');
ql.registerFunction('platform_fn', fn('alpha'), { packageId: 'app:com.acme.alpha' });
ql.registerFunction('platform_fn', platform);
expect(ql.unregisterFunctionsByPackage('app:com.acme.alpha')).toBe(0);
expect(ql.resolveFunction('platform_fn')).toBe(platform);
});

// CURRENT BEHAVIOUR, see #32907 — not the rule. ADR-0150 D4 refuses a caller
// with no package `ambiguous-in-environment` when the platform and a package
// both hold a name (a 409 naming each); the bare read counts package holders
// only (epic seat ruling R2-C), so this pair answers nothing here. #32907
// aligns the bare read with D4 and flips this pin on purpose. What it pins
// for #32534: the restore picks no holder of two.
it('CONTROL (current behaviour, see #32907) — the platform and one package remain after the last-registered package leaves: two holders, so the restore picks nothing', async () => {
const { ql } = await boot();
ql.registerFunction('platform_fn', fn('platform'));
ql.registerFunction('platform_fn', fn('alpha'), { packageId: 'app:com.acme.alpha' });
ql.registerFunction('platform_fn', fn('beta'), { packageId: 'app:com.acme.beta' });
ql.unregisterFunctionsByPackage('app:com.acme.beta');
expect(ql.functionHoldersOf('platform_fn').packageHolders).toEqual(['com.acme.alpha']);
expect(ql.functionHoldersOf('platform_fn').platformItem?.()).toBeDefined();
expect(ql.resolveFunctionEntry('platform_fn')).toBeUndefined();
});
});
Loading
Loading