Skip to content

Commit 9ed2ee1

Browse files
refactor(metadata-core): look up a package's customization pair through the App that carries it (pairOf), behaviour unchanged (#31026)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoGiPU3nfGMxJdPY5ydbkoUG)_ Refs #29594 (PR 1 of 5–7: the lookup refactor only; the ensure door returning the App's pair, the `customizable` verdict, the `sys_` pass-through close and the platform pair follow in later PRs) Clause-②: yes (widening) Metadata-protocol: none Ruling-ref: 6691022506 ## 维护者速读 - 改了什么:「这个包的定制包是哪个」这个问题现在只有一个答案入口 `pairOf(base)`(在 `@objectstack/metadata-core`),它先问「这个包属于哪个 App」(`appOf`),再按 App 推出 `<id>.custom`。原来散在 metadata-protocol、objectql、两个 service 里的 20 处直接推导和 3 处 `isCustomizationPairOf` 判定都改走它。 - 为什么改:裁决 ① 定制单位 = App(HotCRM 四个包只有一个 `app.objectstack.hotcrm.custom`)。下一个 PR 只要改 `appOf` 一处,所有读写门、列表、卸载拒绝就一起按 App 找定制包,不会有漏改的地方各说各话。 - 风险与回滚:本 PR 不改任何行为:`appOf` 现在原样返回包自己,所以每处答案、拒绝原因、定制包 id 都和今天一模一样;`runtime` 的两处留给下一个 PR。回滚即 revert 本 PR。 - 先看:`packages/metadata-core/src/customization-pair-lookup.ts`(整个查找就 3 个函数);`packages/metadata-protocol/src/protocol-packaged-base-refusal.ts`(改动最多的一处,5 个调用点 + 1 处判定);`packages/metadata-protocol/src/served-override.ts`(被五个读路径共用的判定)。 - 两分钟验证:`pnpm --filter @objectstack/metadata-core exec vitest run --maxWorkers=2 src/customization-pair-lookup.test.ts`,预期 16 个用例全绿(App、模块、独立包、`sys` 内核包、无命名空间的包都与旧推导相同)。 - 需要你做的:无,席位落地。 ## What changes **The lookup (`packages/metadata-core/src/customization-pair-lookup.ts`, exported from `@objectstack/metadata-core`).** - `appOf(packageId)`: the App that carries a package for customization. Today the identity: every package is its own App, which is what every call site assumed. - `pairOf(base)` = `customizationPackageId(appOf(base))`: the pair, or the same refusal the id rule names (`base_id_missing`, `base_is_customization_package`, `base_id_invalid`). - `isPairOf(pairId, base)`: whether `pairId` is THE pair of `base`, the predicate `isCustomizationPairOf` answered. The id rule itself stays in `@objectstack/spec/kernel`; `packages/spec` is untouched. **Why metadata-core and not metadata-protocol or objectql:** `service-datasource` (a class (i) site) declares neither of those, only `metadata-core`, and `metadata-core` is the one package every class (i) caller already declares (metadata-protocol, objectql, service-automation, service-datasource, runtime). No new dependency edge, no lockfile change. That is the criterion already used for the write dispatch predicates and the code-artifact provenance test in that package (claim amendment 6691222368). **The call sites.** Each class (i) site now calls `pairOf` / `isPairOf` where it called `customizationPackageId(base)` / `isCustomizationPairOf(pair, base)`. Nothing else in those functions moved: same branches, same sentences, same refusal codes. **Census** of `git grep -n customizationPackageId origin/main -- 'packages/**/*.ts' ':!*.test.ts'` at `69e133ea`: 53 hits in 17 files. (i) = "the pair of this package" derivation, routed through the lookup. (ii) = the definition, a parse ("is this id a pair"), an import, a doc mention or a field name; stays. | File | Hits | (i) routed | (i) deferred | (ii) stays | |:--|--:|--:|--:|:--| | `lint/src/validate-customization-namespace.ts` | 3 | 0 | 0 | 3: import, doc, L108 is-a-pair skip (the `base_is_customization_package` reason on the manifest's own id, the same parse as `isCustomizationPackage(manifest.id)`) | | `metadata-protocol/src/protocol-customization-package.ts` | 3 | 1 (L255, ensure door's pair id) | 0 | 2: import (removed), doc (reworded) | | `metadata-protocol/src/protocol-discovery-and-bases.ts` | 2 | 1 (L330, pair translation bundle) | 0 | 1: import | | `metadata-protocol/src/protocol-environment-rows.ts` | 2 | 1 (L296, `baseAddressServesMember`) | 0 | 1: import | | `metadata-protocol/src/protocol-meta-layered-reads.ts` | 3 | 2 (L408, L413) | 0 | 1: import | | `metadata-protocol/src/protocol-meta-list-reads.ts` | 4 | 3 (L197, L531, L675) | 0 | 1: import | | `metadata-protocol/src/protocol-overlay-merge.ts` | 2 | 1 (L114) | 0 | 1: import | | `metadata-protocol/src/protocol-packaged-base-refusal.ts` | 7 | 5 (L197, L217, L419, L684, L1075) | 0 | 2: import, doc | | `metadata-protocol/src/protocol-packageless-overlay-migration.ts` | 3 | 1 (L383) | 0 | 2: import, doc | | `metadata-protocol/src/protocol-served-overrides.ts` | 2 | 1 (L98) | 0 | 1: import | | `metadata-protocol/src/protocol-write-gates.ts` | 2 | 1 (L330, `pairOfAddedMember`) | 0 | 1: import | | `objectql/src/registry.ts` | 6 | 1 (L7311, uninstall refusal) | 0 | 5: import, 2 docs, `PackageUninstallExtendedError.customizationPackageId` field declaration + assignment (a public property name, not a derivation) | | `runtime/src/domains/packages.ts` | 4 | 0 | 2 (L669 `customizeInPairRemedy`, L3628 package customizations read) | 2: import, doc | | `services/service-automation/src/flow-precedence.ts` | 2 | 1 (L259, `undeclaredRemedy`) | 0 | 1: import | | `services/service-datasource/src/plugin.ts` | 2 | 1 (L372, import landing) | 0 | 1: import | | `spec/src/kernel/customization-package.ts` | 5 | 0 | 0 | 5: the definition and its own inverse/predicate | | `spec/src/api/package-lifecycle.zod.ts` | 1 | 0 | 0 | 1: doc | | **Total** | **53** | **20** | **2** | **31** | Class (i) is 22 (the ruling estimated about 20): 20 routed here, 2 in `packages/runtime/src/domains/packages.ts` left for PR 2 by the order, because #30756 (same seat) is editing that file. Plus 3 `isCustomizationPairOf(pair, base)` sites, the same derivation through a second spec helper, routed to `isPairOf` (claim amendment 6691222368): `metadata-protocol/src/served-override.ts:88` (`isServedOverrideRowOf`, the predicate five reads share), `protocol-class-head.ts:190` (restore intent), `protocol-packaged-base-refusal.ts:1076` (declared-override gate, which already reads `pairOf` two lines up). After this PR, `git grep -n "customizationPackageId(\|isCustomizationPairOf(" -- 'packages/**/*.ts' ':!*.test.ts' ':!packages/spec/**'` answers 4 lines: the lookup itself, lint's is-a-pair skip, and the 2 deferred runtime sites. **Not in this PR:** the ensure door resolving a member to its App and answering the App's pair (200), the `customizable` verdict, the `sys_` pass-through in `validateObjectNamespacePrefix`, the platform pair, the ADR-0146 D3 amendment (#31014, spec seat). Kernel objects stay not customizable: ③ is ruled (6691217245), the kernel whitelist (Users, Organizations) is a later version's work. ## Tests New `packages/metadata-core/src/customization-pair-lookup.test.ts` (16 cases): for an App package (`app.objectstack.hotcrm`), a module its artifact brings (`app.objectstack.hotcrm.service`, still its own pair today), a standalone package, a `sys` kernel package (`com.objectstack.plugin-auth`, scope `system`) and a manifest with no namespace, `appOf` is the identity and `pairOf` equals `customizationPackageId` and the literal pair; the five refusals match the id rule; `isPairOf` equals `isCustomizationPairOf` over seven candidates per row. The existing suites of every touched package are the behaviour pin: they assert the served answers (pair ids in refusal sentences, served override rows, list and layered reads, uninstall refusal, flow precedence remedy, datasource import landing) and none of them changed. <details><summary>Ablation (appOf moved off the identity, twice)</summary> Both mutations through `node scripts/ablation-replace.mjs` on the line ` return packageId;` of `customization-pair-lookup.ts`, each restored and proved (`blob == HEAD bd176c7`, `git diff HEAD` empty, whole-tree `git status --porcelain` empty). 1. Source, the unit table: `return packageId === 'app.objectstack.hotcrm.service' ? 'app.objectstack.hotcrm' : packageId;` (the HotCRM module resolved to its App). `Tests 2 failed | 14 passed (16)`: the module row's `appOf` (`Expected: "app.objectstack.hotcrm.service"`, `Received: "app.objectstack.hotcrm"`) and its `isPairOf` (`expected false to be true`). The other rows stay green. 2. Through `dist/`, the consumers: `return packageId === 'com.acme.crm' ? 'com.acme.suite' : packageId;`, `pnpm --filter @objectstack/metadata-core build`, `ablation-dist-preflight.mjs @objectstack/metadata-core '"com.acme.suite"' --source-marker=…` exit 0 (marker in 2 built files). `metadata-protocol` `served-override.test.ts`, `protocol.pair-translation-bundle.test.ts`, `protocol.named-base-overlay-write.test.ts`, `protocol.packaged-base-refusal.test.ts`: **48 failed** (23 + 22 + 1 + 2), for example `Expected: "'com.acme.crm.custom' (?package=com.acme.crm.custom)"`, `Received: "… save it into 'com.acme.suite.custom' (?package=com.acme.suite.custom) …"`. So the write gates, the served-override predicate and the pair translation read ask the lookup through the published build. Restore, rebuild, preflight `--absent` exit 0 (absent from all 12 built files), tree clean. </details> ## Verification <details><summary>Suites, typechecks, lint</summary> Before the merge of `origin/main` (head `33e47fa35`): - `pnpm --filter @objectstack/metadata-core test`: Test Files 25 passed, Tests 944 passed - `pnpm --filter @objectstack/lint test`: Test Files 196 passed, Tests 8275 passed - `pnpm --filter @objectstack/service-datasource test`: Test Files 72 passed, Tests 1239 passed - `pnpm --filter @objectstack/service-automation test`: Test Files 264 passed, Tests 3431 passed - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 320 passed, 4 skipped; Tests 22487 passed, 26 skipped - `pnpm --filter @objectstack/objectql test`: Test Files 560 passed, Tests 11207 passed - `typecheck` on metadata-core, metadata-protocol, objectql, service-automation, service-datasource: exit 0 each - `pnpm exec eslint --no-inline-config` over the 18 changed `.ts` files: exit 0 - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: exit 0 (22 anchors judged, 0 lost) After merging `origin/main` at `33bc8ae5f` (one conflict, the spec import line of `protocol-write-gates.ts`: main added `DEFAULT_METADATA_TYPE_REGISTRY` beside `customizationPackageId`; resolved to `DEFAULT_METADATA_TYPE_REGISTRY` alone, the body already read `pairOf`), `pnpm install --frozen-lockfile` + closure rebuild, head `979d839e4`: - `pnpm --filter @objectstack/metadata-core test`: Test Files 25 passed, Tests 944 passed - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 320 passed, 4 skipped; Tests 22489 passed, 26 skipped - `pnpm --filter @objectstack/objectql test`: Test Files 560 passed, Tests 11207 passed - `pnpm --filter @objectstack/service-automation test`: Test Files 264 passed, Tests 3431 passed - `pnpm --filter @objectstack/service-datasource test`: Test Files 72 passed, Tests 1279 passed - `typecheck` on the same five: exit 0 each - census re-run on the merged tree: no new `customizationPackageId(` / `isCustomizationPairOf(` call site arrived with main </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` against this diff, every line run with stdin closed, exit codes recorded before any pipe: record `/mnt/project-files/server-seat-2/r2026-11-06/out/29594-gates-ran-33e47fa35.list`. `--ran` reconciliation: **65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN**; every run family exit 0. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET (it reads every package's `dist/`; this worktree built the closure of the touched packages, not the whole workspace). CI's `Lint & Repo Gates` runs it on a full build. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first answered exit 3 on the shallow clone (pinned fixture commit `621a4876` unreachable); after `git fetch origin 621a487…` it is exit 0 (48 cases). </details> ## Acceptance notes - **Changeset:** `Clause-②: yes (widening)` (the contract review's reading: three new names on the published `@objectstack/metadata-core` entry), patch on `@objectstack/metadata-core` (three new exports), `metadata-protocol`, `objectql`, `service-automation`, `service-datasource` (their dist now imports the lookup). No served answer changes, so `Metadata-protocol: none`. - **Deferred to PR 2, named here so it is not lost:** `packages/runtime/src/domains/packages.ts` L669 and L3628 (now L672 / L3737 after the merge). The parse direction (`customizationBasePackageId` in cli `package/install.ts`, cloud-connection `marketplace-install-local-plugin.ts`, runtime, metadata-protocol) is class (ii) and stays: it maps a pair to its base, which under the App unit is the App. - **Outside this census (`packages/**` only), for PR 2's rehearsal and the `domain:ui` half:** six client-side derivations in `ui/packages/app-shell` call `customizationPackageId` from the spec directly: `views/metadata-admin/ResourceListPage.tsx:211`, `views/studio-design/AutomationsFlowRail.tsx:367`, `BuilderLanding.tsx:163`, `CustomizationMark.tsx:143`, `customizationMarks.ts:58`, `packages-io.ts:239`. They answer the same as the server today; once `appOf` resolves a module to its App they would still derive the module's own pair (for example `app.objectstack.hotcrm.service.custom`) until the console reads the served pair (ruling 6691022506 routes that to `domain:ui`, after #29591). - **For PR 2:** the module row of the new unit table (`app.objectstack.hotcrm.service` → `app.objectstack.hotcrm.service.custom`) pins today's answer on purpose; it is the row the App resolver is meant to flip. - `PackageUninstallExtendedError.customizationPackageId` keeps its name: it is a public error property, and renaming it is not part of a no-behaviour-change refactor. - GitHub writes: this PR through the fleet-write relay `pr_create`; the harness's assignee/reviewer step is skipped per the dispatch frame (the seat assigns at hand-over). Head after the review fixes: `678d6d6e` (changeset declaration + one test docblock line). <!-- signed: domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9052337 commit 9ed2ee1

