Skip to content

0.13.0

Choose a tag to compare

@somework somework released this 07 Sep 17:28
· 3 commits to main since this release

Additive: the predicates of the three optional packages move into the core, so that an adapter can ask "is
indexnowkit/sitemap installed?" without loading a class that lives in indexnowkit/sitemap.

Added

  • Adapter\OptionalPackage::sitemap(), verify(), history() (each ?bool $installed = null): the Composer names,
    the marker classes (Sitemap\SitemapReader, Verify\PageSignals, History\HistoryConfig, written as strings — the
    core names no class of a package it does not require) and the feature words of the family's optional packages, in
    the core. Sitemap\Adapter\SitemapServices::package(), Verify\Adapter\VerifyServices::package() and
    History\Adapter\HistoryServices::package() delegate to them (sitemap 0.7.1, verify 0.3.1, history 0.3.1); an
    adapter calls the core's method directly. Before, the Symfony bundle, the Laravel and the Yii2 adapters called the
    packages' *Services::package() unconditionally, which is a Class "IndexNowKit\Sitemap\Adapter\SitemapServices" not found at the first configuration build of an application without the package (symfony-bundle 0.14.1, laravel 0.14.1,
    yii2 0.13.1 fix it; the tests of wave I passed installed: false with the packages still installed, so they did not
    catch it). docs/adapters.md §2 "Optional packages" names the new recipe; docs/bc.md lists Adapter\OptionalPackage
    in the "call" tier, where it always belonged.

Then the six-lens audit of 0.13 (docs/plans/audit-0.13.md in the specification workspace), run on this wave before it
was tagged. Everything below went into the same unreleased minor, so there is no 0.13.0 in the wild without it. Two of
the findings lost URLs silently: an insert whose verifier compared a DECIMAL column never announced the new page, and a
dry_run given as the string "false" read as true. The rest is the shape of the API the next adapter is written
against, plus the four constructions every adapter had copied.

