Skip to content

Commit 3ce4fc7

Browse files
committed
Merge origin/main into claude/issue-16952-import-protocol-typed-args
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
2 parents 739a6c0 + d57611d commit 3ce4fc7

14 files changed

Lines changed: 2490 additions & 68 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
feat(driver-sql)!: the file family's physical column holds the bare `sys_file` id, per deployment (#15989)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No spec key, no authored metadata property, no object definition, no accepted request shape and no `packages/spec` declaration changes spelling or shape in this diff: `FILE_REFERENCE_TYPES` and the ADR-0104 stored-value contract are READ here and not edited, and `packages/spec` is untouched. So `objectstack migrate meta` has nothing to rewrite, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain — the ledger's whole subject is authored metadata, and what moves here is a driver's choice of physical column plus one read-side decode. The row-data side of ADR-0104 already has its own declared, operator-run surface (`os migrate files-to-references`) and is not a metadata upgrade. The `**BREAKING**` banner below is carried rather than dropped, because published storage behaviour of `@objectstack/driver-sql` does change. -->
8+
9+
**BREAKING** on the published storage behaviour of `@objectstack/driver-sql`, under the maintainer ruling on #15041 (decision batch #49 item 1), verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」. The physical column for the file family — `file` / `image` / `avatar` / `video` / `audio` — holds the **actual `sys_file` id**, a bare id string in a string column, rather than a JSON-quoted id in a JSON column. The SQL generator already emitted `VARCHAR(2048)` for the family and does not move; the driver is the side that moves.
10+
11+
Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level.
12+
13+
**The switch is per DEPLOYMENT, and its default is today's encoding.** The ADR-0104 addendum forbids keying it on the `adr-0104-file-references` flag alone: every creation-attested store since 17.0 and every deployment that ran `os migrate files-to-references --apply` before a column step existed holds that flag *and* JSON-quoted ids in a JSON column. The evidence is `sys_migration.columns_moved_at`, which reaches this driver as the new published option `SqlDriverConfig.fileColumnsMoved` — a boolean or an async resolver, resolved once at `initObjects` and memoized. **Every way of not knowing answers "not moved"**: the option omitted, a resolver that throws, a resolver that never runs, a host that never calls `initObjects`. Absence is the JSON arm because every flag row that exists in the world today lacks the field, and a driver that guessed the other way would write bare ids into a JSON column.
14+
15+
**What an UNMOVED deployment gets** — which is every deployment until something supplies that option — is today's driver, with exactly one answer changed:
16+
17+
- the column is still `json` / `jsonb` / SQLite `TEXT`, the write still JSON-encodes, and `isJsonField` still answers `true` for the family;
18+
- a media cell whose bytes are a JSON-quoted id **sitting in a character column** now reads back as the id instead of as the id with its quotes. That population is not hypothetical: a database built by `os generate migration --format sql` has a `VARCHAR(2048)` media column, and MEASURED on live PostgreSQL 16.13, the driver wrote `"file_01HXYZ"` into it and handed it back verbatim — every consumer that matches the raw stored form (file resolution, ownership claims) refused it. SQLite never had this defect: its read arm parses the cell and keeps the raw string when the parse fails, which is why the gap was a server-dialect one.
19+
20+
**What a MOVED deployment gets**: the family leaves `JSON_COLUMN_TYPES`, so `isJsonField` / `formatInput` / `formatOutput` stop treating a single-value media field as JSON; `createColumn` builds `varchar(2048)` — the generator's own width, mirrored by `varcharColumnChars` so the drift detector reads the column the emitter actually builds; and the id on disk is the id. Throughout the window the read path accepts **both** encodings on every dialect, so a cell a column step has not converted still reads correctly. The decode is deliberately narrow — it engages only on a leading `"`, `{` or `[`, none of which can begin a `sys_file` id, a resolver URL or a `data:` URI — because an all-digit id would otherwise parse to a number.
21+
22+
`multiple: true` media is unaffected on both arms: its value is a list of ids, it is a JSON column on every deployment, and `createColumn` decides `multiple` above its type switch.
23+
24+
**The drift detector moves with the writer.** `JSON_COLUMN_FIELD_TYPES` no longer names the family, because the family is no longer a constant on either side; `diffManagedTable` takes a `fileColumnsMoved` input instead, and OMITTING it reproduces this module's previous verdicts exactly — an unthreaded caller keeps reporting a `varchar` media column as the corruption it still is on an unmoved deployment. Without this, a deployment that moved its columns would be told by its own tooling to convert them back to `json`, i.e. to undo the ruling.
25+
26+
**Not shipped here, and named rather than implied:** the column step itself. `os migrate files-to-references --apply` does not yet retype or rewrite media columns, and nothing in this diff moves any deployment's storage. A deployment moves only when it runs that step and its host supplies the arm, and the two must be one act — MEASURED on SQLite: after the columns are converted, a driver still on the JSON arm reads the migrated column correctly but its next write re-quotes.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
name: Board Snapshot
2+
3+
# The standing caller for `scripts/pm/board-snapshot.mjs` (#17390).
4+
#
5+
# ## Why a scheduled backup, and why it lands on a branch
6+
#
7+
# Three fleet accounts were suspended in two months and the appeals are
8+
# unanswered. #17374 F3 measured what a suspension destroys: every issue, pull
9+
# request and comment the account authored — while every branch and commit
10+
# survives, because those belong to the repository rather than to a user. The
11+
# board IS the fleet's state by rule (pm-dispatch SKILL.md, 全体座位的不变量:
12+
# 「GitHub 之外永不维护任何跟踪状态」), so today one suspension erases state that
13+
# nothing else holds; six cards, one of them a p1 security decision, are gone.
14+
#
15+
# The maintainer's answer, verbatim and untranslated, in chat on 2026-09-10:
16+
# 「或者用 ci 写个定时任务备份?」 — this file is that scheduled task. It runs as
17+
# `github-actions[bot]`, which is not a fleet account and cannot be suspended
18+
# with one, and it writes to an ORPHAN BRANCH of this same repository: no second
19+
# repo, no second credential, and a branch is precisely the thing a suspension
20+
# leaves standing.
21+
#
22+
# ## An archive is not a tracker — the one-board rule still holds
23+
#
24+
# ⛔ Nothing reads `board-archive` for state. Not a seat, not a patrol, not a
25+
# gate, not a query; every reading of the board still goes to GitHub. The
26+
# archive answers exactly one question, after a loss: what did the record say?
27+
# The script it runs has NO write path to GitHub in any mode — two of its
28+
# self-test cases read its own source and hold that structurally — and
29+
# `--restore` PRINTS a recreate payload for a seat to post, or for nobody to.
30+
#
31+
# ## Budget, and why a run may stop before it is finished
32+
#
33+
# `GITHUB_TOKEN` is limited to 1,000 requests per hour PER REPOSITORY, and this
34+
# job is not that budget's only caller (the half-state patrol and the closed-card
35+
# sweep share it). A first full snapshot of this board — several thousand
36+
# numbers, each with a comment thread — does not fit one run and must not try, so
37+
# the script stops at its own `--max-requests` ceiling, writes a resume cursor
38+
# into the manifest and exits 0. The next scheduled run continues from that
39+
# cursor. Four runs a day walk the backlog in a few days without ever exceeding
40+
# the budget, and a steady-state incremental run costs a few hundred requests.
41+
#
42+
# ⛔ On a real rate-limit refusal the script does NOT retry: it stops, writes the
43+
# cursor and exits non-zero with the reset time. A loop against a spent budget
44+
# starves every other automated caller in the repository for the rest of the hour.
45+
#
46+
# ## The `pull_request` trigger, and what it is allowed to do
47+
#
48+
# Every patrol in this tree exercises itself on the pull requests that change
49+
# it, and that is not a style preference: `check:pm-dispatch-gates` pins that NO
50+
# discovered gate family is reachable only through a schedule, so a workflow
51+
# whose script CI never runs at PR time reddens that gate by existing. A first
52+
# draft of this file omitted the trigger to keep PR-time cost at zero and was
53+
# refused by exactly that pin.
54+
#
55+
# So a pull_request run DOES execute the snapshot on a real runner — the
56+
# transport, the flags, the page walk and the rendering, proven where they will
57+
# actually run — and it writes NOTHING: no archive branch is checked out, the
58+
# run is `--dry-run` into the runner's temp dir, and the commit step is skipped.
59+
# It is also capped hard (`--limit`, `--max-requests` below), because the
60+
# repository's request budget is shared with the half-state patrol and the
61+
# closed-card sweep and must not be spent per push. ⛔ A pull request must never
62+
# write to the board OR to the archive.
63+
#
64+
# The offline half is held for free beside it: `check:pm-board-snapshot` runs the
65+
# script's 70-case `--self-test` inside the required `Lint & Repo Gates` job,
66+
# which has no paths filter (`check:self-test-wired` is what requires that step
67+
# to exist). This job runs the same self-test again before it touches the
68+
# archive, so a broken tool fails loudly instead of committing a broken backup.
69+
#
70+
# ## Adopting this in a sibling repo
71+
#
72+
# Copy FOUR files, unchanged: `scripts/pm/board-snapshot.mjs`,
73+
# `scripts/pm/check-half-states.mjs` (the archiver imports its proxy-re-exec plan
74+
# and its repo resolver), `scripts/invoked-as.mjs` (imported by both) and this
75+
# file. A repo that has already adopted the half-state patrol has the middle two.
76+
# ⛔ Do not shorten this list from memory — the imports decide it, not this
77+
# comment, and the patrol's own adopt list was wrong for months in exactly that
78+
# way, which installed a dead sweeper that failed before its first predicate.
79+
#
80+
# There is nothing to configure. The board archived is `github.repository`,
81+
# passed explicitly below, and the script REFUSES its resolver's default rather
82+
# than falling back to one: a copy of this file quietly archiving the repo it was
83+
# copied FROM would produce a complete, well-formed, green archive of the wrong
84+
# board.
85+
86+
on:
87+
schedule:
88+
# The half-state patrol's cadence, deliberately: four times a day, six hours
89+
# apart, at :37 past the hour — offset from the top of the hour where the
90+
# hourly triage Routine runs, so the two do not contend for the same minute
91+
# of the shared request budget. Six-hourly is the loss window this accepts:
92+
# a card created and destroyed inside one interval was never archived, and
93+
# nothing cheaper than a webhook closes that, which is a different card.
94+
- cron: '37 1,7,13,19 * * *'
95+
workflow_dispatch: {}
96+
# Changes to the archiver itself get exercised before they merge. The paths
97+
# name every file the run actually loads — the archiver, the module it imports
98+
# its proxy plan and repo resolver from, the entry-guard helper both import,
99+
# and this file. An undeclared dependency is one a change can break without
100+
# this trigger firing, which is the same defect the adopt list above warns
101+
# about, one layer along.
102+
pull_request:
103+
paths:
104+
- 'scripts/pm/board-snapshot.mjs'
105+
- 'scripts/pm/check-half-states.mjs'
106+
- 'scripts/invoked-as.mjs'
107+
- '.github/workflows/board-snapshot.yml'
108+
109+
# Least privilege. `contents: write` is for the archive BRANCH and nothing else;
110+
# the two read scopes are what the snapshot reads. ⛔ This job never writes to
111+
# `main` and never writes to any issue, pull request, label or comment — there is
112+
# no scope here that would let it, which is the mechanical half of the one-board
113+
# rule stated above.
114+
permissions:
115+
contents: write
116+
issues: read
117+
pull-requests: read
118+
119+
# One archiver at a time. Two runs racing the same branch would have the loser
120+
# push a snapshot built from a stale checkout, silently discarding the winner's.
121+
concurrency:
122+
group: board-snapshot
123+
cancel-in-progress: false
124+
125+
jobs:
126+
archive:
127+
name: Snapshot the board to the archive branch
128+
runs-on: ubuntu-latest
129+
timeout-minutes: 15
130+
steps:
131+
- name: Checkout repository
132+
uses: actions/checkout@v7
133+
134+
- name: Setup Node.js
135+
uses: actions/setup-node@v7
136+
with:
137+
node-version: '22'
138+
139+
# No `pnpm install`: the archiver imports only `node:` builtins, global
140+
# `fetch`, and two repo-local modules — no npm dependency, so installing
141+
# the workspace here would buy nothing and would give a scheduled backup a
142+
# lockfile it could fail on.
143+
- name: The tool's own cases, before it touches the archive
144+
run: node scripts/pm/board-snapshot.mjs --self-test
145+
146+
# The archive branch is an ORPHAN: it shares no history with `main`, so a
147+
# checkout of it carries the archive and nothing else. First run creates it
148+
# locally; the push below is what publishes it.
149+
- name: Check out or create the archive branch
150+
if: github.event_name != 'pull_request'
151+
run: |
152+
set -euo pipefail
153+
git fetch --depth=1 origin '+refs/heads/board-archive:refs/remotes/origin/board-archive' || true
154+
if git rev-parse --verify --quiet refs/remotes/origin/board-archive > /dev/null; then
155+
git worktree add archive -b board-archive refs/remotes/origin/board-archive
156+
echo "archive branch exists; checked out at $(git -C archive rev-parse --short HEAD)"
157+
else
158+
git worktree add --detach archive HEAD
159+
git -C archive checkout --orphan board-archive
160+
git -C archive rm -rf --quiet .
161+
# An orphan that still carries main's tree would commit the whole
162+
# repository into the archive branch on its first run, so this is
163+
# asserted rather than assumed.
164+
tracked=$(git -C archive ls-files | wc -l)
165+
if [ "$tracked" != "0" ]; then
166+
echo "::error::the new orphan branch still tracks $tracked file(s) from main; refusing to seed the archive with them."
167+
exit 1
168+
fi
169+
echo "archive branch created as an orphan (first run)"
170+
fi
171+
172+
- name: Run the snapshot
173+
id: snapshot
174+
env:
175+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
176+
# WHICH board this run archives: the repo this workflow is installed
177+
# in, always. A copy of this file cannot end up archiving the repo it
178+
# was copied FROM, and the script refuses to guess when this is unset.
179+
PM_SWEEP_REPO: ${{ github.repository }}
180+
# A pull_request run PROVES the tool on a runner and writes nothing:
181+
# the temp dir has no manifest, so it is a full read, capped to a
182+
# handful of numbers and a budget the shared quota will not notice.
183+
# ⛔ The two spellings differ in WHERE they write and in nothing else,
184+
# so what a PR exercises is the path a schedule takes.
185+
SNAPSHOT_ARGS: ${{ github.event_name == 'pull_request' && '--out=board --dry-run --limit=5 --max-requests=25' || '--out=archive/board' }}
186+
run: |
187+
set +e
188+
node scripts/pm/board-snapshot.mjs $SNAPSHOT_ARGS \
189+
> "$RUNNER_TEMP/snapshot.md" 2> "$RUNNER_TEMP/snapshot.err"
190+
code=$?
191+
set -e
192+
# Captured with NO pipe in between. `cmd | tail` reports the PIPE's
193+
# status — `tail` essentially never fails, so a clean snapshot and a
194+
# failed count check would both read as 0, and this script's exit
195+
# register (0 / 2 / 3 / 4) is the whole point of having one.
196+
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
197+
echo "board-snapshot exited $code"
198+
cat "$RUNNER_TEMP/snapshot.md" || true
199+
cat "$RUNNER_TEMP/snapshot.err" >&2 || true
200+
201+
# Land the truth, THEN raise the alarm: whatever the snapshot managed to
202+
# read is committed before the run is allowed to go red, so a failing count
203+
# check never costs the records the same run archived.
204+
- name: Commit and push the archive
205+
id: commit
206+
if: github.event_name != 'pull_request'
207+
run: |
208+
set -euo pipefail
209+
if [ -z "$(git -C archive status --porcelain)" ]; then
210+
echo "committed=none" >> "$GITHUB_OUTPUT"
211+
echo "no change on the board since the last run — nothing committed."
212+
exit 0
213+
fi
214+
git -C archive add -A
215+
files=$(git -C archive diff --cached --name-only | wc -l)
216+
git -C archive \
217+
-c user.name='github-actions[bot]' \
218+
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
219+
commit -q -m "board snapshot $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
220+
-m "run ${{ github.run_id }} · ${files} file(s) changed"
221+
git -C archive push origin board-archive
222+
echo "committed=${files}" >> "$GITHUB_OUTPUT"
223+
echo "pushed ${files} changed file(s) to board-archive"
224+
225+
- name: Publish the run to the summary
226+
if: always()
227+
run: |
228+
{
229+
echo "### Board snapshot — exit ${{ steps.snapshot.outputs.exit_code }}"
230+
echo
231+
echo "Committed: ${{ steps.commit.outputs.committed || 'nothing (the run stopped before the commit step)' }}"
232+
echo
233+
echo '```'
234+
cat "$RUNNER_TEMP/snapshot.md" 2>/dev/null || echo '(no report produced)'
235+
echo '```'
236+
echo
237+
echo '<details><summary>stderr</summary>'
238+
echo
239+
echo '```'
240+
cat "$RUNNER_TEMP/snapshot.err" 2>/dev/null || true
241+
echo '```'
242+
echo
243+
echo '</details>'
244+
} >> "$GITHUB_STEP_SUMMARY"
245+
246+
- name: Fail the run if the snapshot did not read the board
247+
if: steps.snapshot.outputs.exit_code != '0'
248+
run: |
249+
echo "::error::board-snapshot exited ${{ steps.snapshot.outputs.exit_code }} — 2 is a failed count check (the archive and the board disagree about how many open issues exist), 3 is a prerequisite the runner did not meet, 4 is a rate-limit stop with a resume cursor written. Whatever was read HAS been committed; see this run's summary."
250+
exit 1

0 commit comments

Comments
 (0)