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/29594-customization-pair-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/metadata-core': patch
'@objectstack/metadata-protocol': patch
'@objectstack/objectql': patch
'@objectstack/service-automation': patch
'@objectstack/service-datasource': patch
---

refactor(metadata-core): the customization pair of a package is looked up through the App that carries it

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

`@objectstack/metadata-core` exports `pairOf(base)`, `isPairOf(pairId, base)` and `appOf(packageId)`: the pair of a package is `customizationPackageId(appOf(base))` (ADR-0146 D3, with the customization unit being the App per ruling record 6691022506). `appOf` is the identity today, so every package is still its own App for customization and every answer is the one `customizationPackageId` / `isCustomizationPairOf` gave before: the same pair ids, the same refusals, the same served overrides, write gates, list and layered reads, uninstall refusal, flow precedence remedy and datasource import landing.

The served `/meta` reads and writes, the ensure-customization door and the package routes answer exactly as before. Code that asks "which pair customizes this package?" should call `pairOf` / `isPairOf` instead of the spec id rule directly; the parse direction (`customizationBasePackageId`, `isCustomizationPackage`) is unchanged.
78 changes: 78 additions & 0 deletions packages/metadata-core/src/customization-pair-lookup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { customizationPackageId, isCustomizationPairOf } from '@objectstack/spec/kernel';
import { appOf, isPairOf, pairOf } from './customization-pair-lookup.js';

/**
* The lookup answers exactly what the pure id rule answered for every package
* shape on `main` — an App, a module its artifact brings, a standalone
* package, a `sys` kernel package, a manifest with no namespace — because
* `appOf` is the identity until the ensure door resolves a member to its App.
* Each row pins the literal pair, so a resolver that moves one package to
* another App reds that row by name. The rows' `type`, `scope` and `namespace`
* are read by nothing today: they are the App resolver's inputs when it lands.
*/
const ROWS: ReadonlyArray<{ shape: string; manifest: { id: string; type?: string; scope?: string; namespace?: string }; pair: string }> = [
{
shape: 'an App package',
manifest: { id: 'app.objectstack.hotcrm', type: 'app', namespace: 'crm' },
pair: 'app.objectstack.hotcrm.custom',
},
{
shape: 'a module an App artifact brings (still its own pair today)',
manifest: { id: 'app.objectstack.hotcrm.service', type: 'module', namespace: 'crm' },
pair: 'app.objectstack.hotcrm.service.custom',
},
{
shape: 'a standalone package',
manifest: { id: 'com.example.showcase', type: 'app', namespace: 'showcase' },
pair: 'com.example.showcase.custom',
},
{
shape: 'a sys kernel package',
manifest: { id: 'com.objectstack.plugin-auth', type: 'plugin', scope: 'system', namespace: 'sys' },
pair: 'com.objectstack.plugin-auth.custom',
},
{
shape: 'a manifest with no namespace',
manifest: { id: 'hotcrm', type: 'app' },
pair: 'hotcrm.custom',
},
];

describe('pairOf — the pair of a package, looked up through its App', () => {
it.each(ROWS)('$shape: answers the old derivation, $pair', ({ manifest, pair }) => {
expect(appOf(manifest.id)).toBe(manifest.id);
expect(pairOf(manifest.id)).toEqual(customizationPackageId(manifest.id));
expect(pairOf(manifest.id)).toEqual({ ok: true, value: pair });
});

it.each([
{ input: undefined, reason: 'base_id_missing' },
{ input: '', reason: 'base_id_missing' },
{ input: 42, reason: 'base_id_missing' },
{ input: 'hotcrm.custom', reason: 'base_is_customization_package' },
{ input: 'Com.Acme', reason: 'base_id_invalid' },
])('refuses $input as the id rule does: $reason', ({ input, reason }) => {
expect(pairOf(input)).toEqual(customizationPackageId(input));
expect(pairOf(input)).toEqual({ ok: false, reason });
});
});

