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
19 changes: 19 additions & 0 deletions .changeset/32948-dataset-door-contended-holder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/rest": patch
---

`POST /analytics/dataset/query` (and each query of `POST /analytics/dataset/query/batch`), under the analytics service's plugin-wired admission: a holder whose own address is contended by a stored row two or more holders of the name could own (ADR-0150 D4) is now judged on its own dataset instead of being left undetermined. Such a row makes each of those holders' own exact read refuse the name, naming them; the door now reads each such holder's own dataset as the list scoped to it serves it (that list withholds the row), asks admission about it, names it in the `409` when the caller may query it, and never runs it.

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

Moved answers, when the name is refused (bare name, `?callerPackage=`, or `?package=` naming a contended holder) and its holders' own addresses are contended:

- admitted to two or more holders: `503 SERVICE_UNAVAILABLE` → `409 RESOURCE_CONFLICT` naming those holders;
- admitted to exactly one contended holder: `503` → `409` naming that holder only, nothing run; its sentence speaks of that holder and the row alone and neither names nor counts any holder the caller may not query;
- admitted to none: `503` → `404 NOT_FOUND`, byte-equal to the answer for a name nothing holds;
- admitted to exactly one holder whose address is not contended, every other holder contended and not admitted: `503` → `200`, that holder's dataset runs (the rule for one admitted holder of an ambiguity, now reached). At `?package=` or `?callerPackage=` naming a contended module that holder is not among the refused holders, so it never runs there: admitted to it alone is the `404`, admitted beside a module is the `409` naming that module only.

The stored row this arises from today is an App's customization-pair row that names none of the App's modules. A row bound to no package is declined at each holder's address for a dataset, so it moves no answer, before or after.

Also: the `503` the door still answers where a holder cannot be judged carries, on its logged cause, which holders could not be judged and why; its body is unchanged and names no holder. Unchanged: every refusal of a holder's own read in any other shape stays undetermined; a name the read resolves keeps its answer. No fix needed from callers.
18 changes: 16 additions & 2 deletions content/docs/data-modeling/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,25 @@ With none left, the answer is the `404` a missing name gets. Where two or more
holders were in contention and exactly one is left, that one runs. Otherwise
the `409` names only the holders left. If whether the caller may query a holder
cannot be judged, and it could change the answer, the route answers `503` and
runs nothing. A service built by hand, whose admission passes every caller, is
not asked, so its refusal names every holder. A name that resolves to one
runs nothing; the server log says which holders could not be judged and why.
Where some holders cannot be judged and the answer is a `409` anyway, that
`409` names only the holders that could be judged. A service built by hand,
whose admission passes every caller, is not asked, so its refusal names every
holder. A name that resolves to one
dataset is unchanged: a caller who may not query it is refused by the query
itself (`403`).

A stored row of the name that two or more holders could own is no one holder's
customization (ADR-0150 D4). A row bound to no package is declined at each
holder's address for a dataset, so it changes no answer: the name is judged as
if the row were not there. An App's customization-pair row that names none of
the App's modules makes each module's own address refuse the name until the row
is saved under one package. Such a module never runs, and it is judged on its
own dataset: the `409` names it when the caller may query it, and a caller who
may query none of the holders gets the `404`. A `409` that names one module
speaks of that module and the row only: it neither names nor counts a holder
the caller may not query.

