From 9abad39a115a3a608b173d869a93ae316bbc97bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:16:40 +0000 Subject: [PATCH] fix(plugin-sharing): require warn on the orphan sweep's logger and pin every required warn (#10692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #10692 — the ruled remainder (option A, maintainer 2026-08-25) after PR #11856 landed the three publicly exported producers under #10556 limb (c). - record-orphan-cleanup.ts's module-local MinimalLogger drops its bare Function members for the producers' real signatures and makes warn REQUIRED: every report the sweep emits lands on warn, so a logger without a guaranteed warn is one the sweep loses its only output into (#9754's permit-silence shape, one module downstream of the tightened producers). Zero in-repo call-site cost: both callers forward the owning services' logger options, whose warn is already required. - logger-required-warn.pin.ts pins the contract at compile time for all four sinks (three public options types + the sweep parameter): a { info, error }-only literal is refused, a bare { warn } stub stays legal, and a value of type Function no longer satisfies the members. Pins live in a src-level .pin.ts because this package's tsconfig excludes tests from tsc; the file deliberately declares no type literals with channel members so check:optional-error-sink's census never reads it. - logger-shapes.ts's shape map is updated: the record-orphan-cleanup row no longer describes bare Function as blocked, and names the pin file. - Changeset: minor with the published-contract break declared (BREAKING) and the one-line host fix named, per the launch-window convention. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa --- .../sharing-sweep-logger-required-warn.md | 31 ++++++ .../src/logger-required-warn.pin.ts | 105 ++++++++++++++++++ .../plugin-sharing/src/logger-shapes.ts | 18 +-- .../src/record-orphan-cleanup.ts | 25 ++++- 4 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 .changeset/sharing-sweep-logger-required-warn.md create mode 100644 packages/plugins/plugin-sharing/src/logger-required-warn.pin.ts diff --git a/.changeset/sharing-sweep-logger-required-warn.md b/.changeset/sharing-sweep-logger-required-warn.md new file mode 100644 index 0000000000..ab277b00c3 --- /dev/null +++ b/.changeset/sharing-sweep-logger-required-warn.md @@ -0,0 +1,31 @@ +--- +'@objectstack/plugin-sharing': minor +--- + +⚠️ **BREAKING (published parameter tightened):** `sweepOrphanedRowsByRecordExistence` — +publicly exported from `@objectstack/plugin-sharing` — now types its optional `logger` +parameter as `{ info?, warn }` with real signatures (`(msg: any, ...rest: any[]) => void`) +and a **required** `warn`, replacing the old `{ info?: Function, warn?: Function }`. A host +that passed this function a logger without a `warn` member (for example an +`{ info, error }`-only literal) compiles today and stops compiling after this release. The +one-line fix: add a `warn` callback to that logger object — or omit the `logger` argument +entirely, since the parameter itself stays optional. Nothing about the sweep's runtime +behaviour changes, and no call site in this repo moved: both in-package callers forward the +owning services' `logger` options, whose `warn` is already required since the producer +tightening (`SharingServiceOptions` / `ShareLinkServiceOptions` / +`SharingRuleServiceOptions`) shipped in the previous release. + +Why: every report this sweep emits lands on `warn` — the "could not check whether records +still exist", "stopped early" and "revoked N rows" lines — so a logger without a +guaranteed `warn` is one the sweep can lose its ONLY output into. That is #9754's +permit-silence shape, one module downstream of the producers #10556 tightened, and the +maintainer ruled (#10692, 2026-08-25) that this package's logger contracts refuse it +loudly at compile time rather than keep it. The bare `Function` members were also their +own defect: they documented no call shape and caught no arity mistake. The refusal is +pinned at compile time in `logger-required-warn.pin.ts`, which also pins the three +publicly exported options types above. + +Breaking ships as `minor` per the launch-window convention +(`scripts/check-changeset-no-major.mjs`). + + diff --git a/packages/plugins/plugin-sharing/src/logger-required-warn.pin.ts b/packages/plugins/plugin-sharing/src/logger-required-warn.pin.ts new file mode 100644 index 0000000000..dd12a05317 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/logger-required-warn.pin.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10692 — compile-time pins for the REQUIRED `warn` channel on this package's + * logger sinks, and for their members being real signatures rather than bare + * `Function`. + * + * WHY A PIN AT ALL. A test that passes a full logger cannot tell a required + * member from an optional one — every runtime suite in this package stays + * green if some `warn:` quietly regresses to `warn?:` or to `Function`. The + * contract IS the compile-time refusal of a `{ info, error }`-only host + * logger (#9754: an optional `error` beside an optional `warn` is a contract + * that permits silence; the maintainer ruled on 2026-08-25 that these + * publicly exported types refuse it loudly instead of baselining it). So the + * pin is the refusal itself: each `@ts-expect-error` below is red exactly + * when the refusal stops happening. + * + * WHY A `.pin.ts` AND NOT A `*.test.ts`: this package's `tsconfig.json` + * excludes `**\/*.test.ts` (measured TEST_DEBT), so a pin written in a test + * file is read by NO tsc program the `typecheck` script runs — a phantom + * check that stays green however the contract is broken. This file IS in the + * program, is imported by nothing, and is never bundled (tsup entry is + * `src/index.ts`), exactly like `exec-context-annotation.pin.ts`. + * + * ⚠️ SHAPE DISCIPLINE for this file: it deliberately declares NO interface, + * no type-literal alias and no inline type literal carrying channel-named + * members. `check:optional-error-sink`'s census reads exactly those three + * node kinds, and a pin file must observe the population, never join it. The + * sinks are named below through indexed-access and `Parameters<>` types only. + * + * The four pinned sinks: + * - `SharingServiceOptions['logger']` (public, tightened by PR #11856) + * - `ShareLinkServiceOptions['logger']` (public, tightened by PR #11856) + * - `SharingRuleServiceOptions['logger']` (public, tightened by PR #11856) + * - `sweepOrphanedRowsByRecordExistence`'s `logger` parameter + * (`record-orphan-cleanup.ts`'s module-local `MinimalLogger`, tightened + * by #10692 — the consumer that was blocked behind the three producers) + */ + +import type { SharingServiceOptions } from './sharing-service.js'; +import type { ShareLinkServiceOptions } from './share-link-service.js'; +import type { SharingRuleServiceOptions } from './sharing-rule-service.js'; +import type { sweepOrphanedRowsByRecordExistence } from './record-orphan-cleanup.js'; + +type SharingSink = NonNullable; +type ShareLinkSink = NonNullable; +type RuleSink = NonNullable; +type SweepSink = NonNullable[3]>; + +/** + * Never called — every line below is a type-level assertion evaluated by + * `tsc --noEmit`. `noop` and `anyCallable` exist only to give the literals + * members to carry. + */ +export function __pinLoggerWarnIsRequiredAndReal( + noop: (msg: any, ...rest: any[]) => void, + anyCallable: Function, +): void { + // ── NEGATIVE: a `{ info, error }`-only host logger must NOT compile. ───── + // This is the break the changeset declares, pinned as a refusal. The three + // producers declare `error?`, so the ONLY error each literal can produce is + // the missing required `warn` (TS2741) — nothing else can satisfy the + // directive. The sweep's sink declares no `error` member at all, so its + // refusal literal spells only `info` (an `error` key would trip the + // excess-property check instead and let a `warn?` regression hide). + // @ts-expect-error 'warn' is required on SharingServiceOptions['logger'] (#9754/#10692) + const sharingRefusesSilence: SharingSink = { info: noop, error: noop }; + // @ts-expect-error 'warn' is required on ShareLinkServiceOptions['logger'] (#9754/#10692) + const shareLinkRefusesSilence: ShareLinkSink = { info: noop, error: noop }; + // @ts-expect-error 'warn' is required on SharingRuleServiceOptions['logger'] (#9754/#10692) + const ruleRefusesSilence: RuleSink = { info: noop, error: noop }; + // @ts-expect-error 'warn' is required on the orphan sweep's logger (#10692) + const sweepRefusesWarnless: SweepSink = { info: noop }; + + // ── POSITIVE control: a bare `{ warn }` stub still compiles everywhere. ── + // Keeps the negatives honest (the aliases resolve, the literals are not red + // for some unrelated reason) and pins the documented promise that a minimal + // test stub stays a legal host logger. + const sharingAcceptsWarnOnly: SharingSink = { warn: noop }; + const shareLinkAcceptsWarnOnly: ShareLinkSink = { warn: noop }; + const ruleAcceptsWarnOnly: RuleSink = { warn: noop }; + const sweepAcceptsWarnOnly: SweepSink = { warn: noop }; + + // ── NEGATIVE: bare `Function` no longer satisfies the members. ─────────── + // `Function` is assignable to a concrete signature in neither direction, so + // this line is red exactly while the member carries a REAL signature — and + // goes green (failing the directive) if anyone loosens it back to + // `warn?: Function` or `warn: any` (#11069 taught the sink census to read + // bare `Function`; the members must not regress to it). + // @ts-expect-error a value of type Function does not satisfy SharingSink's real-signature 'warn' + const sharingRefusesBareFunction: SharingSink = { warn: anyCallable }; + // @ts-expect-error a value of type Function does not satisfy SweepSink's real-signature 'warn' + const sweepRefusesBareFunction: SweepSink = { warn: anyCallable }; + + void sharingRefusesSilence; + void shareLinkRefusesSilence; + void ruleRefusesSilence; + void sweepRefusesWarnless; + void sharingAcceptsWarnOnly; + void shareLinkAcceptsWarnOnly; + void ruleAcceptsWarnOnly; + void sweepAcceptsWarnOnly; + void sharingRefusesBareFunction; + void sweepRefusesBareFunction; +} diff --git a/packages/plugins/plugin-sharing/src/logger-shapes.ts b/packages/plugins/plugin-sharing/src/logger-shapes.ts index beb0ac1333..66916d4fc1 100644 --- a/packages/plugins/plugin-sharing/src/logger-shapes.ts +++ b/packages/plugins/plugin-sharing/src/logger-shapes.ts @@ -58,14 +58,16 @@ * is required because they FORWARD into `bulk-recompute.ts`'s guaranteed sink; * a `warn?` there re-opens the silence one module downstream of where #10556 * closed it. - * - `record-orphan-cleanup.ts` — `{ info?: Function, warn?: Function }`. Bare - * `Function` documents nothing, but it cannot be tightened to this shape: - * `Function` is NOT assignable to a concrete signature ("Type 'Function' - * provides no match for the signature"), and the two loggers handed to it — - * `SharingServiceOptions['logger']` and `ShareLinkServiceOptions['logger']` — - * are themselves spelled with bare `Function`. Tightening it requires - * tightening those producers first, which is a gate-population change, not a - * refactor. It stays open on #10692 rather than being done quietly here. + * - `record-orphan-cleanup.ts` — `{ info?, warn }`. Its `warn` is required + * because the sweep's every report lands there (#10692, ruled 2026-08-25); + * this type must stay optional-`warn` because its consumers are + * fire-and-forget projections whose reports are advisory. The bare-`Function` + * spelling it once carried could not tighten until the two producers feeding + * it — `SharingServiceOptions['logger']` and `ShareLinkServiceOptions + * ['logger']` — dropped bare `Function` (#10556 limb (c), PR #11856; + * `Function` is assignable to no concrete signature). Requiredness of every + * required `warn` in this package's public options types and in the sweep is + * pinned by `logger-required-warn.pin.ts`. */ export interface OptionalSharingLogger { info?: (msg: string, meta?: Record) => void; diff --git a/packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts b/packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts index 42db8b705e..8d6ed8ec5a 100644 --- a/packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts +++ b/packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts @@ -86,13 +86,30 @@ export interface OrphanShareSweepResult { } /** - * Structural and loose on purpose — it has to accept both owning services' - * option shapes (`SharingServiceOptions['logger']`, `ShareLinkServiceOptions`) + * Structural on purpose — it accepts both owning services' option shapes + * (`SharingServiceOptions['logger']`, `ShareLinkServiceOptions['logger']`) * and a bare `{ warn }` stub in a test. + * + * `warn` is REQUIRED, and the members carry the same real signatures as the + * producers that feed this parameter (#10692, ruled 2026-08-25). Every report + * this module emits lands on `warn` — the "could not check", "stopped early" + * and "revoked N rows" lines — so a logger without a guaranteed `warn` is one + * this sweep can lose its ONLY output into: #9754's silence rule, one module + * downstream of the producers #10556 tightened. Both owning services' `logger` + * members require `warn` since PR #11856, so they stay assignable; what stops + * compiling is a logger with no `warn`, which was never a useful argument + * here. The bare-`Function` spelling this replaces documented no call shape + * and could not tighten until those producers dropped bare `Function` + * (`Function` is assignable to no concrete signature). + * + * Deliberately NO `error` member — adding one would enrol this sink in + * `check:optional-error-sink`'s population (see `logger-shapes.ts`, "Why this + * shape declares no `error`"). Requiredness is pinned in + * `logger-required-warn.pin.ts`. */ interface MinimalLogger { - info?: Function; - warn?: Function; + info?: (msg: any, ...rest: any[]) => void; + warn: (msg: any, ...rest: any[]) => void; } /** How one caller's rows are named in this module's log lines. */