describe('isPairOf — is this THE pair of that package', () => {
it.each(ROWS)('$shape: only $pair is its pair', ({ manifest, pair }) => {
const candidates = [pair, manifest.id, `${pair}.custom`, 'app.objectstack.hotcrm.custom', 'partner.ext', null, undefined];
for (const candidate of candidates) {
expect(isPairOf(candidate, manifest.id)).toBe(isCustomizationPairOf(candidate, manifest.id));
}
expect(isPairOf(pair, manifest.id)).toBe(true);
expect(isPairOf(manifest.id, manifest.id)).toBe(false);
});

it('a pair has no pair, and a missing base has none', () => {
expect(isPairOf('hotcrm.custom.custom', 'hotcrm.custom')).toBe(false);
expect(isPairOf('.custom', '')).toBe(false);
expect(isPairOf('x.custom', undefined)).toBe(false);
});
});
76 changes: 76 additions & 0 deletions packages/metadata-core/src/customization-pair-lookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The customization pair of a package, looked up through the App that carries
* it — the one place every "which pair customizes this package?" question is
* answered.
*
* The customization unit is the App (ruling record 6691022506 on ADR-0146 D3):
* one pair per App, `<app id>.custom`, never one per member package. An App is
* the `type: app` package and the `type: module` / `plugin` packages its
* artifact brings (ADR-0019 D1–D4; ADR-0130 D1/D6: one artifact, one version).
* So the pair of a package is the pair of its App:
*
* pairOf(base) = customizationPackageId(appOf(base))
*
* The id rule itself stays in `@objectstack/spec/kernel`
* (`customizationPackageId`, `<base>.custom`). What lives here is the lookup
* in front of it, so that moving the unit from "the package" to "its App"
* changes {@link appOf} and nothing else. A caller asking for the pair of a
* package calls {@link pairOf} or {@link isPairOf}, ⛔ never
* `customizationPackageId(base)` / `isCustomizationPairOf(pair, base)`
* directly. The parse direction (is this id a pair, and of which base —
* `customizationBasePackageId`, `isCustomizationPackage`) is unaffected and
* stays with the spec helpers.
*
* Today {@link appOf} is the identity: every package is its own App for
* customization, so {@link pairOf} answers exactly what
* `customizationPackageId` answers, refusals included. Resolving a member
* package to the App whose artifact brought it is the ensure door's change
* (ADR-0146 D3 as amended), not this module's.
*
* Why this package: every caller that asks the question —
* `@objectstack/metadata-protocol`, `@objectstack/objectql`,
* `@objectstack/service-automation`, `@objectstack/service-datasource`,
* `@objectstack/runtime` — already declares `@objectstack/metadata-core`, and
* it depends on none of them; `service-datasource` declares neither
* `metadata-protocol` nor `objectql`. The same criterion that sank the write
* dispatch predicates and the code-artifact provenance test here.
*/

import {
customizationPackageId,
type CustomizationDerivation,
type CustomizationPackageIdRefusal,
} from '@objectstack/spec/kernel';

/**
* The App that carries `packageId` for customization: the id whose pair holds
* every customization of `packageId`'s items.
*
* Today the identity — every package is its own App for this purpose. The
* lookup is the seam the App-unit ruling moves; its callers never assume the
* identity.
*/
export function appOf(packageId: string): string {
return packageId;
}

/**
* The paired customization package of `base`: the pair of the App that carries
* it, or the refusal `customizationPackageId` names (`base_id_missing`,
* `base_is_customization_package`, `base_id_invalid`).
*/
export function pairOf(base: unknown): CustomizationDerivation<CustomizationPackageIdRefusal> {
return customizationPackageId(typeof base === 'string' && base.length > 0 ? appOf(base) : base);
}

/**
* Whether `pairId` is THE paired customization package of `base` — the only
* package that declares an override of `base`'s items (ADR-0146, ruling A on
* its amendment card).
*/
export function isPairOf(pairId: unknown, base: unknown): boolean {
const pair = pairOf(base);
return pair.ok && pair.value === pairId;
}
9 changes: 9 additions & 0 deletions packages/metadata-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,12 @@ export * from './flow-cycle.js';
// install door's dry run and the `manifest` service's `planUpgrade`, so the
// preview an installer reads and the comparison a door decides on are one.
export * from './package-upgrade-plan.js';

