Skip to content

Commit 976023f

Browse files
committed
test(cli): the host-composition migrate suite pins its drift and coverage on the security tables stage 8 keeps
schema-migrate.host-composition.integration.test.ts composed SecurityPlugin and read sys_position / sys_permission_set as the plugin's tables: pending work naming both, cloud#1695's legacy global unique swapped back on sys_position.name, and an empty sys_permission_set as the plan's write absence. Stage 8 retires both objects (ADR-0131 D3), so a fresh composed boot declares neither and five cases failed. - Pending work and the #13028 coverage case now name sys_user_position and sys_user_permission_set, which the plugin still declares. - The legacy-unique cases use sys_audience_binding_suggestion's organization-respelled declared unique (package_id, permission_set_name, anchor): the #8323 arm of the same replace_unique_index plan, measured to plan safe, apply without --allow-destructive and converge. Both index names come from the driver's buildIndexName. - The write absence reads the plugin's remaining tables; the #13028 no-table case checks sys_user_permission_set. The #21054 cases keep sys_position / sys_permission_set as names no composition declares, which still holds. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a29bede commit 976023f

1 file changed

Lines changed: 76 additions & 48 deletions

File tree

‎packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts‎

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { dirname, join, resolve } from 'node:path';
88
// Loaded at module top, so the first transform is paid during collection
99
// rather than inside a clocked test body (`check:test-source-alias`).
1010
import { AppPlugin } from '@objectstack/runtime';
11+
import { buildIndexName } from '@objectstack/driver-sql';
1112
import { loadConfig } from './config.js';
1213
import { bootSchemaStack } from './schema-migrate.js';
1314

@@ -24,17 +25,22 @@ import { bootSchemaStack } from './schema-migrate.js';
2425
*
2526
* The fixture is cloud#1695's shape, built by the platform rather than by hand:
2627
* a host `objectstack.config.ts` composing `SecurityPlugin`, the tables created
27-
* through the migrate path itself, and then `sys_position`'s per-organization
28-
* composite unique swapped back to the pre-#8323 global spelling
29-
* (`uniq_sys_position_name`) — the exact index a deployed control DB was still
30-
* carrying.
28+
* through the migrate path itself, and then a per-organization composite unique
29+
* swapped back to its pre-#8323 global spelling — the drift a deployed control
30+
* DB was carrying. The measured index was `sys_position`'s
31+
* (`uniq_sys_position_name`); C3 stage 8 retired that object (ADR-0131 D3), so
32+
* the swap is now made on the one organization-respelled unique the plugin
33+
* still declares: `sys_audience_binding_suggestion`'s
34+
* `(package_id, permission_set_name, anchor)`, the #8323 declared-index arm of
35+
* the same `replace_unique_index` plan.
3136
*
3237
* Four things are pinned, and the fourth is an ABSENCE. Composing a host
33-
* config means composing arbitrary code, and `SecurityPlugin` seeds its
34-
* built-in permission sets from `start()`: measured, that produced 14 insert
35-
* attempts against `sys_permission_set` during a run documented as writing
36-
* nothing. `sys_permission_set` staying EMPTY across a plan is what proves the
37-
* declaration-phase composition actually holds.
38+
* config means composing arbitrary code. Measured before the catalog seeders
39+
* retired, `SecurityPlugin.start()` made 14 insert attempts against
40+
* `sys_permission_set` during a run documented as writing nothing. That table
41+
* is retired with its object; the plugin's tables holding exactly the
42+
* fixture's rows across a plan is what still shows the declaration-phase
43+
* composition holds.
3844
*/
3945

4046
const require_ = createRequire(import.meta.url);
@@ -63,6 +69,19 @@ const ARTIFACTLESS_BASELINE_TABLES = [
6369
'sys_metadata_history',
6470
];
6571

