Skip to content

Commit 34d4829

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21267-analytics-order-key-selected
2 parents c515c61 + 4e53056 commit 34d4829

14 files changed

Lines changed: 2725 additions & 121 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-package': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix: on MySQL, `sys_packages` is now created and written, so installed and edited packages survive a restart. When a `sys_packages` write fails, a package install or edit now answers the failure instead of success (#21243)
7+
8+
Clause-②: no
9+
10+
**`@objectstack/service-package`.** The `sys_packages` DDL and the publish upsert are spelled for the dialect the default driver names (`SqlDriver.dialectName`). SQLite and PostgreSQL keep the exact statements they always ran, and so does any driver that names no SQL dialect. MySQL gets the same `(id, version)` key and columns in its own spelling. Its index is created only after `information_schema` reports it absent, and its upsert is `INSERT … AS incoming ON DUPLICATE KEY UPDATE`, which needs MySQL 8.0.19 or later. Before this, the table was never created on MySQL. That DDL failed with `ER_INVALID_DEFAULT`, `ER_BLOB_KEY_WITHOUT_LENGTH` and `ER_PARSE_ERROR`. The DDL refusal was logged only at `debug`, as "may already exist". The `ON CONFLICT` upsert also failed with `ER_PARSE_ERROR`, so `POST /api/v1/packages/publish` answered `500 DATABASE_ERROR`. A refused DDL statement now fails the plugin's `start()` and is logged at `error`.
11+
12+
**`@objectstack/metadata-protocol`.** `installPackage` and `updatePackage` no longer answer success when the `package` service's `sys_packages` write fails. The registry write is undone first. A fresh install leaves no package and releases the namespace it registered. A re-install puts the prior row back, and an edit puts the prior manifest back. Then the failure is thrown. A store fault answers `500`, with `DATABASE_ERROR` from a live SQL driver and `INTERNAL_ERROR` otherwise. A declared 4xx refusal is passed through unchanged. Before this, `POST /api/v1/packages` answered `201` and `PATCH /api/v1/packages/:id` answered `200` over a write that never landed, and the package was gone after the next restart. A host with no `package` service still installs in memory only and says so with a warning. That degraded path is unchanged.

