@astram/identity 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/branch.object.d.ts +9 -1
  2. package/dist/branch.object.d.ts.map +1 -1
  3. package/dist/branch.object.js +14 -1
  4. package/dist/company-branch.object.d.ts +5 -4
  5. package/dist/company-branch.object.d.ts.map +1 -1
  6. package/dist/company-branch.object.js +6 -4
  7. package/dist/company-department.object.d.ts +6 -6
  8. package/dist/company-department.object.d.ts.map +1 -1
  9. package/dist/company-department.object.js +7 -6
  10. package/dist/department.object.d.ts +9 -1
  11. package/dist/department.object.d.ts.map +1 -1
  12. package/dist/department.object.js +14 -1
  13. package/dist/features/access/access-admin.access.d.ts.map +1 -1
  14. package/dist/features/access/access-admin.access.js +8 -1
  15. package/dist/features/access/index.d.ts +1 -0
  16. package/dist/features/access/index.d.ts.map +1 -1
  17. package/dist/features/access/index.js +10 -2
  18. package/dist/features/access/org-structure.access.d.ts +3 -0
  19. package/dist/features/access/org-structure.access.d.ts.map +1 -0
  20. package/dist/features/access/org-structure.access.js +41 -0
  21. package/dist/features/access/user-management.access.d.ts.map +1 -1
  22. package/dist/features/access/user-management.access.js +5 -0
  23. package/dist/features/audit/audit-read.service.d.ts +32 -1
  24. package/dist/features/audit/audit-read.service.d.ts.map +1 -1
  25. package/dist/features/audit/audit-read.service.js +67 -14
  26. package/dist/features/audit/auth-audit-sink.d.ts +19 -0
  27. package/dist/features/audit/auth-audit-sink.d.ts.map +1 -0
  28. package/dist/features/audit/auth-audit-sink.js +24 -0
  29. package/dist/features/audit/directory-label-resolver.d.ts +10 -1
  30. package/dist/features/audit/directory-label-resolver.d.ts.map +1 -1
  31. package/dist/features/audit/directory-label-resolver.js +28 -1
  32. package/dist/features/authz/grant-admin.controller.d.ts +4 -4
  33. package/dist/features/authz/grant-admin.controller.js +4 -4
  34. package/dist/features/authz/grant-admin.service.d.ts +9 -8
  35. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  36. package/dist/features/authz/grant-admin.service.js +20 -24
  37. package/dist/features/authz/grant-ceiling.d.ts +35 -16
  38. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  39. package/dist/features/authz/grant-ceiling.js +44 -29
  40. package/dist/features/authz/role-binding.d.ts +25 -24
  41. package/dist/features/authz/role-binding.d.ts.map +1 -1
  42. package/dist/features/authz/role-binding.js +31 -44
  43. package/dist/features/identity-feature.module.d.ts +0 -11
  44. package/dist/features/identity-feature.module.d.ts.map +1 -1
  45. package/dist/features/identity-feature.module.js +90 -1
  46. package/dist/features/membership/membership.controller.d.ts.map +1 -1
  47. package/dist/features/membership/membership.controller.js +20 -6
  48. package/dist/features/membership/membership.service.d.ts +16 -10
  49. package/dist/features/membership/membership.service.d.ts.map +1 -1
  50. package/dist/features/membership/membership.service.js +61 -21
  51. package/dist/features/membership/posting.service.d.ts +25 -6
  52. package/dist/features/membership/posting.service.d.ts.map +1 -1
  53. package/dist/features/membership/posting.service.js +63 -16
  54. package/dist/features/org-bootstrap/org-bootstrap-file.d.ts +6 -2
  55. package/dist/features/org-bootstrap/org-bootstrap-file.d.ts.map +1 -1
  56. package/dist/features/org-bootstrap/org-bootstrap-file.js +54 -11
  57. package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts +30 -11
  58. package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts.map +1 -1
  59. package/dist/features/org-bootstrap/org-bootstrap-plan.js +172 -68
  60. package/dist/features/org-bootstrap/org-state.reader.d.ts.map +1 -1
  61. package/dist/features/org-bootstrap/org-state.reader.js +56 -49
  62. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +6 -1
  63. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -1
  64. package/dist/features/org-bootstrap/run-org-bootstrap.js +81 -92
  65. package/dist/features/org-structure/org-catalog.controller.d.ts +29 -6
  66. package/dist/features/org-structure/org-catalog.controller.d.ts.map +1 -1
  67. package/dist/features/org-structure/org-catalog.controller.js +105 -17
  68. package/dist/features/org-structure/org-catalog.service.d.ts +99 -18
  69. package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
  70. package/dist/features/org-structure/org-catalog.service.js +327 -51
  71. package/dist/features/org-structure/org-unit-name.d.ts +31 -0
  72. package/dist/features/org-structure/org-unit-name.d.ts.map +1 -0
  73. package/dist/features/org-structure/org-unit-name.js +50 -0
  74. package/dist/features/role/role.controller.d.ts +28 -13
  75. package/dist/features/role/role.controller.d.ts.map +1 -1
  76. package/dist/features/role/role.controller.js +54 -14
  77. package/dist/features/role/role.service.d.ts +76 -21
  78. package/dist/features/role/role.service.d.ts.map +1 -1
  79. package/dist/features/role/role.service.js +204 -27
  80. package/dist/features/security-policy/security-policy-version-bump.d.ts +41 -0
  81. package/dist/features/security-policy/security-policy-version-bump.d.ts.map +1 -0
  82. package/dist/features/security-policy/security-policy-version-bump.js +65 -0
  83. package/dist/features/security-policy/security-policy.controller.d.ts +51 -0
  84. package/dist/features/security-policy/security-policy.controller.d.ts.map +1 -0
  85. package/dist/features/security-policy/security-policy.controller.js +121 -0
  86. package/dist/features/security-policy/security-policy.service.d.ts +94 -0
  87. package/dist/features/security-policy/security-policy.service.d.ts.map +1 -0
  88. package/dist/features/security-policy/security-policy.service.js +429 -0
  89. package/dist/features/service-account/service-account.service.d.ts +13 -3
  90. package/dist/features/service-account/service-account.service.d.ts.map +1 -1
  91. package/dist/features/service-account/service-account.service.js +48 -10
  92. package/dist/features/service-account/service-account.store.d.ts +11 -1
  93. package/dist/features/service-account/service-account.store.d.ts.map +1 -1
  94. package/dist/features/service-account/service-account.store.js +20 -1
  95. package/dist/features/user/signin-admin.tokens.d.ts +11 -0
  96. package/dist/features/user/signin-admin.tokens.d.ts.map +1 -0
  97. package/dist/features/user/signin-admin.tokens.js +13 -0
  98. package/dist/features/user/user-mfa-status.d.ts +38 -0
  99. package/dist/features/user/user-mfa-status.d.ts.map +1 -0
  100. package/dist/features/user/user-mfa-status.js +47 -0
  101. package/dist/features/user/user-signin-admin.service.d.ts +48 -0
  102. package/dist/features/user/user-signin-admin.service.d.ts.map +1 -0
  103. package/dist/features/user/user-signin-admin.service.js +232 -0
  104. package/dist/features/user/user.controller.d.ts +29 -2
  105. package/dist/features/user/user.controller.d.ts.map +1 -1
  106. package/dist/features/user/user.controller.js +71 -3
  107. package/dist/features/user/user.service.d.ts +40 -14
  108. package/dist/features/user/user.service.d.ts.map +1 -1
  109. package/dist/features/user/user.service.js +143 -10
  110. package/dist/index.d.ts +1 -0
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +3 -1
  113. package/dist/objects.d.ts.map +1 -1
  114. package/dist/objects.js +2 -0
  115. package/dist/role.object.d.ts +1 -1
  116. package/dist/role.object.d.ts.map +1 -1
  117. package/dist/role.object.js +6 -15
  118. package/dist/security-policy.object.d.ts +43 -0
  119. package/dist/security-policy.object.d.ts.map +1 -0
  120. package/dist/security-policy.object.js +107 -0
  121. package/dist/user.object.d.ts +6 -0
  122. package/dist/user.object.d.ts.map +1 -1
  123. package/dist/user.object.js +13 -1
  124. package/migrations/1710000000103-AddUserLoginName.cjs +87 -0
  125. package/migrations/1710000000104-CreateSecurityPolicy.cjs +90 -0
  126. package/migrations/1710000000105-OwnDepartmentsAndBranches.cjs +327 -0
  127. package/migrations/1710000000106-OwnRolesPerCompany.cjs +622 -0
  128. package/package.json +4 -4
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Framework identity migration — each company OWNS its departments and branches (G-012, spec §4).
3
+ *
4
+ * WHY: `departments` / `branches` were global catalogs (one "Sales" row shared by every company
5
+ * that used the name, `UNIQUE (d_name)`), so no company could safely rename or delete its own.
6
+ * After this migration every row has one owner company (`d_company_uuid` / `b_company_uuid`),
7
+ * names are unique per company, and no row is shared between companies.
8
+ *
9
+ * Steps, per table (idempotent; a half-applied state is completed, a re-run changes nothing):
10
+ * 1. ADD the nullable owner column, its index `idx_<table>_company` and
11
+ * `FK_<table>_company` -> `companies (c_uuid)` ON DELETE CASCADE, each when absent.
12
+ * 2. OWNER. A row with no owner that a junction row links gets the linked company with the
13
+ * smallest `c_uuid`. A row with no owner and no junction row that postings reference gets the
14
+ * posting's membership company with the smallest `c_uuid`. Every owned row then gets a
15
+ * junction row to its owner when it has none, so no posting points at an unowned row. Only
16
+ * `IS NULL` owners are written: a re-run never overwrites an owner.
17
+ * 3. DROP the global unique `IDX_<table>_name` when present (copies share the name).
18
+ * 4. SPLIT. For every (company, row) where the company is not the row's owner — a junction row,
19
+ * or a posting of that company's memberships — the company gets its own copy: an existing
20
+ * row it owns with the same name (column collation) is reused, else one is inserted
21
+ * (`UUID()`, same name, that owner). The company's junction row to the copy is added, that
22
+ * company's postings are repointed to the copy, and only then its junction row to the
23
+ * original is deleted (creates before deletes, so a re-run after a partial failure resumes).
24
+ * A re-run finds no (company, row) off the owner and does nothing.
25
+ * 5. ADD UNIQUE `IDX_<table>_company_name (owner, name)` when absent; duplicates (only possible
26
+ * on a database that never had the global unique) fail with the names, nothing indexed.
27
+ * A row linked to no company and referenced by no posting keeps a NULL owner (root only).
28
+ *
29
+ * `down`: refuses (no change) while any name exists more than once in a table whose global
30
+ * unique is absent; otherwise drops the per-company unique, re-adds the global unique, drops the
31
+ * FK, the index and the column (IF EXISTS each). Split copies are not merged back.
32
+ *
33
+ * Names: schema from `ctx.schemas` (`frameworkSchemasForMigration`), so the four-database,
34
+ * namespace and single layouts all work (single: framework tables unprefixed in one database, as
35
+ * in `...101`). Literal table/column/index names, every value `?`-bound, no hardcoded ids. Sorts
36
+ * after `...001` (catalogs), `...002` (junctions, postings) and `...102` (the latest published).
37
+ */
38
+
39
+ const { frameworkSchemasForMigration } = require('@astram/core');
40
+
41
+ const MIGRATION = '1710000000105-OwnDepartmentsAndBranches';
42
+ /** Duplicate groups listed in a failure message; the rest are counted. */
43
+ const MAX_LISTED = 50;
44
+
45
+ const KINDS = [
46
+ {
47
+ label: 'department',
48
+ table: 'departments',
49
+ pk: 'd_uuid',
50
+ name: 'd_name',
51
+ created: 'd_created_at',
52
+ owner: 'd_company_uuid',
53
+ index: 'idx_departments_company',
54
+ fk: 'FK_departments_company',
55
+ globalUnique: 'IDX_departments_name',
56
+ companyUnique: 'IDX_departments_company_name',
57
+ junction: 'company_departments',
58
+ jCompany: 'cd_company_uuid',
59
+ jUnit: 'cd_department_uuid',
60
+ posting: 'mp_department_uuid',
61
+ },
62
+ {
63
+ label: 'branch',
64
+ table: 'branches',
65
+ pk: 'b_uuid',
66
+ name: 'b_name',
67
+ created: 'b_created_at',
68
+ owner: 'b_company_uuid',
69
+ index: 'idx_branches_company',
70
+ fk: 'FK_branches_company',
71
+ globalUnique: 'IDX_branches_name',
72
+ companyUnique: 'IDX_branches_company_name',
73
+ junction: 'company_branches',
74
+ jCompany: 'cb_company_uuid',
75
+ jUnit: 'cb_branch_uuid',
76
+ posting: 'mp_branch_uuid',
77
+ },
78
+ ];
79
+
80
+ const rowsOf = (result) => (Array.isArray(result) ? result : []);
81
+
82
+ const present = async (query, sql, params) => rowsOf(await query(sql, params)).length > 0;
83
+
84
+ const hasColumn = (query, APP, table, column) =>
85
+ present(
86
+ query,
87
+ `SELECT 1 AS n FROM information_schema.COLUMNS
88
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`,
89
+ [APP, table, column],
90
+ );
91
+
92
+ const hasIndex = (query, APP, table, index) =>
93
+ present(
94
+ query,
95
+ `SELECT 1 AS n FROM information_schema.STATISTICS
96
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1`,
97
+ [APP, table, index],
98
+ );
99
+
100
+ const hasFk = (query, APP, table, fk) =>
101
+ present(
102
+ query,
103
+ `SELECT 1 AS n FROM information_schema.REFERENTIAL_CONSTRAINTS
104
+ WHERE CONSTRAINT_SCHEMA = ? AND TABLE_NAME = ? AND CONSTRAINT_NAME = ? LIMIT 1`,
105
+ [APP, table, fk],
106
+ );
107
+
108
+ /** Step 1: owner column, its index and FK, each when absent. */
109
+ async function addOwnerColumn(query, APP, k) {
110
+ const T = `\`${APP}\`.\`${k.table}\``;
111
+ if (!(await hasColumn(query, APP, k.table, k.owner))) {
112
+ await query(`ALTER TABLE ${T} ADD COLUMN ${k.owner} VARCHAR(36) NULL`);
113
+ }
114
+ if (!(await hasIndex(query, APP, k.table, k.index))) {
115
+ await query(`CREATE INDEX ${k.index} ON ${T} (${k.owner})`);
116
+ }
117
+ if (!(await hasFk(query, APP, k.table, k.fk))) {
118
+ await query(`ALTER TABLE ${T}
119
+ ADD CONSTRAINT ${k.fk} FOREIGN KEY (${k.owner})
120
+ REFERENCES \`${APP}\`.\`companies\` (c_uuid) ON DELETE CASCADE`);
121
+ }
122
+ }
123
+
124
+ /** Step 2: owners from junctions, then from postings; every owned row linked to its owner. */
125
+ async function assignOwners(query, APP, k) {
126
+ const T = `\`${APP}\`.\`${k.table}\``;
127
+ const J = `\`${APP}\`.\`${k.junction}\``;
128
+ const MP = `\`${APP}\`.\`membership_posting\``;
129
+ const CM = `\`${APP}\`.\`company_membership\``;
130
+
131
+ await query(`UPDATE ${T} u
132
+ SET u.${k.owner} = (SELECT MIN(j.${k.jCompany}) FROM ${J} j WHERE j.${k.jUnit} = u.${k.pk})
133
+ WHERE u.${k.owner} IS NULL
134
+ AND EXISTS (SELECT 1 FROM ${J} j WHERE j.${k.jUnit} = u.${k.pk})`);
135
+
136
+ await query(`UPDATE ${T} u
137
+ SET u.${k.owner} = (SELECT MIN(cm.cm_company_uuid)
138
+ FROM ${MP} mp
139
+ JOIN ${CM} cm ON cm.cm_uuid = mp.mp_membership_uuid
140
+ WHERE mp.${k.posting} = u.${k.pk})
141
+ WHERE u.${k.owner} IS NULL
142
+ AND NOT EXISTS (SELECT 1 FROM ${J} j WHERE j.${k.jUnit} = u.${k.pk})
143
+ AND EXISTS (SELECT 1 FROM ${MP} mp WHERE mp.${k.posting} = u.${k.pk})`);
144
+
145
+ const unlinked = rowsOf(
146
+ await query(`SELECT u.${k.owner} AS company, u.${k.pk} AS unit FROM ${T} u
147
+ WHERE u.${k.owner} IS NOT NULL
148
+ AND NOT EXISTS (SELECT 1 FROM ${J} j
149
+ WHERE j.${k.jCompany} = u.${k.owner} AND j.${k.jUnit} = u.${k.pk})
150
+ ORDER BY company, unit`),
151
+ );
152
+ for (const { company, unit } of unlinked) await link(query, APP, k, company, unit);
153
+ }
154
+
155
+ /** Insert the junction row (company, unit) when absent. */
156
+ async function link(query, APP, k, company, unit) {
157
+ const J = `\`${APP}\`.\`${k.junction}\``;
158
+ await query(
159
+ `INSERT INTO ${J} (${k.jCompany}, ${k.jUnit})
160
+ SELECT ?, ? FROM DUAL
161
+ WHERE NOT EXISTS (SELECT 1 FROM ${J} WHERE ${k.jCompany} = ? AND ${k.jUnit} = ?)`,
162
+ [company, unit, company, unit],
163
+ );
164
+ }
165
+
166
+ /** Step 4: give every non-owner company that uses a row its own copy. */
167
+ async function splitShared(query, APP, k) {
168
+ const T = `\`${APP}\`.\`${k.table}\``;
169
+ const J = `\`${APP}\`.\`${k.junction}\``;
170
+ const MP = `\`${APP}\`.\`membership_posting\``;
171
+ const CM = `\`${APP}\`.\`company_membership\``;
172
+
173
+ const pairs = rowsOf(
174
+ await query(`SELECT j.${k.jCompany} AS company, u.${k.pk} AS unit, u.${k.name} AS name
175
+ FROM ${J} j
176
+ JOIN ${T} u ON u.${k.pk} = j.${k.jUnit}
177
+ WHERE u.${k.owner} IS NOT NULL AND j.${k.jCompany} <> u.${k.owner}
178
+ UNION
179
+ SELECT cm.cm_company_uuid AS company, u.${k.pk} AS unit, u.${k.name} AS name
180
+ FROM ${MP} mp
181
+ JOIN ${CM} cm ON cm.cm_uuid = mp.mp_membership_uuid
182
+ JOIN ${T} u ON u.${k.pk} = mp.${k.posting}
183
+ WHERE u.${k.owner} IS NOT NULL AND cm.cm_company_uuid <> u.${k.owner}
184
+ ORDER BY company, unit`),
185
+ );
186
+
187
+ const findOwned = async (company, name) => {
188
+ const rows = rowsOf(
189
+ await query(
190
+ `SELECT ${k.pk} AS uuid FROM ${T} WHERE ${k.owner} = ? AND ${k.name} = ? ORDER BY ${k.pk} LIMIT 1`,
191
+ [company, name],
192
+ ),
193
+ );
194
+ return rows.length > 0 ? String(rows[0].uuid) : null;
195
+ };
196
+
197
+ for (const { company, unit, name } of pairs) {
198
+ let copy = await findOwned(company, name);
199
+ if (copy === null) {
200
+ await query(
201
+ `INSERT INTO ${T} (${k.pk}, ${k.name}, ${k.owner}, ${k.created})
202
+ SELECT UUID(), src.${k.name}, ?, src.${k.created} FROM ${T} src WHERE src.${k.pk} = ?`,
203
+ [company, unit],
204
+ );
205
+ copy = await findOwned(company, name);
206
+ if (copy === null) {
207
+ throw new Error(`${MIGRATION}: could not create a company-owned ${k.label} copy; re-run.`);
208
+ }
209
+ }
210
+ await link(query, APP, k, company, copy);
211
+ // Resume safety (security review P2): after a partial failure a membership may already hold the
212
+ // same posting (role + the other org unit) on the copy. Repointing its twin would break
213
+ // `IDX_mp_unique` (1062) and fail every re-run, so the twin on the original is removed first;
214
+ // the posting on the copy keeps the assignment.
215
+ const other = k.posting === 'mp_department_uuid' ? 'mp_branch_uuid' : 'mp_department_uuid';
216
+ await query(
217
+ `DELETE mp FROM ${MP} mp
218
+ JOIN ${CM} cm ON cm.cm_uuid = mp.mp_membership_uuid
219
+ JOIN ${MP} twin ON twin.mp_membership_uuid = mp.mp_membership_uuid
220
+ AND twin.mp_role_uuid = mp.mp_role_uuid
221
+ AND twin.${other} <=> mp.${other}
222
+ AND twin.${k.posting} = ?
223
+ WHERE mp.${k.posting} = ? AND cm.cm_company_uuid = ?`,
224
+ [copy, unit, company],
225
+ );
226
+ await query(
227
+ `UPDATE ${MP} mp
228
+ JOIN ${CM} cm ON cm.cm_uuid = mp.mp_membership_uuid
229
+ SET mp.${k.posting} = ?
230
+ WHERE mp.${k.posting} = ? AND cm.cm_company_uuid = ?`,
231
+ [copy, unit, company],
232
+ );
233
+ await query(`DELETE FROM ${J} WHERE ${k.jCompany} = ? AND ${k.jUnit} = ?`, [company, unit]);
234
+ }
235
+ }
236
+
237
+ function duplicateError(k, APP, groups, scope, tail) {
238
+ const listed = groups.slice(0, MAX_LISTED).map((g) => ` - "${g.name}": ${g.n} rows`);
239
+ if (groups.length > MAX_LISTED) listed.push(` - ... and ${groups.length - MAX_LISTED} more`);
240
+ return new Error(
241
+ `${MIGRATION}: ${groups.length} ${k.label} name(s) occur more than once ${scope} in ` +
242
+ `${APP}.${k.table} (compared as the column collation does: case- and accent-insensitive):\n` +
243
+ `${listed.join('\n')}\n${tail}`,
244
+ );
245
+ }
246
+
247
+ /** Step 5: per-company unique when absent; duplicates fail with their names. */
248
+ async function addCompanyUnique(query, APP, k) {
249
+ if (await hasIndex(query, APP, k.table, k.companyUnique)) return;
250
+ const groups = rowsOf(
251
+ await query(`SELECT MIN(${k.name}) AS name, COUNT(*) AS n
252
+ FROM \`${APP}\`.\`${k.table}\`
253
+ WHERE ${k.owner} IS NOT NULL
254
+ GROUP BY ${k.owner}, ${k.name}
255
+ HAVING COUNT(*) > 1
256
+ ORDER BY n DESC`),
257
+ );
258
+ if (groups.length > 0) {
259
+ throw duplicateError(
260
+ k,
261
+ APP,
262
+ groups,
263
+ 'within one company',
264
+ `No unique index was created. Rename or remove the duplicates, then re-run astram-migrate.`,
265
+ );
266
+ }
267
+ await query(
268
+ `CREATE UNIQUE INDEX ${k.companyUnique} ON \`${APP}\`.\`${k.table}\` (${k.owner}, ${k.name})`,
269
+ );
270
+ }
271
+
272
+ async function up(ctx) {
273
+ const { query } = ctx;
274
+ const APP = frameworkSchemasForMigration(ctx, process.env).app;
275
+ for (const k of KINDS) {
276
+ await addOwnerColumn(query, APP, k);
277
+ await assignOwners(query, APP, k);
278
+ if (await hasIndex(query, APP, k.table, k.globalUnique)) {
279
+ await query(`DROP INDEX ${k.globalUnique} ON \`${APP}\`.\`${k.table}\``);
280
+ }
281
+ await splitShared(query, APP, k);
282
+ await addCompanyUnique(query, APP, k);
283
+ }
284
+ }
285
+
286
+ async function down(ctx) {
287
+ const { query } = ctx;
288
+ const APP = frameworkSchemasForMigration(ctx, process.env).app;
289
+
290
+ // Pre-check every table before changing any: the global unique cannot come back while a name
291
+ // is held by more than one row (a split copy, or two companies' own rows).
292
+ const restore = [];
293
+ for (const k of KINDS) {
294
+ if (await hasIndex(query, APP, k.table, k.globalUnique)) continue;
295
+ const groups = rowsOf(
296
+ await query(`SELECT MIN(${k.name}) AS name, COUNT(*) AS n
297
+ FROM \`${APP}\`.\`${k.table}\`
298
+ GROUP BY ${k.name}
299
+ HAVING COUNT(*) > 1
300
+ ORDER BY n DESC`),
301
+ );
302
+ if (groups.length > 0) {
303
+ throw duplicateError(
304
+ k,
305
+ APP,
306
+ groups,
307
+ 'across companies',
308
+ 'Nothing was rolled back: the global unique name index cannot be restored while these ' +
309
+ 'exist (split copies are not merged back). Rename or remove them first.',
310
+ );
311
+ }
312
+ restore.push(k);
313
+ }
314
+
315
+ for (const k of KINDS) {
316
+ const T = `\`${APP}\`.\`${k.table}\``;
317
+ await query(`DROP INDEX IF EXISTS ${k.companyUnique} ON ${T}`);
318
+ if (restore.includes(k)) {
319
+ await query(`CREATE UNIQUE INDEX IF NOT EXISTS ${k.globalUnique} ON ${T} (${k.name})`);
320
+ }
321
+ await query(`ALTER TABLE ${T} DROP FOREIGN KEY IF EXISTS ${k.fk}`);
322
+ await query(`DROP INDEX IF EXISTS ${k.index} ON ${T}`);
323
+ await query(`ALTER TABLE ${T} DROP COLUMN IF EXISTS ${k.owner}`);
324
+ }
325
+ }
326
+
327
+ module.exports = { up, down };