Added

  • What every router bridge, queue dispatcher and locale check of the family had copied, as core classes (wave M,
    spec 19). Url\RouteOrigin (static, "call" tier): expand() — the locales a rule generates for, 'all' over the
    adapter's list, with one warning per process when that list is empty (the rule silently collapsing to one URL was
    logged by the Laravel bridge only; the other three now say it too); pinnedRoot() — hosts.<host>.base_url, else
    https://<host>; rebase() — scheme, host and port of an origin over the path, query and fragment of a generated
    URL; generationFailed() and noRequestHost() — the two ConfigurationExceptions, one text each (four bridges had
    four spellings of Cannot generate route). Dispatch\BatchingDispatcher(Closure $enqueue, Config, ?Logger, string $noun = 'job'): one unit per batch.max_urls, newJobId() (twelve hex characters: three adapters had
    bin2hex(random_bytes(6)) each), a push that throws logged as cannot queue {count} URL(s) ({noun} {id}), they are lost with Config::logSample() and never rethrown; the adapters' dispatchers are the push closure around it.
    Check\LocalesCheck($locales, $rules, Closure $classes, $option = 'router.locales', ?string $parameter = null), code
    router.locales: the one check three adapters printed with three texts — the warning names the option and up to
    three classes ("and N more"), the ok line names the locales and the route parameter when the adapter gives one, a
    class whose rules cannot be read is skipped. Check\DispatchLine::describe($dispatch): the sync / none
    sentence of the adapters' queue checks.
  • Debounce\DebounceStoreFactory::isShared(?string $store): whether debounce.store names a cache shared by
    every process, the question nine places across five packages answered with their own in_array($store, [MEMORY, NONE], true) (the bundle with the literals). Check\DebounceStoreCheck::PROBE_KEY (indexnowkit_check): the
    key every adapter's probe writes — two adapters wrote indexnowkit:check, and : is a character PSR-16 reserves,
    so a strict cache (yiisoft/cache) refused the probe and check reported a working store as broken. The docblock of
    the check says "writes a test key" now; two probes only read one.
  • Adapter\OptionalPackage knows the options its package owns: a fifth constructor argument, ?string $optionsProvider — the name of the package's *Services::options() as a string, so the core still names no class
    of a package it does not require — filled by sitemap(), verify(), history(); options() calls it only when
    installed() says yes; the static ownedOptions(list<OptionalPackage>) and ignoredBlocks(list<OptionalPackage>)
    fold a list of predicates into the two arguments of Adapter\ConfigFactory, which three adapters built from eight
    identical lines each. A fourth package is one line in the core now, not three edits in three adapters.
  • Http\Psr18Transport::discover() and Http\TransportFactory::lazy() take the application's PSR-17 factories
    (?RequestFactoryInterface $requestFactory, ?StreamFactoryInterface $streamFactory, appended and optional): a
    PSR-7 stack with a Psr17Factory service of its own (Slim, Mezzio, Yii3) hands it over instead of having
    php-http/discovery find one; null discovers, as before. The docblock of sendRequest() says why the two PSR-18
    exception subtypes are not told apart; docs/adapters.md §12 says what the transport does about redirects (nothing
    the standard does not: off on the clients it builds, the application's own client keeps its defaults) and §13 why the
    core takes PSR-16 only (wrap a PSR-6 pool in the view your ecosystem ships) and §11 which key-file recipe each stack
    gets. Testing\ArrayLogger's docblock says it does not check the level — a double, not a PSR-3 logger.
  • Key\KeyFileRequestHandler (wave L, spec 18 §3.5): GET /<key>.txt over PSR-7 for any PSR-15 stack — a
    RequestHandlerInterface (the action of a route: the key is read off the path), a MiddlewareInterface (in front
    of the router: a request it does not serve goes on to the next handler untouched) and respond(?string $key, $request)
    for a key the router already extracted. The body is KeyFileResponder's, the headers are Config::keyFileHeaders(),
    the host is the request URI's; fromConfig($config, $keys, $responseFactory, $streamFactory) builds it over the
    adapter's key provider and PSR-17 factories. Nothing changes in the semantics — only the PSR-7 response is built in
    one place instead of in every adapter (the Yii3 handler delegates to it). psr/http-server-handler and
    psr/http-server-middleware join require (interfaces only; the core stays PSR-only).
  • Adapter\SubmitterFactory takes ?ClockInterface $clock as its last parameter and passes it to the Submitter
    it builds; Adapter\Services::submitterFactory() gives it the graph's clock node (A1). Appended and optional, so
    the "call" tier holds. Without it the clock node stopped at the application's submitter: Testing\FrozenClock left
    submit, submit-record and sitemap on the wall clock, the submission history recorded commands and requests on
    two different clocks, and the debounce window was counted twice over.
  • Adapter\Services::requireRouter() and requireResolverLocator() (A9): the same nodes as router() and
    resolverLocator(), but typed non-nullable and throwing a ConfigurationException that names the missing node. An
    adapter that always wires one wrote ?? throw with a message nobody ever read; two adapters had shipped that dead
    branch before this existed.
  • The events closure of Adapter\ServicesBuilder may return null (A8), like the nullable nodes. An adapter
    whose container may or may not hold a Psr\EventDispatcher\EventDispatcherInterface now answers that inside the
    closure instead of asking the container during build(), which is documented to do no IO.
  • Check\SampleGateCheck over Check\SampleOptions (A2): the --sample / --sample-class gate in front of the
    optional indexnowkit/verify, in the core because it has to load without the package. All four adapters carried a
    byte-identical copy of the class, the holder and the two user-facing sentences. Every line it writes carries the code
    verify.installed, which is what the adapters always printed — docs/check-codes.md had documented verify.sample
    for it, a code that exists only while the package does (D1).
  • Hook\ObserverHelper::forKit() next to forChanges() (A23): the plain case as a named constructor, so the
    union-typed constructor with its conditionally required $sink is no longer the way in.
  • Transaction\VerifyingStaging::stage() takes a subject key (?string $key, appended) and merges a later change
    of the same subject into the entry already staged, keeping both sets of URLs and the newer verifier (R4). Without it
    two writes to one row inside one transaction staged two entries, both verified at the end against the last state
    of the row, so the first one's URLs were dropped as "did not land" — including the old URL of a two-step rename.
  • Url\ArrayResolverLocator wraps a throwing container lookup in a ConfigurationException naming the id
    (IndexNow URL resolver "%s" cannot be built by the container: %s) (A10). Three adapters had copied that try/catch
    with three different sentences and the fourth had none at all, so a container error escaped it raw, past
    IndexNowException. Adapters hand locate: a bare lookup now.

Fixed

  • PHP 8.5 without deprecations, phpstan on PHP 8.4+ clean — Url\ViaWalk uses the SplObjectStorage offset methods
    (attach()/contains() are deprecated since 8.5), Url\UrlNormalizer decodes a percent-escape with hex2bin() and
    Attribute\RuleCompiler::compile() is @template T of object over its ReflectionClass<T> (the newest stubs make the
    template invariant). No runtime change.
  • Transaction\VerifyingStaging::rowMatches() compares only unambiguous values — integers, strings, booleans,
    backed enums and null — and treats an empty expectation as "the row exists" (R1). The row comes back raw from the
    driver, so 19.9 written by the application met '19.90' from a DECIMAL column, a timestamptz came back with a
    zone suffix, and JSON came back in the database's own spelling. Inserts staged every non-null property, so one
    such column dropped the announcement of every new page of that class, at debug level. Floats, dates, arrays and
    objects are now skipped like a column the row does not carry.
  • Config::fromArray() parses dry_run, enabled, strict_hosts and collector.detect_leaks through the same
    boolean parser as every neighbouring key instead of casting with (bool) (W1). An adapter that hands an environment
    variable straight to fromArray() read INDEXNOW_DRY_RUN=false as true, and dryRunExplicit as true with it, so
    check reported a deliberate choice while production submitted nothing.
  • Config::baseHost() returns the host as punycode, lower-cased (S4), the same form Url\UrlNormalizer produces.
    With an internationalized base_url and strict_hosts the two spellings never matched, so every URL of the site was
    skipped with no_key — fail-closed, and with no line saying why.
  • Config::keyFileHeaders() adds Vary: Host under strict_hosts too, not only with a hosts map (S3). With
    strict_hosts and a base_url the key file answers 200 for the base host and 404 for every other one, so a shared
    cache or CDN kept whichever of the two came first; a cached 404 turns every submission into a 403.
  • Url\UrlNormalizer normalizes percent-encoding per RFC 3986 §6.2.2: an escape of an unreserved character
    (A-Za-z0-9-._~) becomes the character, every other escape is upper-cased (W9). /%7Euser and /~user are one URL
    now, debounced, submitted and recorded once.
  • Retry\ForbiddenCounter::key() drops the brackets and the colons of an IPv6 host (S10). A host like [::1] put
    a colon — a PSR-16 reserved character — into the cache key, so a strict cache rejected the read and the write: the
    counter of that host degraded to one warning per 403 and the critical escalation never fired for it.

Changed

  • Config.php is 600 lines instead of 1033 (audit 0.13 W11, deferred since 0.10; docs/plans/config-decomposition.md
    in the specification workspace). Reading the array and the environment shapes moved to Config\ConfigParser
    (fromArray(), fromEnv(), serveKeyFileFrom(), the "3" / "true" scalar parsing, INDEXNOW_HOSTS), the
    normalisation of the raw maps to Config\ConfigNormalizer (hosts, engine_aliases, locale_hosts, logging.levels,
    normalizer.tracking_params, production_environments, the URL predicates); both are @internal. Config::with()
    builds its map of current values from the constructor's signature by reflection instead of a hand-written list of 44
    names. Nothing on the surface changed: the same properties, constants, methods and messages — ConfigParityTest holds
    the snapshot of all of them taken before the move (properties, toArray(), derived methods, the exception of every
    invalid case) and stays as the regression guard.