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
14 changes: 14 additions & 0 deletions .changeset/31677-package-scoped-connector-instances.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/service-automation': patch
---

feat: a declared connector instance's name resolves by (package, name) through the shared package-scoped resolver (ADR-0150 D3/D4)

Clause-②: no
Metadata-protocol: none

Which declared connector instance a name materializes is now answered by `resolvePackageScopedName` from `@objectstack/objectql`, never by list order. The connector reconcile reads the whole environment's declaration, so it asks as a caller with no package: the environment's own item, else the unique package holder, else a refusal that names every candidate.

Who is affected: nobody today. Phase 1's one-holder refusal still holds for `connector`, so no two installed packages can ship the same connector instance name, and every name resolves exactly as before. Once that backstop narrows (its own later release), a name two packages ship is not materialized, and boot fails naming both packages, as it already did for a duplicate instance name (which named no candidates).

`@objectstack/service-automation` now depends on `@objectstack/objectql` at runtime (it was a dev dependency).
2 changes: 1 addition & 1 deletion packages/services/service-automation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@objectstack/formula": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/plugin-auth": "workspace:*",
"@objectstack/spec": "workspace:*",
Expand All @@ -38,7 +39,6 @@
"devDependencies": {
"@objectstack/driver-sql": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-audit": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type CredentialResolver,
} from './plugin.js';
import type { AutomationEngine } from './engine.js';
import { SchemaRegistry } from '@objectstack/objectql';
import { computeAliyunAcs3Signature } from './request-signing/aliyun-acs3-hmac-sha256.js';
import { computeAlipayV3Signature, readAlipayPrivateKey } from './request-signing/alipay-v3.js';

Expand Down Expand Up @@ -109,6 +110,11 @@ interface BootOptions {
registered?: unknown[];
/** Collects every `logger.error` record the boot writes (#28594: the soft path's record). */
logged?: Array<{ msg: string; meta?: unknown }>;
/**
* A real `SchemaRegistry` to serve as the objectql registry instead of the
* double over `declared` (ADR-0150 E5: the package-scoped resolver reads it).
*/
schemaRegistry?: SchemaRegistry;
}

