Skip to content

Commit dced781

Browse 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>
1 parent 58ab03d commit dced781

16 files changed

Lines changed: 575 additions & 75 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-package': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/cloud-connection': patch
5+
---
6+
7+
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.

‎packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts‎

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,13 @@ describe('install-local — an unmanaged copy\'s sample data, asked for with `se
662662
* driver error (a lock timeout, a dropped connection) is caught, logged and
663663
* answered `null`; only the seam-unreadable refusal escapes, as a 503.
664664
*/
665-
function world(opts: { packageStore?: boolean; grantless?: boolean } = {}) {
665+
function world(opts: {
666+
packageStore?: boolean; grantless?: boolean;
667+
/** [#31530] A passing store fault armed when the import returns: the next store `get` meets it. */
668+
faultAfterImport?: 'ordinary' | 'seam';
669+
/** [#31530] The import's write names no stored row (a package service that states no stamp). */
670+
noBaseInstance?: boolean;
671+
} = {}) {
666672
const tables: Record<string, Row[]> = {};
667673
const objects = new Map<string, any>();
668674
const packages = new Map<string, { manifest: Record<string, unknown> }>();
@@ -687,7 +693,8 @@ describe('install-local — an unmanaged copy\'s sample data, asked for with `se
687693
return null;
688694
}
689695
});
690-
const storeFault = (kind: 'ordinary' | 'seam') => { fault = kind; };
696+
/** Arm a fault for the next store `get`; `undefined` disarms one that was never met (a passing fault). */
697+
const storeFault = (kind: 'ordinary' | 'seam' | undefined) => { fault = kind; };
691698
const writes: string[] = [];
692699
let nextId = 0;
693700
const known = (object: string) => {
@@ -763,7 +770,8 @@ describe('install-local — an unmanaged copy\'s sample data, asked for with `se
763770
});
764771
}
765772
packages.set(targetPackageId, { manifest: { id: targetPackageId, namespace: source.namespace } });
766-
packageRows.set(targetPackageId, { version: '1.0.0', created_at: `2026-11-08 07:00:${String(++stamp).padStart(2, '0')}` });
773+
const baseRow = { version: '1.0.0', created_at: `2026-11-08 07:00:${String(++stamp).padStart(2, '0')}` };
774+
packageRows.set(targetPackageId, baseRow);
767775
const copied: Array<{ type: string; name: string }> = [];
768776
for (const o of source.objects ?? []) {
769777
objects.set(o.name, o);
@@ -778,7 +786,12 @@ describe('install-local — an unmanaged copy\'s sample data, asked for with `se
778786
copied.push({ type: 'seed', name });
779787
}
780788
}
781-
return { sourcePackageId: source.id, targetPackageId, namespace: source.namespace, copiedCount: copied.length, copied, strippedLocks: [] };
789+
if (opts.faultAfterImport !== undefined) fault = opts.faultAfterImport;
790+
return {
791+
sourcePackageId: source.id, targetPackageId, namespace: source.namespace, copiedCount: copied.length, copied, strippedLocks: [],
792+
// [#31530] The base row as the import's own write answered it; none with no package service.
793+
...(opts.packageStore === false || opts.noBaseInstance ? {} : { baseInstance: { version: baseRow.version, createdAt: baseRow.created_at } }),
794+
};
782795
});
783796
// [#31425] The metadata service's object read the seed run's dependency graph asks; a spy, so a case can make one load throw.
784797
const metadataGetObject = vi.fn(async (name: string) => objects.get(name));
@@ -1086,6 +1099,27 @@ describe('install-local — an unmanaged copy\'s sample data, asked for with `se
10861099
expect(res.payload.data).toMatchObject({ deleted: 3, errors: 0 });
10871100
});
10881101