72+
/**
73+
* The organization-respelled declared unique the drift cases below put back to
74+
* its global spelling: `sys_audience_binding_suggestion`'s
75+
* `(package_id, permission_set_name, anchor)`, declared `unique: 'organization'`.
76+
* Both names are the driver's own (`buildIndexName`), never spelled by hand.
77+
*/
78+
const RESPELLED = {
79+
table: 'sys_audience_binding_suggestion',
80+
listed: ['package_id', 'permission_set_name', 'anchor'],
81+
} as const;
82+
const LEGACY_UNIQUE = buildIndexName(RESPELLED.table, [...RESPELLED.listed], true);
83+
const ORG_UNIQUE = buildIndexName(RESPELLED.table, ['organization_id', ...RESPELLED.listed], true);
84+
6685
describe('os migrate plan/apply compose the deployment\'s own object set (#12938)', () => {
6786
let dir: string;
6887
let dbFile: string;
@@ -147,9 +166,9 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938
147166
};
148167

149168
/**
150-
* Put `sys_position` back into the pre-respelling physical shape — the
151-
* per-organization composite retired, the global single-column unique
152-
* present. This is cloud#1695's index, verbatim.
169+
* Put {@link RESPELLED} back into the pre-respelling physical shape — the
170+
* per-organization composite retired, the global unique on the listed
171+
* columns present. cloud#1695's shape, on a table the plugin still declares.
153172
*
154173
* Idempotent, and called by every test that needs it, so the cases below do
155174
* not depend on each other's order. An integration file whose third test only
@@ -160,18 +179,20 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938
160179
const stack = await bootLikeMigrate();
161180
try {
162181
const k = (stack.driver as any).knex;
163-
const present = await indexNames(stack.driver, 'sys_position');
164-
if (present.includes('uniq_sys_position_organization_id_name')) {
165-
await k.raw('DROP INDEX uniq_sys_position_organization_id_name');
182+
const present = await indexNames(stack.driver, RESPELLED.table);
183+
if (present.includes(ORG_UNIQUE)) {
184+
await k.raw(`DROP INDEX ${ORG_UNIQUE}`);
166185
}
167-
if (!present.includes('uniq_sys_position_name')) {
168-
await k.raw('CREATE UNIQUE INDEX uniq_sys_position_name ON sys_position (name)');
186+
if (!present.includes(LEGACY_UNIQUE)) {
187+
await k.raw(`CREATE UNIQUE INDEX ${LEGACY_UNIQUE} ON ${RESPELLED.table} (${RESPELLED.listed.join(', ')})`);
169188
}
170-
if (await countRows(stack.driver, 'sys_position') === 0) {
171-
await k('sys_position').insert({
172-
id: 'pos_1',
173-
name: 'org_admin',
174-
label: 'Org Admin',
189+
if (await countRows(stack.driver, RESPELLED.table) === 0) {
190+
await k(RESPELLED.table).insert({
191+
id: 'abs_1',
192+
package_id: 'com.acme.crm',
193+
permission_set_name: 'sales_readonly',
194+
anchor: 'everyone',
195+
status: 'pending',
175196
organization_id: 'org_jia',
176197
});
177198
}
@@ -196,15 +217,18 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938
196217
expect(stack.composition.hostConfigPath).toBe(join(dir, 'objectstack.config.ts'));
197218

198219
// The number the consumer-side coverage gate reads. Strictly greater than
199-
// the baseline, and by more than one: the security plugin alone brings six
200-
// RBAC tables and the platform floor another four.
220+
// the baseline, and by more than one: the security plugin alone brings
221+
// three tables (its assignment objects and the audience-binding
222+
// suggestions; the catalog row objects retired in C3 stage 8) and the
223+
// platform floor another four.
201224
expect(stack.managedTableCount).toBeGreaterThan(ARTIFACTLESS_BASELINE_TABLES.length);
202225

203226
const pending = stack.pendingSchemaWork.map((p) => p.table);
204-
// The two tables the issue names by hand, neither of which was reachable
205-
// from a migrate boot at all.
206-
expect(pending).toContain('sys_position');
207-
expect(pending).toContain('sys_permission_set');
227+
// The issue named `sys_position` and `sys_permission_set` by hand, neither
228+
// reachable from a migrate boot at all; both objects are retired now
229+
// (ADR-0131 D3), and the security plugin's tables that remain stand in.
230+
expect(pending).toContain('sys_user_position');
231+
expect(pending).toContain('sys_user_permission_set');
208232
// …and the platform floor `serve` composes unconditionally.
209233
expect(pending).toContain('sys_migration');
210234
for (const table of ARTIFACTLESS_BASELINE_TABLES) expect(pending).toContain(table);
@@ -225,15 +249,15 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938
225249
try {
226250
const drift = await stack.driver!.detectManagedDrift();
227251
const entry = drift.find(
228-
(d) => d.table === 'sys_position' && d.op.type === 'replace_unique_index',
252+
(d) => d.table === RESPELLED.table && d.op.type === 'replace_unique_index',
229253
);
230254
expect(entry, 'the legacy platform-wide unique must be planned, not invisible').toBeDefined();
231255
expect(entry!.category).toBe('safe');
232256
expect(entry!.op).toMatchObject({
233-
table: 'sys_position',
234-
dropIndexNames: ['uniq_sys_position_name'],
235-
createIndexName: 'uniq_sys_position_organization_id_name',
236-
createColumns: ['organization_id', 'name'],
257+
table: RESPELLED.table,
258+
dropIndexNames: [LEGACY_UNIQUE],
259+
createIndexName: ORG_UNIQUE,
260+
createColumns: ['organization_id', ...RESPELLED.listed],
237261
});
238262
// The prescription that made this a contract rather than an incomplete report.
239263
expect(entry!.message).toContain('os migrate apply');
@@ -249,13 +273,15 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938
249273
try {
250274
await stack.driver!.detectManagedDrift();
251275

252-
// The absence that matters. `SecurityPlugin.start()` bootstraps
253-
// `admin_full_access`, `organization_admin`, `member_default` and the rest;
254-
// fully started, it attempted 14 inserts into this table during a plan.
255-
expect(await countRows(stack.driver, 'sys_permission_set')).toBe(0);
256-
expect(await countRows(stack.driver, 'sys_position')).toBe(1);
276+
// The absence that matters: the security plugin's tables hold exactly
277+
// the fixture's rows. (Fully started, `SecurityPlugin.start()` once made
278+
// 14 insert attempts into `sys_permission_set` during a plan; that seeder
279+
// and that object are retired, ADR-0131 D3.)
280+
expect(await countRows(stack.driver, 'sys_user_permission_set')).toBe(0);
281+
expect(await countRows(stack.driver, 'sys_user_position')).toBe(0);
282+
expect(await countRows(stack.driver, RESPELLED.table)).toBe(1);
257283
// The legacy index is still there — a plan proposes, it does not apply.
258-
expect(await indexNames(stack.driver, 'sys_position')).toContain('uniq_sys_position_name');
284+
expect(await indexNames(stack.driver, RESPELLED.table)).toContain(LEGACY_UNIQUE);
259285
} finally {
260286
await stack.shutdown();
261287
}
@@ -274,10 +300,10 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938
274300
expect(applied.some((d) => d.op.type === 'replace_unique_index')).toBe(true);
275301
expect(skipped).toHaveLength(0);
276302

277-
const after = await indexNames(stack.driver, 'sys_position');
278-
expect(after).toContain('uniq_sys_position_organization_id_name');
279-
expect(after).not.toContain('uniq_sys_position_name');
280-
expect(await countRows(stack.driver, 'sys_position')).toBe(1);
303+
const after = await indexNames(stack.driver, RESPELLED.table);
304+
expect(after).toContain(ORG_UNIQUE);
305+
expect(after).not.toContain(LEGACY_UNIQUE);
306+
expect(await countRows(stack.driver, RESPELLED.table)).toBe(1);
281307
} finally {
282308
await stack.shutdown();
283309
}
@@ -464,11 +490,13 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
464490
// being a second, differently-derived number.
465491
expect(stack.managedTableCount).toBe(coverage!.examinedObjects);
466492

467-
// The two tables #12938 named by hand as the proof the five-table set was
468-
// wrong. Pre-#13028 this shape reached NEITHER.
493+
// #12938 named `sys_position` and `sys_permission_set` by hand as the proof
494+
// the five-table set was wrong; pre-#13028 this shape reached neither. Both
495+
// objects are retired now (ADR-0131 D3), so the security plugin's tables
496+
// that remain carry the same proof.
469497
const pending = stack.pendingSchemaWork.map((p) => p.table);
470-
expect(pending).toContain('sys_position');
471-
expect(pending).toContain('sys_permission_set');
498+
expect(pending).toContain('sys_user_position');
499+
expect(pending).toContain('sys_user_permission_set');
472500

473501
// Full coverage means SILENCE about coverage: the honesty note exists to
474502
// mark a shortfall, and one that printed anyway would train readers to
@@ -493,7 +521,7 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur
493521
// assignment on the driver. If it had reached `initObjects` instead, the
494522
// table would exist here.
495523
const k = (stack.driver as any).knex;
496-
const exists = await k.schema.hasTable('sys_permission_set');
524+
const exists = await k.schema.hasTable('sys_user_permission_set');
497525
expect(exists, 'a plan must not create a table on its way to a coverage number').toBe(false);
498526
} finally {
499527
await stack.shutdown();

0 commit comments

Comments
 (0)