To run several queries in one request, as a dashboard render does, send them
to `POST {basePath}/analytics/dataset/query/batch` as `{ queries: [...] }`,
each entry a body the single route takes (at most 50). Each query is judged
Expand Down
159 changes: 127 additions & 32 deletions packages/rest/src/analytics-dataset-admission-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,23 @@ import { stripReadDecorations } from '@objectstack/spec/kernel';
* answers, and otherwise the `409` names only the admitted holders;
* - a holder whose admission could not be judged may be one the caller can
* query: whenever it could change the answer, nothing runs (`503`). That
* includes a holder whose own exact-qualifier read refuses: its refusal is
* kept inside this arm and never answered.
* includes a holder whose own exact-qualifier read refuses in any other
* shape than the one below: its refusal is kept inside this arm and never
* answered. The `503` carries, on `cause` (the operator's log, never the
* body), which holders could not be judged and why.
*
* [#32948, ADR-0150 D4] A holder whose own address is CONTENDED is judged, not
* left undetermined. A row two or more holders of the name could own (a
* package-less row, or an App pair's row that no module's address declares,
* epic ruling on #31699 F2) is no one holder's customization, so each such
* holder's own exact read refuses `ambiguous-in-closure` naming the holders,
* itself among them ({@link isOwnAddressContention}). Nothing can run at that
* address, so such a holder is never picked; the caller's admission to it is
* judged on the holder's own item as the list scoped to it serves it (that
* list withholds the row), as `query_dataset` judges every holder over the
* list. Admitted, it is named in the `409`; not admitted, it is not named.
* Before, every such holder was undetermined, and the door answered `503` for
* every caller where D4 asks for the refusal naming the holders.
*
* ⛔ Only the refusal arm (seat ruling 6817809380, Q1 = A). A name the read
* RESOLVES keeps its answer: a caller not admitted to the one holder (or to the
Expand Down Expand Up @@ -51,8 +66,12 @@ export type DatasetRefusalAdmission =
| { readonly kind: 'read'; readonly read: { readonly item: unknown } }
/** The refusal over the admitted holders only. */
| { readonly kind: 'refused'; readonly error: PackageScopedNameRefusedError }
/** Whether a holder is admitted could not be judged, and it could move the answer. */
| { readonly kind: 'undetermined' };
/**
* Whether a holder is admitted could not be judged, and it could move the
* answer. `cause` names each holder that could not be judged and why: the
* operator's text, logged with the `503`, ⛔ never put in its body.
*/
| { readonly kind: 'undetermined'; readonly cause: string };

/** The analytics service's admission face, read structurally (this package carries no analytics code). */
interface DatasetAdmissionService {
Expand All @@ -70,17 +89,47 @@ function admissionServiceOf(svc: unknown): DatasetAdmissionService | undefined {
}

/**
* A package-scoped refusal by class OR by shape (`code` `RESOURCE_CONFLICT` with
* a `refusal` whose `status` is `'refused'`), whatever its reason: the door's
* `isPackageScopedRefusal` predicate, so a refusal from another copy of
* `@objectstack/core` is caught here too.
* [#32072] A package-scoped resolver's refusal, by class or by shape: `code`
* `RESOURCE_CONFLICT` with a `refusal` whose `status` is `'refused'`, whatever
* its reason. The same predicate the `/meta` dispatcher reads
* (`isPackageScopedRefusal` in `@objectstack/runtime`'s `domains/meta.ts`), so
* one refusal is a `409` at both doors whichever copy of the class threw it.
* [#32826] The ONE copy in this package: the door's by-name read and this
* arm's per-holder reads both ask it.
*/
function isRefusal(e: unknown): boolean {
export function isPackageScopedRefusal(e: unknown): e is { code: 'RESOURCE_CONFLICT'; refusal: PackageScopedRefusal } {
if (e instanceof PackageScopedNameRefusedError) return true;
const thrown = e as { code?: unknown; refusal?: { status?: unknown } } | null | undefined;
return thrown?.code === 'RESOURCE_CONFLICT' && thrown.refusal?.status === 'refused';
}

/**
* [#32948] Is `e` the refusal of `packageId`'s own exact address over a row
* two or more holders could own: `ambiguous-in-closure`, read AS that package
* (`callerPackageId`), naming only packages, that package among them? The
* exact read throws exactly this for a package-less row, and for an App pair's
* undeclared row, of a name two or more packages hold
* (`exactQualifierItem` in `@objectstack/metadata-protocol`). Any other refusal
* of a holder's own read stays undetermined.
*/
function isOwnAddressContention(e: unknown, packageId: string): boolean {
if (!isPackageScopedRefusal(e)) return false;
const { reason, callerPackageId, candidates } = e.refusal;
return reason === 'ambiguous-in-closure'
&& callerPackageId === packageId
&& candidates.length >= 2
&& candidates.every((c) => c.kind === 'package')
&& candidates.some((c) => c.kind === 'package' && c.packageId === packageId);
}

/**
* The ambiguity reasons: the core vocabulary's whole ambiguity class, so an
* ambiguity is never read as a non-ambiguity. [#32826] `ambiguous-in-namespace`
* cannot reach this door today: only a read that names a namespace
* (`/meta/:type/:ns/:name`) is refused with it, and the dataset doors name a
* package or none. It is listed so that a refusal carrying it would be judged
* as the ambiguity it is, never as `outside-closure` is.
*/
const AMBIGUITY: ReadonlySet<PackageScopedRefusal['reason']> = new Set([
'ambiguous-in-environment',
'ambiguous-in-closure',
Expand All @@ -89,18 +138,22 @@ const AMBIGUITY: ReadonlySet<PackageScopedRefusal['reason']> = new Set([

/**
* Judge `refusal`'s holders for the caller in `context`. `readHolder` is the
* door's exact-qualifier read of one holder. A refusal it throws, of any
* reason and by class or by shape, leaves that holder undetermined; any other
* throw is a failed read, which the door answers as it answers any (`503`), so
* it is left to throw.
* door's exact-qualifier read of one holder. A refusal it throws, by class or
* by shape, leaves that holder undetermined, unless it is that holder's own
* address contended ({@link isOwnAddressContention}): that holder is then
* judged on `listHolder`'s answer, the holder's own item as the list scoped to
* it serves it, and is never picked. Any other throw is a failed read, which
* the door answers as it answers any (`503`), so it is left to throw.
*/
export async function admitRefusedDatasetHolders(args: {
readonly refusal: PackageScopedRefusal;
readonly svc: unknown;
readonly context: unknown;
readonly readHolder: (packageId: string) => Promise<{ item?: unknown } | null | undefined>;
/** The holder's own item of the name in the list scoped to it, `undefined` when it lists none. */
readonly listHolder?: (packageId: string) => Promise<unknown>;
}): Promise<DatasetRefusalAdmission> {
const { refusal, context, readHolder } = args;
const { refusal, context, readHolder, listHolder } = args;
const service = admissionServiceOf(args.svc);
if (!service) return { kind: 'rethrow' };
const packageIds: string[] = [];
Expand All @@ -112,46 +165,76 @@ export async function admitRefusedDatasetHolders(args: {
}

const { DatasetSchema } = await import('@objectstack/spec/ui');
const admitted: Array<{ packageId: string; read: { item: unknown } }> = [];
let unresolved = false;
// `read` absent: a contended holder, admitted on its own listed item, which
// is named but never run.
const admitted: Array<{ packageId: string; read?: { item: unknown } }> = [];
// [#32826] Each holder that could not be judged, and why: the `503`'s cause.
const unjudged: string[] = [];
/** Judge one holder on `item`; `read` is what runs when it is picked. */
const judge = async (packageId: string, item: unknown, runnable: boolean): Promise<void> => {
const parsed = (DatasetSchema as { safeParse(v: unknown): { success: boolean; data?: unknown } })
.safeParse(stripReadDecorations(item));
if (!parsed.success) return;
const admission = await service.admitDatasetCatalogEntry(parsed.data, context);
if (admission.admitted) admitted.push({ packageId, ...(runnable ? { read: { item } } : {}) });
else if (admission.unresolved) unjudged.push(`package "${packageId}": its admission could not be resolved`);
};
for (const packageId of packageIds) {
let read: { item?: unknown } | null | undefined;
try {
read = await readHolder(packageId);
} catch (readError) {
// The holder's own read refused its address: which item would run
// [#32948] The holder's own address is contended by a row no one
// holder owns (ADR-0150 D4): nothing runs there, and the holder is
// judged on its own listed item. The read's refusal never leaves
// this arm.
if (isOwnAddressContention(readError, packageId) && listHolder) {
let listed: unknown;
try {
listed = await listHolder(packageId);
} catch (listError) {
if (!isPackageScopedRefusal(listError)) throw listError;
listed = undefined;
}
if (listed !== undefined && listed !== null) {
await judge(packageId, listed, false);
continue;
}
unjudged.push(`package "${packageId}": its own address is contended and the list scoped to it serves no item of the name`);
continue;
}
// Any other refusal of the holder's own read: which item would run
// there is not known, so neither is the caller's admission to it.
// Undetermined, and the read's own refusal (its candidates
// included) never leaves this arm. Any other throw is a failed
// read, answered `503` by the door.
if (isRefusal(readError)) {
unresolved = true;
if (isPackageScopedRefusal(readError)) {
unjudged.push(`package "${packageId}": its own exact read refused (${readError.refusal.reason})`);
continue;
}
throw readError;
}
const item = read?.item;
// The holder's own address serving nothing is no fact about the caller.
if (item === undefined || item === null) {
unresolved = true;
unjudged.push(`package "${packageId}": its own exact read serves nothing`);
continue;
}
const parsed = (DatasetSchema as { safeParse(v: unknown): { success: boolean; data?: unknown } })
.safeParse(stripReadDecorations(item));
if (!parsed.success) continue;
const admission = await service.admitDatasetCatalogEntry(parsed.data, context);
if (admission.admitted) admitted.push({ packageId, read: { item } });
else if (admission.unresolved) unresolved = true;
await judge(packageId, item, true);
}

const ambiguity = AMBIGUITY.has(refusal.reason);
const picked = ambiguity && admitted.length === 1 ? admitted[0].read : undefined;
// An unjudged holder could be admitted: it moves "none" to "one" (or to a
// refusal), and for an ambiguity "one" to a refusal.
if (unresolved && (admitted.length === 0 || (ambiguity && admitted.length === 1))) {
return { kind: 'undetermined' };
// refusal), and for an ambiguity a picked "one" to a refusal.
if (unjudged.length > 0 && (admitted.length === 0 || picked !== undefined)) {
return {
kind: 'undetermined',
cause: `The dataset "${refusal.name}" (${refusal.reason}) has holders whose admission could not be judged: ${unjudged.join('; ')}.`,
};
}
if (admitted.length === 0) return { kind: 'not-found' };
if (ambiguity && admitted.length === 1) return { kind: 'read', read: admitted[0].read };
if (picked !== undefined) return { kind: 'read', read: picked };
const narrowed = {
status: 'refused' as const,
reason: refusal.reason,
Expand All @@ -162,9 +245,21 @@ export async function admitRefusedDatasetHolders(args: {
(c) => c.kind === 'package' && admitted.some((a) => a.packageId === c.packageId),
),
};
// [#32948] An ambiguity narrowed to one holder is that holder's contended
// address (a runnable one would have been picked above): the vocabulary's
// ambiguity sentence counts holders and tells the caller to address one,
// which is no remedy here, so the sentence says what stands in the way.
// ⛔ It speaks of that one package and the row only: a holder the caller
// may not query is hidden, so nothing here says or counts that another
// holder exists (no plural, no "other", no "no one package").
const [only] = narrowed.candidates;
const message = ambiguity && narrowed.candidates.length === 1 && only?.kind === 'package'
? `The dataset "${refusal.name}" cannot be read at package "${only.packageId}": a stored row of the name `
+ 'stands beside that package\'s item and is not saved under it, so nothing is run until the row is '
+ 'saved under a package (ADR-0150 D4).'
: packageScopedRefusalMessage(narrowed);
return {
kind: 'refused',
error: new PackageScopedNameRefusedError({ ...narrowed, message: packageScopedRefusalMessage(narrowed) }),
error: new PackageScopedNameRefusedError({ ...narrowed, message }),
};
}

Loading
Loading