1102+
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 () => {
1103+
for (const kind of ['ordinary', 'seam'] as const) {
1104+
rmSync(join(dir, 'unmanaged-copies'), { recursive: true, force: true });
1105+
const w = world({ faultAfterImport: kind });
1106+
const h = await w.boot();
1107+
const res = await h.install({ manifest: seededCrm(), installMode: 'unmanaged', seedSampleData: true });
1108+
expect(res.status, `${kind}: ${JSON.stringify(res.payload)}`).toBe(200);
1109+
expect(res.payload.data).not.toHaveProperty('copyRecord');
1110+
// Nothing read the store at install: the fault is still armed. It passes.
1111+
expect(w.packageGet).not.toHaveBeenCalled();
1112+
w.storeFault(undefined);
1113+
// The record is tied to exactly what a later store read answers for the row.
1114+
const record = JSON.parse(readFileSync(COPY_RECORD(), 'utf8'));
1115+
const row = await w.packageGet('com.example.crm-copy');
1116+
expect(record.instance).toEqual({ version: row!.version, createdAt: row!.created_at });
1117+
const purged = await h.purge('com.example.crm-copy');
1118+
expect(purged.status, `${kind}: ${JSON.stringify(purged.payload)}`).toBe(200);
1119+
expect(purged.payload.data).toMatchObject({ deleted: 3, errors: 0 });
1120+
}
1121+
});
1122+
10891123
it('with no `package` service the record holds no instance and the purge reaches the copy by id alone (the stated residual)', async () => {
10901124
const w = world({ packageStore: false });
10911125
const h = await w.boot();
@@ -1111,21 +1145,20 @@ describe('install-local — an unmanaged copy\'s sample data, asked for with `se
11111145
expect((await h.purge('com.example.crm-copy')).status).toBe(404);
11121146
rmSync(join(dir, 'unmanaged-copies'), { force: true });
11131147
}
1114-
// The package store meets a fault at install — an ordinary error it
1115-
// answers null, or the seam refusal it throws: no record is written,
1116-
// and the copy stays unrecorded (re-recording is #31425).
1117-
for (const kind of ['ordinary', 'seam'] as const) {
1118-
const w = world();
1148+
// [#31530] The import names no stored row while a package service is
1149+
// mounted (a service that states no stamp): no record is written, and
1150+
// the copy stays unrecorded, failing closed (re-recording is #31425).
1151+
{
1152+
const w = world({ noBaseInstance: true });
11191153
const h = await w.boot();
1120-
w.storeFault(kind);
11211154
const res = await h.install({ manifest: seededCrm(), installMode: 'unmanaged', seedSampleData: true });
1122-
expect(res.status, `${kind}: ${JSON.stringify(res.payload)}`).toBe(200);
1155+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
11231156
expect(res.payload.data.copyRecord).toEqual({
1124-
written: false, message: expect.stringContaining('the package store did not answer with the new base\'s row'),
1157+
written: false, message: expect.stringContaining('the import did not answer with the new base\'s stored row'),
11251158
});
1126-
expect(res.payload.data.copyRecord.message).not.toContain('SQLITE_BUSY');
11271159
expect(existsSync(COPY_RECORD())).toBe(false);
11281160
expect((await h.purge('com.example.crm-copy')).status).toBe(404);
1161+
rmSync(join(dir, 'unmanaged-copies'), { recursive: true, force: true });
11291162
}
11301163
// A record that is kept adds no key to the answer.
11311164
{

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4869,10 +4869,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
48694869
// operator in the log.
48704870
let copyRecordFailure: string | undefined;
48714871
let copyRecordSaid: string | undefined;
4872-
const tie = await this.unmanagedCopyInstance(ctx, result.targetPackageId);
4872+
const tie = this.unmanagedCopyInstance(ctx, result.targetPackageId, result.baseInstance);
48734873
if ('failure' in tie) {
48744874
copyRecordFailure = tie.failure;
4875-
copyRecordSaid = 'the package store did not answer with the new base\'s row';
4875+
copyRecordSaid = 'the import did not answer with the new base\'s stored row';
48764876
} else {
48774877
try {
48784878
this.unmanagedCopies.write({
@@ -5892,34 +5892,32 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
58925892
};
58935893

58945894
/**
5895-
* [#30808] The base instance a new copy's record is tied to: the package
5896-
* store's row of `baseId` ({@link readUnmanagedCopyInstance}). No
5897-
* `instance` on a runtime with no `package` service (the record's reach is
5898-
* then by id alone — the residual its module docblock states); a
5899-
* `failure`, and so no record, when the store holds no readable row —
5900-
* which is also what a passing store error reads as, since the store
5901-
* answers `null` for it. That copy stays unrecorded (`copyRecord` says
5902-
* so); no door records it later. The copy is still installed, and
5903-
* reseed, count and purge answer 404 for it (#31425 measured: the store's
5904-
* `null` cannot be told from "no row" here; a follow-up has the import
5905-
* answer the base row's stamp itself).
5895+
* [#30808, #31530] The base instance a new copy's record is tied to: the
5896+
* base's `sys_packages` row (`version`, `created_at`) as the import's own
5897+
* write answered it (`baseInstance`), never a read of the store after
5898+
* the import. A read could meet a passing store fault, which the store's
5899+
* `get` answers as `null`, and leave the copy unrecorded for good (#31425).
5900+
* No `instance` on a runtime with no `package` service (the record's
5901+
* reach is then by id alone: the residual its module docblock states). A
5902+
* `failure`, and so no record, when there is a `package` service but the
5903+
* import named no stored row (a package service that states no stamp):
5904+
* the copy then stays unrecorded and `copyRecord` says so, failing closed
5905+
* as before. The purge, reseed and count doors read the row again and
5906+
* compare it with this instance ({@link readUnmanagedCopyInstance}).
59065907
*/
5907-
private unmanagedCopyInstance = async (
5908+
private unmanagedCopyInstance = (
59085909
ctx: PluginContext,
59095910
baseId: string,
5910-
): Promise<{ instance?: UnmanagedCopyInstance } | { failure: string }> => {
5911+
stored: { version?: unknown; createdAt?: unknown } | undefined,
5912+
): { instance?: UnmanagedCopyInstance } | { failure: string } => {
5913+
if (typeof stored?.version === 'string' && stored.version !== ''
5914+
&& typeof stored.createdAt === 'string' && stored.createdAt !== '') {
5915+
return { instance: { version: stored.version, createdAt: stored.createdAt } };
5916+
}
59115917
let store: Partial<UnmanagedCopyPackageStore> | undefined;
59125918
try { store = ctx.getService<Partial<UnmanagedCopyPackageStore>>('package'); } catch { return {}; /* no package store */ }
59135919
if (typeof store?.get !== 'function') return {};
5914-
let held: UnmanagedCopyInstance | null | undefined;
5915-
try {
5916-
held = await readUnmanagedCopyInstance(store as UnmanagedCopyPackageStore, baseId);
5917-
} catch (err: any) {
5918-
return { failure: `the package store could not be read for '${baseId}': ${err?.message ?? err}` };
5919-
}
5920-
if (held === null) return { failure: `the package store holds no row of '${baseId}'` };
5921-
if (held === undefined) return { failure: `the package store's row of '${baseId}' carries no readable version and created_at` };
5922-
return { instance: held };
5920+
return { failure: `the import named no stored row of '${baseId}' (its package store stated no version and created_at)` };
59235921
};
59245922

59255923
/**

‎packages/cloud-connection/src/marketplace-install-local-unmanaged-copies.ts‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,36 @@
3636
* or an uninstall racing the record write. A later base under the same id —
3737
* a Studio duplicate also defaults to `<sourceId>-copy` — must not inherit
3838
* the purge. So the record also keeps the base's `instance`: the `version` and
39-
* `created_at` of the base's row in the package store (`sys_packages`, read
40-
* through the `package` service's `get`). The store mints `created_at` when it
41-
* first inserts the row, keeps it when the same base is published again, and
42-
* an uninstall deletes the row, so a later base under the same id gets a row of
43-
* its own. The purge reads the row again and answers 404 when it is gone or its
44-
* `created_at` differs.
39+
* `created_at` of the base's row in the package store (`sys_packages`). At
40+
* install they come from the import's own answer (`baseInstance`, #31530): the
41+
* package service's `publish` answers the `created_at` the row holds after its
42+
* write (the stamp it bound for a new row, the row's own for one already
43+
* stored), so the door never reads the row back after the copy. The store sets `created_at`
44+
* when it first inserts the row, keeps it when the same base is published
45+
* again, and an uninstall deletes the row, so a later base under the same id
46+
* gets a row of its own. The purge reads the row through the `package`
47+
* service's `get` and answers 404 when it is gone or its `created_at` differs.
4548
*
4649
* What it leaves:
4750
* - a runtime with no `package` service persists no base row; the record
4851
* then holds no `instance`, and the purge's reach is by id alone — the
4952
* condition above stays open there (such a base does not survive a
5053
* restart either);
51-
* - `created_at` is the store's `CURRENT_TIMESTAMP`, so a base uninstalled
54+
* - `created_at` is a `YYYY-MM-DD HH:MM:SS` UTC stamp, so a base uninstalled
5255
* and re-created under the same id within the same second shares it;
56+
* - a package service whose `publish` states no stamp (including one whose
57+
* read of an already-stored row before its write faulted) leaves the import with
58+
* no `baseInstance`; the door then writes no record and `copyRecord` says
59+
* so (it fails closed, and no door writes that record later: the record's
60+
* proof is the door's own act at install, and neither the base row nor
61+
* its receipt marks a base as a copy, #31425);
5362
* - the store's `get` catches an ordinary driver error (a lock timeout, a
5463
* dropped connection), logs it and answers `null`; only its
55-
* seam-unreadable refusal is thrown. So a passing store error reads as
56-
* "no row": at purge the copy answers 404 (the purge refuses, nothing is
57-
* deleted), and at install no record is written and `copyRecord` says so;
58-
* no door writes that record later: the record's proof is the door's own
59-
* act at install, and neither the base row nor its receipt marks a base as
60-
* a copy (#31425). Both fail closed. Only the seam refusal reaches the
61-
* purge as a 503.
64+
* seam-unreadable refusal is thrown. At install this no longer matters
65+
* (#31530: nothing is read there). At purge, reseed and count a passing
66+
* store error reads as "no row", and the copy answers 404 (the door
67+
* refuses, nothing is deleted); it fails closed, and the next call reads
68+
* the row again. Only the seam refusal reaches those doors as a 503.
6269
*/
6370

6471
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';

‎packages/metadata-protocol/src/package-unmanaged-install.test.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
*/
3333
// Pays the lint root's first load at collection, never inside a clocked case.
3434
import './lint-root-preload.testkit.js';
35-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
35+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3636
import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
3737
import { ImportUnmanagedPackageResponseSchema } from '@objectstack/spec/api';
3838
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '@objectstack/spec/kernel';
@@ -1364,3 +1364,34 @@ describe('the sample data, copied when the door asks — the seed receipt (#3080
13641364
expect(resolveThrownHttpError(err).status).toBe(400);
13651365
});
13661366
});
1367+
1368+
describe('[#31530] the base row, as its own write answered it (`baseInstance`, door-only)', () => {
1369+
/** The protocol over the stub engine, with a `package` service whose `publish` answers `answer`. */
1370+
function withPackageService(answer: (manifest: any) => unknown) {
1371+
const harness = makeEngine();
1372+
const published: any[] = [];
1373+
const packageService = {
1374+
publish: vi.fn(async ({ manifest }: { manifest: any }) => { published.push(manifest); return answer(manifest); }),
1375+
};
1376+
const protocol = new ObjectStackProtocolImplementation(harness.engine, () => new Map([['package', packageService]])) as any;
1377+
return { ...harness, protocol, published, packageService };
1378+
}
1379+
1380+
it('answers the base row the package service\'s write answered, beside the published answer', async () => {
1381+
const { protocol, published } = withPackageService((manifest) => ({ success: true, version: manifest.version, createdAt: '2026-11-08 14:40:07' }));
1382+
const result = await protocol.importUnmanagedPackage({ manifest: crmBody() });
1383+
expect(published.map((m) => m.id)).toEqual(['com.example.crm-copy']);
1384+
expect(result.baseInstance).toEqual({ version: published[0].version, createdAt: '2026-11-08 14:40:07' });
1385+
// Door-only: outside the published answer, whose declaration still reads every other key.
1386+
const { baseInstance: _door, ...answer } = result;
1387+
expect(ImportUnmanagedPackageResponseSchema.parse(answer)).toEqual(answer);
1388+
expect(ImportUnmanagedPackageResponseSchema.parse(result)).not.toHaveProperty('baseInstance');
1389+
});
1390+
1391+
it('answers none when the write named no stamp, or when there is no package service', async () => {
1392+
const silent = withPackageService(() => ({ success: true }));
1393+
expect(await silent.protocol.importUnmanagedPackage({ manifest: crmBody() })).not.toHaveProperty('baseInstance');
1394+
const { protocol } = makeProtocol();
1395+
expect(await protocol.importUnmanagedPackage({ manifest: crmBody() })).not.toHaveProperty('baseInstance');
1396+
});
1397+
});

0 commit comments

Comments
 (0)