19 files changed

Lines changed: 236 additions & 53 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/service-automation': patch
6+
'@objectstack/service-datasource': patch
7+
---
8+
9+
refactor(metadata-core): the customization pair of a package is looked up through the App that carries it
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: none
13+
14+
`@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.
15+
16+
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.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { customizationPackageId, isCustomizationPairOf } from '@objectstack/spec/kernel';
5+
import { appOf, isPairOf, pairOf } from './customization-pair-lookup.js';
6+
7+
/**
8+
* The lookup answers exactly what the pure id rule answered for every package
9+
* shape on `main` — an App, a module its artifact brings, a standalone
10+
* package, a `sys` kernel package, a manifest with no namespace — because
11+
* `appOf` is the identity until the ensure door resolves a member to its App.
12+
* Each row pins the literal pair, so a resolver that moves one package to
13+
* another App reds that row by name. The rows' `type`, `scope` and `namespace`
14+
* are read by nothing today: they are the App resolver's inputs when it lands.
15+
*/
16+
const ROWS: ReadonlyArray<{ shape: string; manifest: { id: string; type?: string; scope?: string; namespace?: string }; pair: string }> = [
17+
{
18+
shape: 'an App package',
19+
manifest: { id: 'app.objectstack.hotcrm', type: 'app', namespace: 'crm' },
20+
pair: 'app.objectstack.hotcrm.custom',
21+
},
22+
{
23+
shape: 'a module an App artifact brings (still its own pair today)',
24+
manifest: { id: 'app.objectstack.hotcrm.service', type: 'module', namespace: 'crm' },
25+
pair: 'app.objectstack.hotcrm.service.custom',
26+
},
27+
{
28+
shape: 'a standalone package',
29+
manifest: { id: 'com.example.showcase', type: 'app', namespace: 'showcase' },
30+
pair: 'com.example.showcase.custom',
31+
},
32+
{
33+
shape: 'a sys kernel package',
34+
manifest: { id: 'com.objectstack.plugin-auth', type: 'plugin', scope: 'system', namespace: 'sys' },
35+
pair: 'com.objectstack.plugin-auth.custom',
36+
},
37+
{
38+
shape: 'a manifest with no namespace',
39+
manifest: { id: 'hotcrm', type: 'app' },
40+
pair: 'hotcrm.custom',
41+
},
42+
];
43+
44+
describe('pairOf — the pair of a package, looked up through its App', () => {
45+
it.each(ROWS)('$shape: answers the old derivation, $pair', ({ manifest, pair }) => {
46+
expect(appOf(manifest.id)).toBe(manifest.id);
47+
expect(pairOf(manifest.id)).toEqual(customizationPackageId(manifest.id));
48+
expect(pairOf(manifest.id)).toEqual({ ok: true, value: pair });
49+
});
50+
51+
it.each([
52+
{ input: undefined, reason: 'base_id_missing' },
53+
{ input: '', reason: 'base_id_missing' },
54+
{ input: 42, reason: 'base_id_missing' },
55+
{ input: 'hotcrm.custom', reason: 'base_is_customization_package' },
56+
{ input: 'Com.Acme', reason: 'base_id_invalid' },
57+
])('refuses $input as the id rule does: $reason', ({ input, reason }) => {
58+
expect(pairOf(input)).toEqual(customizationPackageId(input));
59+
expect(pairOf(input)).toEqual({ ok: false, reason });
60+
});
61+
});
62+
63+
describe('isPairOf — is this THE pair of that package', () => {
64+
it.each(ROWS)('$shape: only $pair is its pair', ({ manifest, pair }) => {
65+
const candidates = [pair, manifest.id, `${pair}.custom`, 'app.objectstack.hotcrm.custom', 'partner.ext', null, undefined];
66+
for (const candidate of candidates) {
67+
expect(isPairOf(candidate, manifest.id)).toBe(isCustomizationPairOf(candidate, manifest.id));
68+
}
69+
expect(isPairOf(pair, manifest.id)).toBe(true);
70+
expect(isPairOf(manifest.id, manifest.id)).toBe(false);
71+
});
72+
73+
it('a pair has no pair, and a missing base has none', () => {
74+
expect(isPairOf('hotcrm.custom.custom', 'hotcrm.custom')).toBe(false);
75+
expect(isPairOf('.custom', '')).toBe(false);
76+
expect(isPairOf('x.custom', undefined)).toBe(false);
77+
});
78+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The customization pair of a package, looked up through the App that carries
5+
* it — the one place every "which pair customizes this package?" question is
6+
* answered.
7+
*
8+
* The customization unit is the App (ruling record 6691022506 on ADR-0146 D3):
9+
* one pair per App, `<app id>.custom`, never one per member package. An App is
10+
* the `type: app` package and the `type: module` / `plugin` packages its
11+
* artifact brings (ADR-0019 D1–D4; ADR-0130 D1/D6: one artifact, one version).
12+
* So the pair of a package is the pair of its App:
13+
*
14+
* pairOf(base) = customizationPackageId(appOf(base))
15+
*
16+
* The id rule itself stays in `@objectstack/spec/kernel`
17+
* (`customizationPackageId`, `<base>.custom`). What lives here is the lookup
18+
* in front of it, so that moving the unit from "the package" to "its App"
19+
* changes {@link appOf} and nothing else. A caller asking for the pair of a
20+
* package calls {@link pairOf} or {@link isPairOf}, ⛔ never
21+
* `customizationPackageId(base)` / `isCustomizationPairOf(pair, base)`
22+
* directly. The parse direction (is this id a pair, and of which base —
23+
* `customizationBasePackageId`, `isCustomizationPackage`) is unaffected and
24+
* stays with the spec helpers.
25+
*
26+
* Today {@link appOf} is the identity: every package is its own App for
27+
* customization, so {@link pairOf} answers exactly what
28+
* `customizationPackageId` answers, refusals included. Resolving a member
29+
* package to the App whose artifact brought it is the ensure door's change
30+
* (ADR-0146 D3 as amended), not this module's.
31+
*
32+
* Why this package: every caller that asks the question —
33+
* `@objectstack/metadata-protocol`, `@objectstack/objectql`,
34+
* `@objectstack/service-automation`, `@objectstack/service-datasource`,
35+
* `@objectstack/runtime` — already declares `@objectstack/metadata-core`, and
36+
* it depends on none of them; `service-datasource` declares neither
37+
* `metadata-protocol` nor `objectql`. The same criterion that sank the write
38+
* dispatch predicates and the code-artifact provenance test here.
39+
*/
40+
41+
import {
42+
customizationPackageId,
43+
type CustomizationDerivation,
44+
type CustomizationPackageIdRefusal,
45+
} from '@objectstack/spec/kernel';
46+
47+
/**
48+
* The App that carries `packageId` for customization: the id whose pair holds
49+
* every customization of `packageId`'s items.
50+
*
51+
* Today the identity — every package is its own App for this purpose. The
52+
* lookup is the seam the App-unit ruling moves; its callers never assume the
53+
* identity.
54+
*/
55+
export function appOf(packageId: string): string {
56+
return packageId;
57+
}
58+
59+
/**
60+
* The paired customization package of `base`: the pair of the App that carries
61+
* it, or the refusal `customizationPackageId` names (`base_id_missing`,
62+
* `base_is_customization_package`, `base_id_invalid`).
63+
*/
64+
export function pairOf(base: unknown): CustomizationDerivation<CustomizationPackageIdRefusal> {
65+
return customizationPackageId(typeof base === 'string' && base.length > 0 ? appOf(base) : base);
66+
}
67+
68+
/**
69+
* Whether `pairId` is THE paired customization package of `base` — the only
70+
* package that declares an override of `base`'s items (ADR-0146, ruling A on
71+
* its amendment card).
72+
*/
73+
export function isPairOf(pairId: unknown, base: unknown): boolean {
74+
const pair = pairOf(base);
75+
return pair.ok && pair.value === pairId;
76+
}

‎packages/metadata-core/src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,12 @@ export * from './flow-cycle.js';
162162
// install door's dry run and the `manifest` service's `planUpgrade`, so the
163163
// preview an installer reads and the comparison a door decides on are one.
164164
export * from './package-upgrade-plan.js';
165+
166+
// [ADR-0146 D3 · ruling record 6691022506] The customization pair of a package,
167+
// looked up through the App that carries it — `pairOf(base)` /
168+
// `isPairOf(pair, base)`. Sunk here because its callers live in
169+
// `@objectstack/metadata-protocol`, `@objectstack/objectql`,
170+
// `@objectstack/service-automation` and `@objectstack/service-datasource`, and
171+
// this is the one package all four declare; the id rule stays in
172+
// `@objectstack/spec/kernel`.
173+
export * from './customization-pair-lookup.js';

‎packages/metadata-protocol/src/protocol-class-head.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { storedRowDocument } from './item-lock.js';
3737
import { bumpWriteEpoch } from './meta-overlay-cache.js';
3838
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
3939
import type { IObjectQLEngine, IPubSub } from '@objectstack/spec/contracts';
40-
import { isCustomizationPairOf } from '@objectstack/spec/kernel';
40+
import { isPairOf } from '@objectstack/metadata-core';
4141
import { overrideWriteRefusalError } from './protocol-packaged-base-refusal.js';
4242
import { OBJECT_EXTENSION_TYPE, registrableObjectExtension } from './protocol-object-extension-item.js';
4343
import type { ObjectExtensionRegistry } from './protocol-object-extension-item.js';
@@ -188,7 +188,7 @@ export class ProtocolClassHeadFacet {
188188
artifactBacked: boolean,
189189
source: string,): { intent: 'override-artifact' | 'runtime-only'; deriveRestoredBody: (historyBody: unknown) => Promise<unknown> } {
190190
const owner = artifactBacked ? host.packagedArtifactOwner({ type: singularType, name }) : undefined;
191-
const pairRow = owner !== undefined && isCustomizationPairOf(binding, owner);
191+
const pairRow = owner !== undefined && isPairOf(binding, owner);
192192
const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !pairRow ? 'override-artifact' : 'runtime-only';
193193
const writeRestored = host.restoredBodyWriter(singularType, name, binding, source);
194194
const deriveRestoredBody = pairRow

‎packages/metadata-protocol/src/protocol-customization-package.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
* `@objectstack/spec/kernel` (`customization-package.ts`), ⛔ never a local
2020
* spelling:
2121
*
22-
* - id — {@link customizationPackageId}: `<base>.custom`;
22+
* - id — `customizationPackageId`, asked through {@link pairOf}: `<base>.custom`;
2323
* - namespace — {@link customizationNamespace}: `<base namespace>_custom`, SET
2424
* EXPLICITLY on the manifest so `installPackage`'s id-derived default
2525
* (`deriveNamespaceFromPackageId('hotcrm.custom')` = `custom`) never runs
@@ -101,8 +101,8 @@ import {
101101
customizationBasePackageId,
102102
customizationDependencyRange,
103103
customizationNamespace,
104-
customizationPackageId,
105104
} from '@objectstack/spec/kernel';
105+
import { pairOf } from '@objectstack/metadata-core';
106106
import type { CustomizationNamespaceRefusal } from '@objectstack/spec/kernel';
107107

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

255-
const pair = customizationPackageId(baseId);
255+
const pair = pairOf(baseId);
256256
if (!pair.ok) {
257257
throw refusal(400, pair.reason === 'base_is_customization_package'
258258
? `Package '${baseId}' is itself a paired customization package, and a pair is never paired again: `

‎packages/metadata-protocol/src/protocol-discovery-and-bases.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ import { restoreTranslatedEchoes } from './protocol-translated-echo.js';
4242
import type { RestoredTranslatedSlot } from './protocol-translated-echo.js';
4343
import {
4444
DEFAULT_METADATA_TYPE_REGISTRY,
45-
customizationPackageId,
4645
getMetadataCreateSeed,
4746
getMetadataTypeActions,
4847
getMetadataTypeSchema,
4948
} from '@objectstack/spec/kernel';
49+
import { pairOf } from '@objectstack/metadata-core';
5050
import { storedBodyForCarryForward } from './protocol-credential-carry-forward.js';
5151
import type { Equal, MetaTypesListing } from './protocol-meta-types-listing.js';
5252
import {
@@ -407,7 +407,7 @@ export class ProtocolDiscoveryAndBasesFacet {
407407
*/
408408
static async getPairTranslationBundle(host: Pick<ProtocolHost, 'findEnvironmentRows'>, basePackageId: string): Promise<TranslationBundle | undefined> {
409409
if (typeof basePackageId !== 'string' || basePackageId === '') return undefined;
410-
const pair = customizationPackageId(basePackageId);
410+
const pair = pairOf(basePackageId);
411411
if (!pair.ok) return undefined;
412412
const layer = await readAuthoredTranslationLayer({
413413
find: (table: string, opts: { where?: Record<string, unknown>; [key: string]: unknown } = {}) =>

‎packages/metadata-protocol/src/protocol-environment-rows.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
* not move.
4545
*/
4646

47-
import { customizationPackageId } from '@objectstack/spec/kernel';
47+
import { pairOf } from '@objectstack/metadata-core';
4848
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
4949
import type { StoredOverlayRow } from './item-lock.js';
5050
import { overlayMemberWhere } from './migrations/overlay-index.js';
@@ -293,6 +293,6 @@ export function soleShippingPackage(shipped: readonly unknown[]): string | undef
293293
export function baseAddressServesMember(base: string, bound: string | undefined, baseShipsMember: boolean): boolean {
294294
if (bound === undefined || bound === base) return true;
295295
if (baseShipsMember) return false;
296-
const pair = customizationPackageId(base);
296+
const pair = pairOf(base);
297297
return pair.ok && pair.value === bound;
298298
}

‎packages/metadata-protocol/src/protocol-meta-layered-reads.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ import {
2626
// {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer
2727
// fallback so a hydrated row is never answered as the code layer.
2828
isTenantAuthored,
29+
pairOf,
2930
} from '@objectstack/metadata-core';
3031
import {
3132
type MetadataLock,
3233
type MetadataLockSource,
3334
type MetadataProvenance,
34-
customizationPackageId,
3535
} from '@objectstack/spec/kernel';
3636
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
3737
import {
@@ -405,12 +405,12 @@ export async function getMetaItemLayered(host: Pick<ProtocolHost, 'artifactLockL
405405
const owner = boundTo(row);
406406
if (owner === undefined || owner === base) return false;
407407
if (isServedOverrideRowOf(row, base)) return false;
408-
const pair = customizationPackageId(base);
408+
const pair = pairOf(base);
409409
if (pair.ok && pair.value === owner) return true;
410410
return host.resolveWritePackageScope(owner)?.dependencies.includes(base) === true;
411411
})
412412
.sort((a, b) => {
413-
const pair = customizationPackageId(base);
413+
const pair = pairOf(base);
414414
const rank = (row: StoredOverlayRow) => (pair.ok && boundTo(row) === pair.value ? 0 : 1);
415415
return rank(a) - rank(b) || String(boundTo(a)).localeCompare(String(boundTo(b)));
416416
})[0];

‎packages/metadata-protocol/src/protocol-meta-list-reads.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
* not move.
1919
*/
2020

21-
import { ITEM_KEY_DISCRIMINATORS, isTenantAuthored } from '@objectstack/metadata-core';
22-
import { customizationPackageId } from '@objectstack/spec/kernel';
21+
import { ITEM_KEY_DISCRIMINATORS, isTenantAuthored, pairOf } from '@objectstack/metadata-core';
2322
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
2423
import { isAggregatedViewContainer } from '@objectstack/spec/ui';
2524
import {
@@ -194,7 +193,7 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
194193
// come from the package-agnostic read too. They stand in like the
195194
// package-less rows: a pair's item of a name the package does not
196195
// ship is the pair's own, and never listed in the package's list.
197-
const pairOfScope = servedOverride && packageId ? customizationPackageId(packageId) : undefined;
196+
const pairOfScope = servedOverride && packageId ? pairOf(packageId) : undefined;
198197
const overrideRows = pairOfScope?.ok
199198
? lockRows.filter((row) => row?.package_id === pairOfScope.value)
200199
: [];
@@ -528,7 +527,7 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
528527
// [#28409] …and the pair's drafts, as stand-ins, as its active
529528
// arm previews the pair's rows: the pending edit of the
530529
// package's declared override previews over its slot.
531-
const pairOfDraftScope = servedOverride && packageId ? customizationPackageId(packageId) : undefined;
530+
const pairOfDraftScope = servedOverride && packageId ? pairOf(packageId) : undefined;
532531
const overrideDraftRecords = pairOfDraftScope?.ok
533532
? packageAgnosticDrafts.filter((record) => record?.package_id === pairOfDraftScope.value)
534533
: [];
@@ -672,7 +671,7 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
672671
// address serves beside the package's members
673672
// ({@link baseServesBundleMember}), as the env-wide list below does.
674673
if (unseated && servedOverride && packageId && ITEM_KEY_DISCRIMINATORS[request.type] !== undefined) {
675-
const pairOfList = customizationPackageId(packageId);
674+
const pairOfList = pairOf(packageId);
676675
const typeShipping = host.shippingPackagesOf(request.type);
677676
for (const it of items as any[]) {
678677
if (!(it && typeof it === 'object' && unseated.has(it))) continue;

0 commit comments

Comments
 (0)