// [ADR-0146 D3 · ruling record 6691022506] The customization pair of a package,
// looked up through the App that carries it — `pairOf(base)` /
// `isPairOf(pair, base)`. Sunk here because its callers live in
// `@objectstack/metadata-protocol`, `@objectstack/objectql`,
// `@objectstack/service-automation` and `@objectstack/service-datasource`, and
// this is the one package all four declare; the id rule stays in
// `@objectstack/spec/kernel`.
export * from './customization-pair-lookup.js';
4 changes: 2 additions & 2 deletions packages/metadata-protocol/src/protocol-class-head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { storedRowDocument } from './item-lock.js';
import { bumpWriteEpoch } from './meta-overlay-cache.js';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import type { IObjectQLEngine, IPubSub } from '@objectstack/spec/contracts';
import { isCustomizationPairOf } from '@objectstack/spec/kernel';
import { isPairOf } from '@objectstack/metadata-core';
import { overrideWriteRefusalError } from './protocol-packaged-base-refusal.js';
import { OBJECT_EXTENSION_TYPE, registrableObjectExtension } from './protocol-object-extension-item.js';
import type { ObjectExtensionRegistry } from './protocol-object-extension-item.js';
Expand Down Expand Up @@ -188,7 +188,7 @@ export class ProtocolClassHeadFacet {
artifactBacked: boolean,
source: string,): { intent: 'override-artifact' | 'runtime-only'; deriveRestoredBody: (historyBody: unknown) => Promise<unknown> } {
const owner = artifactBacked ? host.packagedArtifactOwner({ type: singularType, name }) : undefined;
const pairRow = owner !== undefined && isCustomizationPairOf(binding, owner);
const pairRow = owner !== undefined && isPairOf(binding, owner);
const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !pairRow ? 'override-artifact' : 'runtime-only';
const writeRestored = host.restoredBodyWriter(singularType, name, binding, source);
const deriveRestoredBody = pairRow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* `@objectstack/spec/kernel` (`customization-package.ts`), ⛔ never a local
* spelling:
*
* - id — {@link customizationPackageId}: `<base>.custom`;
* - id — `customizationPackageId`, asked through {@link pairOf}: `<base>.custom`;
* - namespace — {@link customizationNamespace}: `<base namespace>_custom`, SET
* EXPLICITLY on the manifest so `installPackage`'s id-derived default
* (`deriveNamespaceFromPackageId('hotcrm.custom')` = `custom`) never runs
Expand Down Expand Up @@ -101,8 +101,8 @@ import {
customizationBasePackageId,
customizationDependencyRange,
customizationNamespace,
customizationPackageId,
} from '@objectstack/spec/kernel';
import { pairOf } from '@objectstack/metadata-core';
import type { CustomizationNamespaceRefusal } from '@objectstack/spec/kernel';

/** The HTTP route that serves the door — named by every refusal that points at it. */
Expand Down Expand Up @@ -252,7 +252,7 @@ export async function ensureCustomizationPackage(
}
const baseId = declared.data.basePackageId;

const pair = customizationPackageId(baseId);
const pair = pairOf(baseId);
if (!pair.ok) {
throw refusal(400, pair.reason === 'base_is_customization_package'
? `Package '${baseId}' is itself a paired customization package, and a pair is never paired again: `
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ import { restoreTranslatedEchoes } from './protocol-translated-echo.js';
import type { RestoredTranslatedSlot } from './protocol-translated-echo.js';
import {
DEFAULT_METADATA_TYPE_REGISTRY,
customizationPackageId,
getMetadataCreateSeed,
getMetadataTypeActions,
getMetadataTypeSchema,
} from '@objectstack/spec/kernel';
import { pairOf } from '@objectstack/metadata-core';
import { storedBodyForCarryForward } from './protocol-credential-carry-forward.js';
import type { Equal, MetaTypesListing } from './protocol-meta-types-listing.js';
import {
Expand Down Expand Up @@ -407,7 +407,7 @@ export class ProtocolDiscoveryAndBasesFacet {
*/
static async getPairTranslationBundle(host: Pick<ProtocolHost, 'findEnvironmentRows'>, basePackageId: string): Promise<TranslationBundle | undefined> {
if (typeof basePackageId !== 'string' || basePackageId === '') return undefined;
const pair = customizationPackageId(basePackageId);
const pair = pairOf(basePackageId);
if (!pair.ok) return undefined;
const layer = await readAuthoredTranslationLayer({
find: (table: string, opts: { where?: Record<string, unknown>; [key: string]: unknown } = {}) =>
Expand Down
4 changes: 2 additions & 2 deletions packages/metadata-protocol/src/protocol-environment-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
* not move.
*/

import { customizationPackageId } from '@objectstack/spec/kernel';
import { pairOf } from '@objectstack/metadata-core';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import type { StoredOverlayRow } from './item-lock.js';
import { overlayMemberWhere } from './migrations/overlay-index.js';
Expand Down Expand Up @@ -293,6 +293,6 @@ export function soleShippingPackage(shipped: readonly unknown[]): string | undef
export function baseAddressServesMember(base: string, bound: string | undefined, baseShipsMember: boolean): boolean {
if (bound === undefined || bound === base) return true;
if (baseShipsMember) return false;
const pair = customizationPackageId(base);
const pair = pairOf(base);
return pair.ok && pair.value === bound;
}
6 changes: 3 additions & 3 deletions packages/metadata-protocol/src/protocol-meta-layered-reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ import {
// {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer
// fallback so a hydrated row is never answered as the code layer.
isTenantAuthored,
pairOf,
} from '@objectstack/metadata-core';
import {
type MetadataLock,
type MetadataLockSource,
type MetadataProvenance,
customizationPackageId,
} from '@objectstack/spec/kernel';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import {
Expand Down Expand Up @@ -405,12 +405,12 @@ export async function getMetaItemLayered(host: Pick<ProtocolHost, 'artifactLockL
const owner = boundTo(row);
if (owner === undefined || owner === base) return false;
if (isServedOverrideRowOf(row, base)) return false;
const pair = customizationPackageId(base);
const pair = pairOf(base);
if (pair.ok && pair.value === owner) return true;
return host.resolveWritePackageScope(owner)?.dependencies.includes(base) === true;
})
.sort((a, b) => {
const pair = customizationPackageId(base);
const pair = pairOf(base);
const rank = (row: StoredOverlayRow) => (pair.ok && boundTo(row) === pair.value ? 0 : 1);
return rank(a) - rank(b) || String(boundTo(a)).localeCompare(String(boundTo(b)));
})[0];
Expand Down
9 changes: 4 additions & 5 deletions packages/metadata-protocol/src/protocol-meta-list-reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@
* not move.
*/

import { ITEM_KEY_DISCRIMINATORS, isTenantAuthored } from '@objectstack/metadata-core';
import { customizationPackageId } from '@objectstack/spec/kernel';
import { ITEM_KEY_DISCRIMINATORS, isTenantAuthored, pairOf } from '@objectstack/metadata-core';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import { isAggregatedViewContainer } from '@objectstack/spec/ui';
import {
Expand Down Expand Up @@ -194,7 +193,7 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
// come from the package-agnostic read too. They stand in like the
// package-less rows: a pair's item of a name the package does not
// ship is the pair's own, and never listed in the package's list.
const pairOfScope = servedOverride && packageId ? customizationPackageId(packageId) : undefined;
const pairOfScope = servedOverride && packageId ? pairOf(packageId) : undefined;
const overrideRows = pairOfScope?.ok
? lockRows.filter((row) => row?.package_id === pairOfScope.value)
: [];
Expand Down Expand Up @@ -528,7 +527,7 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
// [#28409] …and the pair's drafts, as stand-ins, as its active
// arm previews the pair's rows: the pending edit of the
// package's declared override previews over its slot.
const pairOfDraftScope = servedOverride && packageId ? customizationPackageId(packageId) : undefined;
const pairOfDraftScope = servedOverride && packageId ? pairOf(packageId) : undefined;
const overrideDraftRecords = pairOfDraftScope?.ok
? packageAgnosticDrafts.filter((record) => record?.package_id === pairOfDraftScope.value)
: [];
Expand Down Expand Up @@ -672,7 +671,7 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
// address serves beside the package's members
// ({@link baseServesBundleMember}), as the env-wide list below does.
if (unseated && servedOverride && packageId && ITEM_KEY_DISCRIMINATORS[request.type] !== undefined) {
const pairOfList = customizationPackageId(packageId);
const pairOfList = pairOf(packageId);
const typeShipping = host.shippingPackagesOf(request.type);
for (const it of items as any[]) {
if (!(it && typeof it === 'object' && unseated.has(it))) continue;
Expand Down
4 changes: 2 additions & 2 deletions packages/metadata-protocol/src/protocol-overlay-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
// are new. `protocol.ts` re-exports what it exported.
import {
itemDiscriminator,
pairOf,
} from '@objectstack/metadata-core';
import { canonicalMetaUrlType } from '@objectstack/spec/shared';
import { customizationPackageId } from '@objectstack/spec/kernel';
import { isServedOverrideRowOf } from './served-override.js';
import {
listSlotNameOf,
Expand Down Expand Up @@ -111,7 +111,7 @@ export function servedOverlayRowCandidates(address: {
}): ServedOverlayRowCandidate[] {
const base = address.packageId;
const pair = address.servedOverride === true && typeof base === 'string' && base !== ''
? customizationPackageId(base)
? pairOf(base)
: undefined;
const places: ReadonlyArray<Pick<ServedOverlayRowCandidate, 'packageId' | 'overrideOf'>> = base === null
? [{ packageId: null }]
Expand Down
Loading
Loading