‎content/docs/releases/v17/17-5.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,15 @@ packages on npm also carry the eight commits below. The version commit did not
16061606
consume their changesets, so no 17.5.0 `CHANGELOG.md` entry names them; they
16071607
will be listed again in 17.6.0's `CHANGELOG.md`. The release-pipeline defect
16081608
that let the publish run past its version commit is tracked in #20613.
1609+
**Correction (2026-10-02):** seven more commits shipped in 17.5.0 with no
1610+
17.5.0 `CHANGELOG.md` entry — they landed before the version commit but were
1611+
not in the version PR when it merged. Two are breaking, `e73ee2d` (#20567,
1612+
`RealtimeEventType`) and `c876a74` (#20504, a forced Turso replica with no
1613+
`syncUrl`), and `7a1faf1` (#20579) makes `os validate --strict` fail on a
1614+
live conversion. See
1615+
[Shipped in
1616+
17.5.0](/docs/releases/v17/17-6#shipped-in-1750--listed-again-in-1760s-changelog)
1617+
on the 17.6.0 page.
16091618

16101619
- `6e3aa75` (#20584) — a permission-set resolution with no active organization
16111620
reads only the organization-less permission sets, the rule the grant

‎content/docs/releases/v17/17-6.mdx‎

Lines changed: 1601 additions & 0 deletions
Large diffs are not rendered by default.

‎content/docs/releases/v17/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"title": "v17",
33
"pages": [
44
"index",
5+
"17-6",
56
"17-5",
67
"17-4",
78
"17-3",

‎docs/adr/0114-field-level-error-code-catalog.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**Status**: Accepted (2026-07-30)
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (D6 deferred exactly this decision, and its catalog+ledger shape is the model reused here), [ADR-0049](./0049-no-unenforced-security-properties.md) (declare-and-enforce — the wire `fields[]` element had no schema at all), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert declarations — `EnhancedApiErrorSchema.fieldErrors` was declared and never emitted), [ADR-0104](./0104-field-runtime-value-shape-contract.md) (the silently-stripped-key line its contract guard enforces — why D4's rename is tombstoned rather than deleted)
6-
**Consumers**: `@objectstack/spec` (`api/errors.zod.ts`, `ui/action-params.zod.ts`), `@objectstack/objectql` (`validation/record-validator.ts`, `validation/rule-validator.ts`), `@objectstack/rest` (`import-coerce.ts`, `import-runner.ts`, `zodIssuesToFields`), `@objectstack/plugin-sharing` (`rule-criteria.ts`), `@objectstack/runtime` (`validation-failure.ts`), `@objectstack/client`, objectui (`react/src/utils/error-message.ts` — the only console consumer)
6+
**Consumers**: `@objectstack/spec` (`api/errors.zod.ts`, `ui/action-params.zod.ts`), `@objectstack/objectql` (`validation/record-validator.ts`, `validation/rule-validator.ts`), `@objectstack/core` (`utils/import-coerce.ts`, `utils/import-runner.ts`), `@objectstack/rest` (`zodIssuesToFields`), `@objectstack/plugin-sharing` (`rule-criteria.ts`), `@objectstack/runtime` (`validation-failure.ts`), `@objectstack/client`, objectui (`react/src/utils/error-message.ts` — the only console consumer)
77
**Surfaced by**: [#3977](https://github.com/objectstack-ai/objectstack/issues/3977), split out of [#3841](https://github.com/objectstack-ai/objectstack/issues/3841) by ADR-0112 D6.
88

99
---

‎packages/metadata-protocol/src/durable-package.test.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,10 @@ describe('installPackage — durable persistence (#2532)', () => {
115115
expect((publish.mock.calls[0][0] as any).manifest.version).toBe('2.3.4');
116116
});
117117

118-
it('stays non-fatal when the durable write fails (registry install already succeeded)', async () => {
119-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
120-
try {
121-
const { impl } = makeImpl({ publish: async () => ({ success: false, error: 'boom' }) });
122-
const res: any = await (impl as any).installPackage({ manifest: { id: 'com.example.fail' } });
123-
expect(res.package.status).toBe('installed');
124-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('persist FAILED'));
125-
} finally {
126-
warn.mockRestore();
127-
}
128-
});
118+
// A FAILED durable write is no longer "non-fatal": since #21243 it fails the
119+
// install and undoes the registry write. That contract, for both verbs and
120+
// both failure channels of `publish`, is pinned in
121+
// `protocol.package-persist-failure.test.ts`.
129122
});
130123

131124
describe('deletePackage — durable un-registration (#2532 counterpart)', () => {
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21243 — a package write the store refused is never answered as success.
5+
*
6+
* ## The defect
7+
*
8+
* `installPackage` and `updatePackage` write two stores: the in-memory
9+
* registry, then `sys_packages` through the `package` service. Both caught the
10+
* second write's failure — returned `{ success: false }` or thrown — logged it
11+
* with `console.warn`, and answered success over the registry row. On MySQL,
12+
* where `sys_packages` was never created, every `POST /api/v1/packages`
13+
* answered 201 and every `PATCH /api/v1/packages/:id` answered 200, and after
14+
* the next restart `GET /api/v1/packages/:id` answered 404.
15+
*
16+
* ## The contract pinned here (triage's ruling: both halves, not one)
17+
*
18+
* 1. The failure is ANSWERED — a thrown error with the status and code the
19+
* dispatcher door reads (`resolveThrownHttpError`, the one rule every
20+
* door applies), never a success body.
21+
* 2. The registry write is UNDONE — after the throw the process holds no
22+
* package the store does not: a fresh install leaves nothing, a
23+
* re-install leaves the prior row, an edit leaves the prior manifest.
24+
*
25+
* Both failure channels of `publish` are driven, because the service has two:
26+
* RETURNED `{ success: false, driverFault }` (an undeclared driver fault the
27+
* service swallowed) and THROWN (a failure that declared its own answer —
28+
* what a live SQL driver's refused raw statement is, `500 DATABASE_ERROR`).
29+
*
30+
* ## The registry double
31+
*
32+
* This package cannot depend on `@objectstack/objectql` (it is the other way
33+
* round), so the registry is a double. It mirrors the real `SchemaRegistry`
34+
* verbs BY NAME and by the behaviour this contract leans on
35+
* (`packages/objectql/src/registry.ts`): `installPackage` builds a NEW row
36+
* object, keeps an existing row's lifecycle fields and registers the
37+
* namespace; `updatePackageManifest` edits the row and its manifest IN PLACE;
38+
* `enablePackage` / `disablePackage` move the lifecycle fields;
39+
* `unregisterItem('package', id)` withdraws the row; the namespace verbs keep
40+
* a set of owners per namespace.
41+
*/
42+
43+
import { describe, it, expect, vi } from 'vitest';
44+
import { resolveThrownHttpError } from '@objectstack/types';
45+
import { ObjectStackProtocolImplementation } from './index.js';
46+
47+
interface Row {
48+
manifest: Record<string, unknown>;
49+
status: string;
50+
enabled: boolean;
51+
installedAt: string;
52+
updatedAt: string;
53+
settings?: unknown;
54+
}
55+
56+
function makeRegistry() {
57+
const rows = new Map<string, Row>();
58+
const namespaces = new Map<string, Set<string>>();
59+
let tick = 0;
60+
const stamp = () => `2026-10-01T00:00:${String(tick++).padStart(2, '0')}.000Z`;
61+
return {
62+
rows,
63+
namespaces,
64+
installPackage(manifest: any, settings?: unknown): Row {
65+
const existing = rows.get(manifest.id);
66+
const row: Row = {
67+
manifest: { ...manifest },
68+
status: existing?.status ?? 'installed',
69+
enabled: existing?.enabled ?? true,
70+
installedAt: stamp(),
71+
updatedAt: stamp(),
72+
settings,
73+
};
74+
if (manifest.namespace) this.registerNamespace(manifest.namespace, manifest.id);
75+
rows.set(manifest.id, row);
76+
return row;
77+
},
78+
getPackage: (id: string) => rows.get(id),
79+
getAllPackages: () => [...rows.values()],
80+
updatePackageManifest(id: string, patch: Record<string, unknown>) {
81+
const row = rows.get(id);
82+
if (!row) return undefined;
83+
for (const [k, v] of Object.entries(patch)) if (v !== undefined) row.manifest[k] = v;
84+
row.updatedAt = stamp();
85+
return row;
86+
},
87+
enablePackage(id: string) {
88+
const row = rows.get(id);
89+
if (row) Object.assign(row, { enabled: true, status: 'installed', updatedAt: stamp() });
90+
return row;
91+
},
92+
disablePackage(id: string) {
93+
const row = rows.get(id);
94+
if (row) Object.assign(row, { enabled: false, status: 'disabled', updatedAt: stamp() });
95+
return row;
96+
},
97+
unregisterItem(type: string, name: string) {
98+
if (type === 'package') rows.delete(name);
99+
},
100+
registerNamespace(ns: string, id: string) {
101+
const owners = namespaces.get(ns) ?? new Set<string>();
102+
owners.add(id);
103+
namespaces.set(ns, owners);
104+
},
105+
unregisterNamespace(ns: string, id: string) {
106+
const owners = namespaces.get(ns);
107+
owners?.delete(id);
108+
if (owners?.size === 0) namespaces.delete(ns);
109+
},
110+
getNamespaceOwners: (ns: string) => [...(namespaces.get(ns) ?? [])],
111+
};
112+
}
113+
114+
function makeImpl(publish: (d: { manifest: any; metadata: unknown }) => Promise<unknown>) {
115+
const registry = makeRegistry();
116+
const publishSpy = vi.fn(publish);
117+
const services = new Map<string, unknown>([['package', { publish: publishSpy, delete: async () => ({ success: true }) }]]);
118+
const impl = new ObjectStackProtocolImplementation({ registry, find: async () => [] } as any, () => services as any);
119+
return { impl: impl as any, registry, publish: publishSpy };
120+
}
121+
122+
/** The service's RETURNED channel: the INSERT broke, nothing declared (`PackagePublishResult`). */
123+
const returnedDriverFault = async () => ({
124+
success: false,
125+
driverFault: { message: 'The package registry could not store this package.' },
126+
});
127+
128+
/** The THROWN channel, shaped as a live SQL driver's refused raw statement (`rawStatementFaultError`). */
129+
const DRIVER_LINE = "Table 'os.sys_packages' doesn't exist";
130+
const thrownDatabaseError = async () => {
131+
throw Object.assign(new Error('The database refused to run a raw statement.'), {
132+
code: 'DATABASE_ERROR',
133+
status: 500,
134+
cause: new Error(DRIVER_LINE),
135+
});
136+
};
137+
138+
/** What the dispatcher door answers for a thrown value (`errorFromThrown` → `resolveThrownHttpError`). */
139+
const door = (e: unknown) => {
140+
const r = resolveThrownHttpError(e, 500);
141+
return { status: r.status, code: r.code, message: r.message };
142+
};
143+
144+
async function rejectionOf(p: Promise<unknown>): Promise<unknown> {
145+
try {
146+
await p;
147+
} catch (e) {
148+
return e;
149+
}
150+
throw new Error('expected the call to reject, and it resolved');
151+
}
152+
153+
describe('#21243 installPackage — a refused sys_packages write fails the install and registers nothing', () => {
154+
it.each([
155+
['returned driverFault', 'INTERNAL_ERROR', returnedDriverFault],
156+
['thrown DATABASE_ERROR', 'DATABASE_ERROR', thrownDatabaseError],
157+
] as const)('fresh id, %s → 500 %s, no row, no namespace', async (_label, code, publish) => {
158+
const { impl, registry } = makeImpl(publish);
159+
160+
const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave' } }));
161+
162+
expect(door(err)).toMatchObject({ status: 500, code });
163+
// The driver's words stay on `cause` for the operator, never in the caller's sentence.
164+
expect(door(err).message).not.toContain(DRIVER_LINE);
165+
expect((err as { cause?: unknown }).cause).toBeDefined();
166+
// The undo half: nothing in this process claims the package.
167+
expect(registry.getPackage('com.example.leave')).toBeUndefined();
168+
// The namespace this install derived (`leave`) is released with it.
169+
expect(registry.getNamespaceOwners('leave')).toEqual([]);
170+
});
171+
172+
it('a re-install over an existing row puts the PRIOR row back, lifecycle included', async () => {
173+
const { impl, registry } = makeImpl(returnedDriverFault);
174+
const prior = registry.installPackage({ id: 'com.example.leave', name: 'Leave v1', version: '1.0.0', namespace: 'leave' });
175+
registry.disablePackage('com.example.leave');
176+
const priorContent = JSON.parse(JSON.stringify(registry.getPackage('com.example.leave')));
177+
178+
const err = await rejectionOf(impl.installPackage({
179+
manifest: { id: 'com.example.leave', name: 'Leave v2', version: '2.0.0', namespace: 'leave' },
180+
enableOnInstall: true,
181+
}));
182+
183+
expect(door(err)).toMatchObject({ status: 500, code: 'INTERNAL_ERROR' });
184+
// Content, not identity: the registry keeps the row object it now holds.
185+
expect(JSON.parse(JSON.stringify(registry.getPackage('com.example.leave')))).toEqual(priorContent);
186+
expect(registry.getPackage('com.example.leave')?.manifest.name).toBe('Leave v1');
187+
expect(registry.getPackage('com.example.leave')?.enabled).toBe(false);
188+
// The namespace the id already owned stays owned.
189+
expect(registry.getNamespaceOwners('leave')).toEqual(['com.example.leave']);
190+
expect(prior.manifest.name).toBe('Leave v1');
191+
});
192+
193+
it('a declared 4xx refusal from the store leaves as the producer answered it, and is still undone', async () => {
194+
const refusal = Object.assign(new Error('Refused by the platform.'), { code: 'DESTRUCTIVE_CHANGE', status: 409 });
195+
const { impl, registry } = makeImpl(async () => {
196+
throw refusal;
197+
});
198+
199+
const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave' } }));
200+
201+
expect(err).toBe(refusal);
202+
expect(door(err)).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' });
203+
expect(registry.getPackage('com.example.leave')).toBeUndefined();
204+
});
205+
206+
it('CONTROL — a landed write answers the installed row, exactly as before', async () => {
207+
const { impl, registry, publish } = makeImpl(async () => ({ success: true }));
208+
209+
const res = await impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave' } });
210+
211+
expect(res.package.manifest.id).toBe('com.example.leave');
212+
expect(registry.getPackage('com.example.leave')).toBe(res.package);
213+
expect(registry.getNamespaceOwners('leave')).toEqual(['com.example.leave']);
214+
expect(publish).toHaveBeenCalledTimes(1);
215+
});
216+
});
217+
218+
describe('#21243 updatePackage — a refused sys_packages write fails the edit and restores the manifest', () => {
219+
it.each([
220+
['returned driverFault', 'INTERNAL_ERROR', returnedDriverFault],
221+
['thrown DATABASE_ERROR', 'DATABASE_ERROR', thrownDatabaseError],
222+
] as const)('%s → 500 %s, the prior manifest back in place', async (_label, code, publish) => {
223+
const { impl, registry } = makeImpl(publish);
224+
const row = registry.installPackage({ id: 'com.example.leave', name: 'Leave', version: '1.0.0' });
225+
const manifestObject = row.manifest;
226+
const priorContent = JSON.parse(JSON.stringify(row));
227+
228+
const err = await rejectionOf(impl.updatePackage({
229+
packageId: 'com.example.leave',
230+
patch: { name: 'Leave (renamed)', description: 'patched' },
231+
}));
232+
233+
expect(door(err)).toMatchObject({ status: 500, code });
234+
expect(door(err).message).not.toContain(DRIVER_LINE);
235+
const now = registry.getPackage('com.example.leave')!;
236+
expect(JSON.parse(JSON.stringify(now))).toEqual(priorContent);
237+
// `description` was ABSENT before the edit and is absent again — not `undefined`, absent.
238+
expect('description' in now.manifest).toBe(false);
239+
// Restored in place: the row and its manifest are the objects the registry already held.
240+
expect(now).toBe(row);
241+
expect(now.manifest).toBe(manifestObject);
242+
});
243+
244+
it('CONTROL — a landed write answers the edited row', async () => {
245+
const { impl, registry } = makeImpl(async () => ({ success: true }));
246+
registry.installPackage({ id: 'com.example.leave', name: 'Leave', version: '1.0.0' });
247+
248+
const res = await impl.updatePackage({ packageId: 'com.example.leave', patch: { description: 'patched' } });
249+
250+
expect(res.package.manifest.description).toBe('patched');
251+
expect(registry.getPackage('com.example.leave')?.manifest.description).toBe('patched');
252+
});
253+
});

0 commit comments

Comments
 (0)