You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit dced781
Browse filesBrowse the repository at this point in the historyBrowse files
fix(cloud-connection): record an unmanaged copy from the row the import wrote (#31570)
domain:server#3 · os-project-manager · session_01FEkQ5pXkgNXVpCn2GZDjyy
Body: seat refresh (round 2) · 2026-11-08T16:33Z
Fixes#31530
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XJZfATGni9GCmv2TJRug9bZ)_
Clause-②: no — no published surface changes.
`ImportUnmanagedPackageResponse` and every REST answer are unchanged.
The import's new `baseInstance` is a door-only member of the in-process
answer, outside the spec type: the schema's parse strips it, and the
request's `actor` / `seedData` are the precedent. `publish`'s answer
(`PackagePublishResult`) gains two optional members, and no REST door
forwards it.
Metadata-protocol: none
## 维护者速读
1. 非托管安装(install-local 的 unmanaged copy)以前在导入之后再读一次 package store,拿基础包
`sys_packages` 行的 `version` 和 `created_at` 来绑定副本记录。那次读遇到普通故障会吞掉错误、返回
null,副本就不会被记录,之后 purge / reseed / count 都答 404。
2. 现在 service-package 的 `publish` 自己生成一个 UTC 时间戳,作为 `created_at` 和
`updated_at` 绑定进 upsert,并把 `{ version, createdAt }`
答回来。时间格式和数据库以前写的一样:`YYYY-MM-DD HH:MM:SS`(不是 ISO)。
3. metadata-protocol 的导入把这一行作为仅进程内的 `baseInstance` 带出来;install-local
直接用它绑定记录,安装时不再读 store。
4. 不改 spec,不改任何 REST 应答。表结构(DDL、latest 索引)不变;只是 upsert
的两个时间列从数据库函数改成了绑定参数,`package-table.ts` 里"逐字节照旧"的说明已改写,写明改了什么、为什么。
5. purge 时的再读和比对不变:那时的 seam 故障仍答 503,普通故障仍按 404 失败关闭。
## What changes
**`publish` states the stamp it writes**
(`@objectstack/service-package`). `publish` makes one UTC stamp per
write with `packageRowStamp(new Date())`, in the shape the column
already stores: `YYYY-MM-DD HH:MM:SS`, which is what `CURRENT_TIMESTAMP`
(standard) and `UTC_TIMESTAMP()` (mysql) produced. It binds that stamp
as the 6th and 7th arguments of both dialects' upserts, where the
statements used to call those functions, and answers `{ success: true,
version, createdAt }`. `createdAt` is the value the row holds after the
write.
- The update arm of the upsert still keeps the row's `created_at`, and
now sets `updated_at` from the bound value (`excluded.updated_at` /
`incoming.updated_at`).
- So before its upsert, `publish` reads the `(id, version)` row's
`created_at` (`PACKAGE_ROW_CREATED_AT_SQL`, one statement for every
dialect). When the row exists, it answers that value; when none exists,
it answers the stamp it bound. When that read faults or the seam answers
no result set, it answers no `createdAt`, and the write still lands. The
install-local door then fails closed, as for a package service that
states no stamp.
- `createTable` and `createLatestIndex` are unchanged, column defaults
included. The "byte-for-byte what shipped" note in `package-table.ts`
now says that about the DDL only, and a new section says what changed in
the upsert and why.
**The import carries the base row, door-only**
(`@objectstack/metadata-protocol`). `installPackage` accepts a door-only
`onStoredRow` hook beside its published request, outside
`InstallPackageRequest`. `persistPackageManifest` calls it when the
package service's write landed and named both `version` and `createdAt`.
`importUnmanagedPackage` passes the hook for the base manifest and
answers `baseInstance: { version, createdAt }` beside the published
`ImportUnmanagedPackageResponse`. Its declared return type is
`ImportUnmanagedPackageAnswer`, which is that response plus the optional
member. Nothing on the REST face carries a function or this member.
**install-local ties the copy record from that answer**
(`@objectstack/cloud-connection`). `unmanagedCopyInstance` is now
synchronous and reads no store:
- `result.baseInstance` with both members → the record is tied to it;
- no package service → no tie, as before;
- a package service that stated no stamp → `copyRecord.written: false`,
"the import did not answer with the new base's stored row", and the copy
doors answer 404. That is the fail-closed behaviour #30808 chose for a
missing row.
The `marketplace-install-local-unmanaged-copies.ts` module docblock
(#30808's design comment) is updated to say the install no longer reads
the row, what the stamp is, and that only the purge-time read can still
meet a store fault.
## An import over an already-stored base row (review)
The import refuses a `targetPackageId` the registry holds, never one
only the store holds. So it can reach an `(id, version)` row that is
already stored. This was measured on the real stack (ObjectQL,
better-sqlite3, `PackageServicePlugin`, the metadata protocol) in two
ways:
- **One process.** A stored base that the boot did not hydrate: its
`engines.protocol` excludes this runtime, so the boot refuses it and its
row remains.
- **Two processes on one store.** The second process booted before the
first one's import, so its registry never saw that base.
In both cases the upsert keeps the row's earlier `created_at`. At
`69952505`, `publish` answered the stamp it bound, so `baseInstance`
named a `created_at` the row did not hold. The copy record would then be
tied to it, and the purge would answer 404. On main, the install's
re-read tied that case correctly, so this was a regression. `4f4f565a`
fixes it with the read before the write described above.
## Residuals
- **A race between that read and the upsert.** A row inserted or deleted
by another writer in between makes the answer differ from the row. The
purge's comparison then answers 404, never a wrong delete.
- **PostgreSQL rows written before this.** `CURRENT_TIMESTAMP` cast to
TEXT on PostgreSQL carried fractional seconds and a `+00` offset. Rows
written from now on carry the shorter form. The `latest` order holds
across both forms, because date and time come first in the same order.
Only two rows of one package written within the same second across the
deploy could sort the other way.
## Tests
Head `cce139b6`: `4f4f565a`, the review fix, on top of `69952505`, then
a clean merge of origin/main `2c6b9364`. A1–A6 and red on main ran at
`893610b9`, the first commit before a clean rebase. B1–B3 and the
red-at-`69952505` check ran at `4f4f565a`. The suites, the gates and
lint ran on `cce139b6`.
Red on main `6f9f0086`, with the production files restored and the new
tests kept:
- `packages/services/service-package/src/package-table.test.ts:263`, the
describe "[#31530] publish answers the row's version and created_at,
from the write itself". 6 red on main:
- real SQLite: the answer equals what `get` then reads, by latest and by
version; the column holds the bound text; a re-publish keeps
`created_at` while `updated_at` takes the new stamp;
- mysql and sqlite: the upsert carries 7 arguments, and arguments 6 and
7 are the stamp the answer names;
- `packageRowStamp` is UTC `YYYY-MM-DD HH:MM:SS`.
-
`packages/metadata-protocol/src/package-unmanaged-install.test.ts:1368`.
The import answers the `baseInstance` the package service's write
answered, the published part still parses, and the parse strips
`baseInstance` (red on main). It answers none when the write named no
stamp, or when there is no package service.
-
`packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts`:
- `:1102` (red on main): an ordinary store fault, and a seam fault,
armed right after the import. The install answers 200 with the copy
recorded and never calls the store's `get`. The record's `instance`
equals what a later store read of that row answers, and the purge
reaches the copy (deletes 3).
- `:1134` (red on main): a package store that states no stamp answers
`copyRecord.written: false`, writes no record file, and the purge
answers 404.
- CONTROL, `:1074`, green on main and here: at purge, a gone row or an
ordinary store error fails closed with 404, and only the seam refusal is
a 503.
-
`packages/services/service-package/src/unmanaged-import-base-row.test.ts`
(new, real stack). A CONTROL with a new base; then the one-process and
two-process cases above. In each case, `baseInstance` equals the
`created_at` a later store read answers, which is the earlier row's. The
two cases are red at `69952505` and green at `4f4f565a`.
- `package-table.test.ts`:
- On real SQLite, a re-publish over an existing row answers that row's
`created_at`, which `get` then answers too, while `updated_at` takes the
new stamp.
- A read before the write that throws, or that answers no result set,
leaves the answer without `createdAt`, and the row is still written.
- The mysql and sqlite statement pins check that the read names the same
`(id, version)` and runs before the upsert.
- Existing pins of `publish(...)` answering `{ success: true }` in 5
service-package test files now expect the version and a stamp of that
shape.
Ablations, each run on the committed tree with
`scripts/ablation-replace.mjs`; every one went red:
| # | Mutation | Red |
|---|---|---|
| A1 | `publish` answers a stamp other than the bound one | 3 in
package-table.test.ts, including the SQLite equals-`get` pin |
| A2 | `persistPackageManifest` never calls `onStoredRow` | the
metadata-protocol `baseInstance` pin |
| A3 | the import drops `baseInstance` from its answer | the
metadata-protocol `baseInstance` pin |
| A4 | install-local ignores `result.baseInstance` | 18 in
install-modes, including `:1102` |
| A5 | the tie records another `created_at` | 13 in install-modes,
including `:1102` |
| A6 | a package store that states no stamp no longer fails closed |
`:1134` |
| B1 | `publish` always answers the bound stamp (at `4f4f565a`) | 4:
both real-stack cases, the SQLite re-publish pin, the fault pin |
| B2 | a read that answers no result set is taken as no row | the fault
pin |
| B3 | a thrown read is taken as no row | the fault pin |
Suites: service-package 135/135 and cloud-connection 1055/1055 on
`cce139b6`. metadata-protocol 22593 passed (26 skipped) and 6 objectql
files on the install and import path (87/87) ran at `893610b9`; the
review fix does not touch metadata-protocol. Typecheck is clean for all
three packages, including the cloud-connection test layer.
`dispatch-gates --ran` on `cce139b6`: 76 derived, 76 run, all exit 0, 0
NOT-MEASURED. ESLint on the three packages is clean.
## Acceptance notes
- **Sibling.** #30237 PR 5 touches `@objectstack/metadata-protocol`
(`deleteData`). Whichever lands second merges origin/main.
- **Dialects measured.** SQLite runs against a real database. MySQL is
pinned at the statement and its bindings. PostgreSQL is not measured on
a live database.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5pQJCx8STEq28nr3Ej)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(cloud-connection): an unmanaged copy is recorded from the row the import wrote, not from a store re-read at install
8
+
9
+
Clause-②: no
10
+
Metadata-protocol: none
11
+
12
+
Before this, the install-local door tied an unmanaged copy's record to the environment base's `sys_packages` row by reading the package store right after the import. That read swallows an ordinary fault and answers null, so a passing store fault at that moment left the copy with no record, and the purge, reseed and count doors answered 404 for a copy that was installed.
13
+
14
+
-**`publish` states the stamp it writes.** The package service now makes one UTC stamp per write, in the shape the column already stores (`YYYY-MM-DD HH:MM:SS`, what `CURRENT_TIMESTAMP` and `UTC_TIMESTAMP()` produced), and binds it as the row's `created_at` and `updated_at` in both dialects' upserts. `publish` answers `{ success: true, version, createdAt }`, where `createdAt` is the value the row holds after the write. On a re-publish of an existing (id, version) row the stored `created_at` stays the earlier one, as before (the update arm sets only `updated_at`), so `publish` reads that row's `created_at` before its upsert and answers it. When that read faults, the answer names no `createdAt`. The table DDL and the latest index are unchanged.
15
+
-**The import carries the base row, door-only.** The in-process `importUnmanagedPackage` answer gains `baseInstance: { version, createdAt }` when the package store stated them. It is outside the published `ImportUnmanagedPackageResponse`: the schema's parse strips it, and no REST answer carries it.
16
+
-**install-local ties the record from that answer.** The install no longer reads the package store. A package store that states no stamp fails closed as before: `copyRecord.written: false`, and the copy doors answer 404. The purge-time re-read and its comparison are unchanged.
it('[#31530] a passing store fault right after the import still records the copy, tied to the row a later read answers, and the purge reaches it',async()=>{
0 commit comments