/** `SchemaRegistry.getArtifactItem('connector', name, packageId)` over `registered` (#28594). */
Expand Down Expand Up @@ -136,7 +142,7 @@ async function boot(declared: unknown[], opts: BootOptions = {}): Promise<LiteKe
dependencies: ['com.objectstack.service-automation'],
async init(ctx: any) {
ctx.registerService('objectql', {
registry: {
registry: opts.schemaRegistry ?? {
listItems: (type: string) => (type === 'connector' ? declared : []),
getPackage: (id: string) => opts.packages?.[id],
getArtifactItem: artifactReader(opts.registered),
Expand Down Expand Up @@ -418,6 +424,40 @@ describe('ADR-0097 — declarative connector materialization', () => {
});
});

describe('ADR-0150 D3 — a declared connector name resolves by (package, name) (epic #31527 child E5)', () => {
const A = 'com.acme.billing_a';
const B = 'com.acme.billing_b';
/** A real registry: each id installed as a code package, each shipping `billing` in its own composite slot. */
function registryShipping(...packageIds: string[]): SchemaRegistry {
const registry = new SchemaRegistry();
registry.logLevel = 'silent';
for (const id of packageIds) {
registry.installPackage({ id, name: id, version: '1.0.0', type: 'app', namespace: id.split('.').pop() } as never, 'code');
registry.registerItem('connector', providerConnector('billing', { label: `billing from ${id}` }), 'name' as never, id);
}
return registry;
}

it('one package holder: the instance materializes, as before', async () => {
const { factory, calls } = makeFakeProvider();
const kernel = await boot([], { providerFactory: factory, schemaRegistry: registryShipping(A) });
expect(automationOf(kernel).getRegisteredConnectors()).toContain('billing');
expect(calls.map((c) => c.label)).toEqual([`billing from ${A}`]);
});

it('two package holders: the boot refuses, naming both — no instance is picked by list order', async () => {
const { factory, calls } = makeFakeProvider();
const err = await boot([], { providerFactory: factory, schemaRegistry: registryShipping(A, B) }).then(
() => undefined,
(e: Error) => e,
);
expect(err?.message).toContain(`connector instance 'billing' is not materialized`);
expect(err?.message).toContain(`"${A}"`);
expect(err?.message).toContain(`"${B}"`);
expect(calls).toEqual([]);
});
});

describe('ADR-0097 — runtime re-materialization on metadata:reloaded (F1)', () => {
it('materializes an instance published after boot', async () => {
const { factory, calls } = makeClosableProvider();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0150 D3/D4, epic #31527 child E5] Which declared connector instance a
* name means — answered by the ONE package-scoped resolver
* (`resolvePackageScopedName`, `@objectstack/objectql`), never by list order.
*
* A connector is addressed by `(package, name)`. The engine keeps one live
* instance per name, and the reconcile that materializes declared instances
* is context-free: it reads the whole environment's declaration, so it asks
* as a caller with no package — the environment's own item, else the unique
* package holder, else a refusal naming every candidate. A refused name is not
* materialized: the reconcile reports the refusal (the boot fails loudly on a
* host-declared entry, as it does for a duplicate name) and tears down what
* it materialized before, so no flow dispatches to one of the candidates on a
* guess. Every other answer leaves the name to the reconcile as it stood.
*
* While phase 1's one-holder refusal stands (`connector` is one of its types)
* no two packages hold a connector name, so this refuses nothing on a booted
* deployment; it is the reader the type's trailing backstop child (ADR-0150
* D9) narrows that refusal behind.
*
* A connector a plugin registers in code (`engine.registerConnector`) is no
* registry item: the resolver answers not-found for it, and it keeps the
* engine's own ADR-0097 §4 one-origin rule.
*/

import {
resolvePackageScopedName,
type PackageScopedRefusal,
type PackageScopedRegistry,
} from '@objectstack/objectql';

/** The registry, when it carries the slice the package-scoped resolver reads. */
export function packageScopedRegistryOf(registry: unknown): PackageScopedRegistry | undefined {
const r = registry as Partial<Record<keyof PackageScopedRegistry, unknown>> | null | undefined;
return typeof r?.packageScopedHoldersOf === 'function'
&& typeof r.getPackageScopedItem === 'function'
&& typeof r.getEnvironmentScopedItem === 'function'
&& typeof r.getPackage === 'function'
&& typeof r.getAllPackages === 'function'
? (registry as PackageScopedRegistry)
: undefined;
}

/**
* The refusal of every declared connector name in `names` that resolves to no
* single holder (ADR-0150 D3, a caller with no package), keyed by name. A name
* the resolver answers — or does not hold at all — is absent.
*/
export function refusedConnectorNames(
registry: PackageScopedRegistry,
names: Iterable<string>,
): Map<string, PackageScopedRefusal> {
const refused = new Map<string, PackageScopedRefusal>();
for (const name of new Set(names)) {
const resolved = resolvePackageScopedName(registry, { type: 'connector', name });
if (resolved.status === 'refused') refused.set(name, resolved);
}
return refused;
}
38 changes: 38 additions & 0 deletions packages/services/service-automation/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import {
} from './connector-credentials.js';
import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE } from '@objectstack/types';
import { createConnectorCredentialSource } from './connector-credential-source.js';
import { packageScopedRegistryOf, refusedConnectorNames } from './connector-package-scope.js';
import type { PackageScopedRefusal, PackageScopedRegistry } from '@objectstack/objectql';
import { createConnectorRequestSigner } from './request-signing/index.js';
import { ConnectorConnectionStore, type ConnectorConnectionRecord } from './connector-connections.js';
import {
Expand Down Expand Up @@ -2586,6 +2588,31 @@ export class AutomationServicePlugin implements Plugin {
this.reloadedConnectors = { items, owners };
}

/**
* [ADR-0150 D3] The refusal of every enabled provider-bound declared
* connector name that resolves to no single holder, read over the ObjectQL
* registry ({@link refusedConnectorNames}). A composition with no registry
* has no package coordinate to resolve against, and refuses nothing here.
*/
private refusedDeclaredConnectorNames(ctx: PluginContext, declared: readonly unknown[]): Map<string, PackageScopedRefusal> {
let registry: PackageScopedRegistry | undefined;
try {
registry = packageScopedRegistryOf(ctx.getService<{ registry?: unknown }>('objectql')?.registry);
} catch {
registry = undefined; // no registry service
}
if (!registry) return new Map();
const names: string[] = [];
for (const raw of declared) {
const entry = raw as DeclaredConnectorItem;
if (typeof entry?.name !== 'string' || entry.name.length === 0) continue;
if (typeof entry.provider !== 'string' || entry.provider.length === 0) continue;
if (entry.enabled === false) continue;
names.push(entry.name);
}
return refusedConnectorNames(registry, names);
}

/**
* [#28534] The readers {@link nonHostDeclaringPackageOf} judges a
* connector entry's origin with, off the `objectql` engine the declared
Expand Down Expand Up @@ -2893,6 +2920,16 @@ export class AutomationServicePlugin implements Plugin {
ctx.logger.error(msg, undefined, cause === undefined ? undefined : describeThrownForLog(cause));
};

// [ADR-0150 D3, epic #31527 child E5] A declared name that resolves to
// no single holder (two packages declare it) is refused, naming every
// candidate, and never materialized: no instance is picked by list
// order ({@link refusedConnectorNames}).
const refusedNames = this.refusedDeclaredConnectorNames(ctx, declared);
for (const [name, refusal] of refusedNames) {
const stored = declared.some((raw) => (raw as DeclaredConnectorItem)?.name === name && storedEntry(raw));
fail(`[Automation] connector instance '${name}' is not materialized: ${refusal.message}`, undefined, stored);
}

// Build the desired set: enabled provider-bound instances, keyed by name.
// A descriptor (no `provider`) or an `enabled: false` instance is not
// desired — if we materialized it before, it is torn down below.
Expand All @@ -2903,6 +2940,7 @@ export class AutomationServicePlugin implements Plugin {
if (typeof entry.provider !== 'string' || entry.provider.length === 0) continue;
const bound = entry as DeclaredConnectorItem & { name: string; provider: string };
if (bound.enabled === false) continue;
if (refusedNames.has(bound.name)) continue;
// [#28713] Held until every plugin has started ({@link holdStoredEntries}).
if (this.holdStoredEntries && storedEntry(bound)) {
this.storedEntriesHeld = true;
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading