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
29 changes: 11 additions & 18 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,14 @@
# stay routed here as directories: the driver still owns a same-category
# collision, which is the residue sharding cannot remove.
#
# One entry below is still a SINGLE file — spec-changes.json — and #8344 asked
# what the driver-less queue does to it. MEASURED (2026-08-13, the real in-flight
# case plus four synthetic pairs, each merged in a clone with no
# merge.os-regen.driver, over it and the protocol upgrade guide, then a second
# committed single file): the queue leaves neither stale-but-clean. Both were sorted
# unions and an ADR-0087 registration is insertion-only, so the queue's text merge
# either takes both sides — byte-identical to the regeneration, gates green — or
# conflicts outright. It conflicts only when the two in-flight entries are ADJACENT
# in registry sort order; one existing entry between them is already enough to
# merge clean AND current.
#
# Sharding it would not buy back an ejection, which is why it is still a single
# file: every conflicting case also conflicts in
# packages/spec/src/migrations/registry.ts — generated, committed, unsharded,
# NOT_DRIVER_MANAGED — and every registration touches it by construction. The table
# and the reproduction are in packages/spec/src/migrations/entries/README.md.
# packages/spec/spec-changes.json LEFT this list at #22485, because it left git: the
# #22449 B′ ruling generates it at publish (`scripts/release-spec-changes.sh` writes
# it into the package before the tarball is packed), the pull request generates it
# in memory, and the file is gitignored. NOT_DRIVER_MANAGED records `gen:spec-changes`
# as untracked output. A branch that still carries a regenerated copy meets a
# modify/delete conflict when it merges main, and the deletion is the resolution:
# `git rm packages/spec/spec-changes.json`, then commit — git runs no merge driver on
# a modify/delete, so nothing is deferred and no marker is left.
#
# docs/protocol-upgrade-guide.md LEFT this list at #22483 because nothing generates
# it any more. It is a hand-written pointer stub naming the guide's public address,
Expand All @@ -46,7 +38,9 @@
# forked before #22483 still defers the guide on its first merge of main; the merged
# tree has no row to discharge it against, so the next commit and the push are
# refused instead of passed (measured on PR #22556 with the previous head as the
# control, both merge directions).
# control, both merge directions). Since #22485 that refusal names the path as
# UNROUTED and prints the remedy that settles it — compare it with the merged-in
# side, keep the right bytes, `node scripts/check-regen-pending.mjs --release PATH`.
#
# `merge=os-regen` hands those paths to `scripts/git-merge-regen.mjs`, which does
# NOT text-merge them. See that file for why it also does not regenerate them
Expand Down Expand Up @@ -177,7 +171,6 @@
# case where two branches' rows do not overlap and the text merge exits 0 describing
# neither side.

packages/spec/spec-changes.json merge=os-regen
packages/spec/liveness/state-counts/** merge=os-regen
packages/spec/authorable-surface/** merge=os-regen
packages/spec/authorable-surface.base.json merge=os-regen
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,9 @@ examples/app-crm/storage/
# The release lane's scratch space: the previously published tarball it unpacks
# and the artifact it packs to verify the ADR-0087 D4 per-release section.
.release-spec-changes/
# The two ADR-0087 D4 projections `@objectstack/spec` ships: generated INTO the
# package by that lane (`scripts/release-spec-changes.sh --prepare` / `--generate`)
# right before the tarball is packed, and by `gen:spec-changes` locally. Never
# committed (#22449 B′, #22485); `files[]` ships whatever the lane wrote.
packages/spec/spec-changes.json
packages/spec/protocol-upgrade-guide.md
36 changes: 16 additions & 20 deletions packages/spec/scripts/build-spec-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
* WHERE it is generated (#22449 B′): at publish, by
* `scripts/release-spec-changes.sh`, which writes it into the package before
* `npm pack` and verifies the packed copy against a fresh generation. The
* pull-request stage generates it IN MEMORY (`--check`) and compares no
* committed copy, so a pull request that only adds a registry entry needs no
* regeneration commit; `render-projection-diff.ts` renders the generated diff
* against the base on the pull request instead. The command-line contract is
* `lib/projection-cli.ts`.
* pull-request stage generates it IN MEMORY (`--check`), so a pull request that
* only adds a registry entry needs no regeneration commit;
* `render-projection-diff.ts` renders the generated diff against the base on the
* pull request instead. There is NO committed copy (#22485): the package path is
* gitignored, and the no-flag mode writes that untracked file — the one the
* lane's `--prepare` / `--generate` put there for `files[]` to ship. The
* command-line contract is `lib/projection-cli.ts`.
*
* pnpm --filter @objectstack/spec gen:spec-changes # write the committed copy (until it is deleted)
* pnpm --filter @objectstack/spec gen:spec-changes # write packages/spec/spec-changes.json (gitignored)
* pnpm --filter @objectstack/spec check:spec-changes # CI: generate in memory, red when generation fails
* tsx scripts/build-spec-changes.ts --out <file> # write the generated bytes anywhere
*
Expand Down Expand Up @@ -61,17 +63,10 @@
* exists to end. `scripts/check-release-spec-changes.mjs` derives the same
* condition from the same artifacts and accepts the absence for the same reason.
*
* `spec-changes.json` itself stays a single file, deliberately (#5837), and #8344
* re-measured that call rather than inheriting it. The original reason — "two PRs
* append under different majors" — is not what actually holds: in-flight
* registrations land in the SAME (current) major, so what separates them is their
* distance in the registry's id sort order, not the major. What holds is the
* conclusion. This file is a sorted union of an insertion-only registration, so a
* driver-less server-side merge either takes both sides (byte-identical to the
* regeneration) or conflicts; it is never stale-but-clean, it conflicts only on
* ADJACENT ids, and in that case `src/migrations/registry.ts` — unsharded and
* outside the merge driver — conflicts too, so splitting this file would not save
* the PR. Measurement table: `../src/migrations/entries/README.md`.
* `spec-changes.json` is one file, not sharded (#5837, re-measured at #8344).
* That call was about MERGING a committed copy; since #22485 there is none, so
* nothing merges it and the question no longer arises. The measurement table it
* rested on stays in `../src/migrations/entries/README.md`.
*/

import { readFileSync, existsSync } from 'node:fs';
Expand All @@ -95,7 +90,8 @@ import { exitWith, runProjectionCli } from './lib/projection-cli';
import { API_SURFACE_DIR_NAME, readApiSurfaceFrom } from './lib/sharded-artifacts';

const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const SNAPSHOT = resolve(PKG_DIR, 'spec-changes.json');
/** The package's own copy — gitignored, written by the no-flag mode, shipped by `files[]`. */
const PACKAGE_COPY = resolve(PKG_DIR, 'spec-changes.json');
const SURFACE = resolve(PKG_DIR, API_SURFACE_DIR_NAME);
const CHECK = process.argv.includes('--check');
const prevSurfaceIdx = process.argv.indexOf('--previous-surface');
Expand Down Expand Up @@ -268,7 +264,7 @@ function build(): string {
);
const problem = surfaceScopeProblem(aggregate);
if (problem) {
console.error(`Refusing to write ${SNAPSHOT}: ${problem}`);
console.error(`Refusing to write ${PACKAGE_COPY}: ${problem}`);
process.exit(1);
}
const release = buildReleaseSection(aggregate);
Expand Down Expand Up @@ -329,7 +325,7 @@ function describeManifest(bytes: string): string {
exitWith(
runProjectionCli({
name: 'spec-changes.json',
committedPath: SNAPSHOT,
defaultPath: PACKAGE_COPY,
build,
describe: describeManifest,
argv: process.argv,
Expand Down
9 changes: 8 additions & 1 deletion packages/spec/scripts/check-generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,14 @@ const GATED: ReadonlyArray<{
gen: 'gen:migration-registry',
artifact: 'src/migrations/registry.ts — its generated regions, from src/migrations/entries/',
},
{ check: 'check:spec-changes', gen: 'gen:spec-changes', artifact: 'spec-changes.json' },
// Its committed copy is gone too (#22485): the publish lane writes the shipped one
// into the package, `gen:spec-changes` writes the same gitignored file locally,
// and the gate generates in memory, so it never reports stale.
{
check: 'check:spec-changes',
gen: 'gen:spec-changes',
artifact: 'spec-changes.json (gitignored; written into the package at publish)',
},
// Its committed copy is gone: `docs/protocol-upgrade-guide.md` is a hand-written
// pointer stub, and `gen:upgrade-guide` writes the docs pages that are the
// guide's address. The gate generates in memory, so it never reports stale.
Expand Down
39 changes: 22 additions & 17 deletions packages/spec/scripts/lib/projection-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@
* Both are pure functions of the D2 conversion table and the D3 migration chain.
* Since the #22449 B′ ruling they are generated where they ship — at publish, by
* `scripts/release-spec-changes.sh` — and the pull-request stage generates them
* IN MEMORY instead of comparing a committed copy:
* IN MEMORY. Neither has a committed copy any more (the guide's went at #22483,
* `spec-changes.json`'s at #22485):
*
* (no flag) write the bytes to the committed path. Kept only until the
* committed copies are deleted; nothing gates on that copy.
* A projection with no committed copy (the upgrade guide,
* whose no-flag mode writes its docs pages instead) passes
* no `committedPath`, and a bare run here is a usage error.
* (no flag) write the bytes to the projection's `defaultPath`. For
* `spec-changes.json` that is the package's own copy,
* `packages/spec/spec-changes.json`: gitignored, and the file
* `files[]` ships — the publish lane's `--prepare` /
* `--generate` run this mode right before `npm pack`. A
* projection with no such path (the upgrade guide, whose
* no-flag mode writes its docs pages instead) passes no
* `defaultPath`, and a bare run here is a usage error.
* --check generate in memory and write NOTHING. Green means the
* registries project; red means they do not. ⛔ No committed
* copy is read, so a pull request that only adds a registry
* registries project; red means they do not. ⛔ Nothing on
* disk is read, so a pull request that only adds a registry
* entry needs no regeneration commit.
* --out <path> write the bytes to `<path>`. The publish lane uses it to put
* the guide inside the package, and the pull-request diff
Expand All @@ -39,11 +43,12 @@ export interface ProjectionCliOptions {
/** The projection's name in every message, e.g. `spec-changes.json`. */
name: string;
/**
* The committed copy the no-flag mode writes. Absent when the projection has
* none — the upgrade guide's no-flag mode is its own (the docs pages,
* `build-upgrade-guide.ts`) — and then a bare run is a usage error.
* Where the no-flag mode writes: an untracked, gitignored path, never a
* committed copy. Absent when the projection has none — the upgrade guide's
* no-flag mode is its own (the docs pages, `build-upgrade-guide.ts`) — and then
* a bare run is a usage error.
*/
committedPath?: string;
defaultPath?: string;
/** The projection's bytes, from the registries. Throws when it cannot. */
build: () => string;
/** One clause describing generated bytes, for `--check`'s success line. */
Expand Down Expand Up @@ -79,8 +84,8 @@ export function runProjectionCli(opts: ProjectionCliOptions): ProjectionCliResul
const usage = (line: string): ProjectionCliResult => ({ code: 2, stdout: [], stderr: [line], wrote: null });

if (out === null) return usage(`--out needs a path: --out <file> (${opts.name}).`);
if (!check && out === undefined && opts.committedPath === undefined) {
return usage(`${opts.name} has no committed copy: pass --check (generate in memory) or --out <file>.`);
if (!check && out === undefined && opts.defaultPath === undefined) {
return usage(`${opts.name} has no default path: pass --check (generate in memory) or --out <file>.`);
}
if (check && out !== undefined) {
return usage(
Expand Down Expand Up @@ -110,15 +115,15 @@ export function runProjectionCli(opts: ProjectionCliOptions): ProjectionCliResul
code: 0,
stdout: [
`✓ ${opts.name} generates from the ADR-0087 registries, in memory${detail}.`,
' No committed copy is compared: the publish lane generates the shipped one and verifies it from the tarball.',
' No copy on disk is compared: the publish lane generates the shipped one and verifies it from the tarball.',
],
stderr: [],
wrote: null,
};
}

// `out ?? committedPath` is never undefined here: the usage check above refused that.
const target = resolve((out ?? opts.committedPath) as string);
// `out ?? defaultPath` is never undefined here: the usage check above refused that.
const target = resolve((out ?? opts.defaultPath) as string);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, bytes);
return { code: 0, stdout: [`Wrote ${target}`], stderr: [], wrote: target };
Expand Down
10 changes: 5 additions & 5 deletions packages/spec/scripts/projection-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// (`lib/projection-cli.ts`), which since the #22449 B′ ruling are generated at
// publish and generated IN MEMORY on a pull request:
//
// - `--check` reads and writes no committed copy, so a pull request that only
// - `--check` reads and writes no copy on disk, so a pull request that only
// adds a registry entry needs no regeneration commit;
// - a generation failure is red in every mode and writes nothing;
// - `--out` writes the generated bytes where it is told, and nowhere else.
Expand Down Expand Up @@ -40,7 +40,7 @@ afterEach(() => {
function options(over: Partial<ProjectionCliOptions> & { argv: string[] }): ProjectionCliOptions {
return {
name: 'fixture.json',
committedPath: path.join(tmp, 'committed', 'fixture.json'),
defaultPath: path.join(tmp, 'committed', 'fixture.json'),
build: () => '{"generated":true}\n',
...over,
};
Expand Down Expand Up @@ -119,14 +119,14 @@ describe('runProjectionCli — the two projections’ command-line contract', ()
expect(fs.readFileSync(committed, 'utf8')).toBe('{"before":true}\n');
});

it('with no flag it writes the committed copy', () => {
it('with no flag it writes the default path (the package copy, untracked since #22485)', () => {
const result = runProjectionCli(options({ argv: [] }));

expect(result.code).toBe(0);
expect(fs.readFileSync(path.join(tmp, 'committed', 'fixture.json'), 'utf8')).toBe('{"generated":true}\n');
});

it('with no flag and no committed copy it refuses as a usage error, builds nothing and writes nothing', () => {
it('with no flag and no default path it refuses as a usage error, builds nothing and writes nothing', () => {
let builds = 0;
const result = runProjectionCli({
name: 'fixture.json',
Expand All @@ -138,7 +138,7 @@ describe('runProjectionCli — the two projections’ command-line contract', ()
});

expect(result.code).toBe(2);
expect(result.stderr.join('\n')).toContain('fixture.json has no committed copy');
expect(result.stderr.join('\n')).toContain('fixture.json has no default path');
expect(result.wrote).toBeNull();
expect(builds).toBe(0);
expect(fs.readdirSync(tmp)).toEqual([]);
Expand Down
Loading
Loading