@aerokit/sdk 14.69.0 → 14.71.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerokit/sdk",
3
- "version": "14.69.0",
3
+ "version": "14.71.0",
4
4
  "description": "Unified TypeScript SDK for modern cloud platforms.",
5
5
  "scripts": {
6
6
  "clean": "rm -rf dist",
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from '../fixtures.js';
2
2
  import { makeApi } from '../api.js';
3
3
  import { fillField, fillForm, resolveRelationSamples } from '../form.js';
4
- import { freshUniques, handleField, sampleRecord } from '../sample-values.js';
4
+ import { freshUniqueKeys, freshUniques, handleField, sampleRecord } from '../sample-values.js';
5
5
 
6
6
  // Server-side row lookup via the toolbar "Search <Entity>..." box - present on every list
7
7
  // layout (manage-list, master-detail, document) and searching the string columns server-side.
@@ -36,6 +36,8 @@ export function crudFlow(manifest, entity, opts = {}) {
36
36
  const cfg = opts.extend?.entities?.[entity.name] ?? {};
37
37
  const skip = new Set(cfg.skip ?? []);
38
38
  if (skip.has('crud')) return;
39
+ // no power page to walk: a composition child is reached through its parent (dirigible #7545)
40
+ if (!entity.route) return;
39
41
  // A hierarchy entity lists as a tree, and a calendar/slots entity replaces the table page
40
42
  // entirely - no filter row / data rows to drive the walk below; create/read/update/delete
41
43
  // stays covered by the REST flow. Same for an entity without a string handle field (nothing
@@ -47,6 +49,8 @@ export function crudFlow(manifest, entity, opts = {}) {
47
49
  const record = await freshUniques(makeApi(api, manifest), entity, sampleRecord(entity));
48
50
  const handle = handleField(entity);
49
51
  const relationSamples = await resolveRelationSamples(api, manifest, entity);
52
+ const taken = await freshUniqueKeys(makeApi(api, manifest), entity, record, relationSamples);
53
+ test.skip(!!taken, `every ${entity.name} combination of the unique key [${taken}] tried already has a row`);
50
54
 
51
55
  const created = record[handle.name];
52
56
  const listRoute = entity.route.replace(/[#/]/g, '\\$&');
@@ -61,7 +65,7 @@ export function crudFlow(manifest, entity, opts = {}) {
61
65
  await page.getByRole('button', { name: 'Create', exact: true }).click();
62
66
  // A create lands on the NEW record's page when there is more to do there - a document's
63
67
  // line items, a manage record's detail collections - and on the list otherwise. Saving an
64
- // edit is what returns to the list.
68
+ // edit returns to the list, except on a document, whose Save stays on it (#7359).
65
69
  const recordPage = entity.layout === 'document' ? '/[^/]+/edit' : '(/[^/]+/edit)?';
66
70
  await expect(page).toHaveURL(new RegExp(listRoute + recordPage + '$'));
67
71
  if (page.url().endsWith('/edit')) await page.goto(manifest.standaloneShell + entity.route);
@@ -78,7 +82,20 @@ export function crudFlow(manifest, entity, opts = {}) {
78
82
  // gets overwritten by the load and Save persists the OLD value
79
83
  await expect(page.locator('#f_' + handle.name)).toHaveValue(created);
80
84
  await fillField(page, handle, updated, opts);
81
- await page.getByRole('button', { name: 'Save', exact: true }).click();
85
+ if (entity.layout === 'document') {
86
+ // A document Save STAYS on the document and re-reads it (#7359): wait for the update to
87
+ // land, then leave for the list the way a user does (dirigible #7545).
88
+ const save = page.getByRole('button', { name: 'Save', exact: true });
89
+ const saved = page.waitForResponse((response) => response.request().method() === 'PUT' && response.ok());
90
+ await save.click();
91
+ await saved;
92
+ // Save is disabled again once the re-read header is the new pristine state - leaving before
93
+ // that would meet the unsaved-changes guard
94
+ await expect(save).toBeDisabled();
95
+ await page.getByRole('button', { name: 'Back to list', exact: true }).click();
96
+ } else {
97
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
98
+ }
82
99
  await expect(page).toHaveURL(new RegExp(listRoute + '$'));
83
100
  await filterBy(page, updated);
84
101
  await expect(dataRow(page, updated)).toHaveCount(1);
@@ -1,6 +1,11 @@
1
1
  import { expect, test } from '../fixtures.js';
2
2
  import { labelOf } from '../sample-values.js';
3
3
 
4
+ // The calendar / slot-picker view root. The slots page renders `x-h-slot-picker.responsive`, and an
5
+ // attribute selector matches an attribute NAME exactly, so the modifier form is listed with its dot
6
+ // escaped (dirigible #7545).
7
+ export const CALENDAR_OR_SLOTS = '[x-h-calendar], [x-h-slot-picker], [x-h-slot-picker\\.responsive]';
8
+
4
9
  // The shell breadcrumb's current crumb - the one page element every list layout shares that names
5
10
  // the entity (the sidebar label, which the manifest's labelPlural mirrors).
6
11
  export function listCrumb(page, entity) {
@@ -13,6 +18,8 @@ export function listCrumb(page, entity) {
13
18
  // column header per major field, or (when the entity has no rows yet) the empty state.
14
19
  // When seed data is expected, rows/nodes must actually be there.
15
20
  export function listFlow(manifest, entity) {
21
+ // no power page: a composition child is reached through its parent (dirigible #7545)
22
+ if (!entity.route) return;
16
23
  test(`${entity.name}: list page renders the declared columns`, async ({ page }) => {
17
24
  await page.goto(manifest.standaloneShell + entity.route);
18
25
  await expect(listCrumb(page, entity)).toBeVisible();
@@ -24,7 +31,7 @@ export function listFlow(manifest, entity) {
24
31
  }
25
32
  if (entity.layout === 'calendar' || entity.layout === 'slots') {
26
33
  // the view family renders a calendar / slot picker instead of the table
27
- await expect(page.locator('[x-h-calendar], [x-h-slot-picker]').first()).toBeVisible();
34
+ await expect(page.locator(CALENDAR_OR_SLOTS).first()).toBeVisible();
28
35
  return;
29
36
  }
30
37
  // filter({ visible: true }): the empty-state markup stays in the DOM (x-show) above the
@@ -11,7 +11,7 @@ const OVERLAY_TIMEOUT = 15_000;
11
11
  // Needs a concrete sample in the manifest: { language, base, translated }.
12
12
  export function multilingualFlow(manifest, entity) {
13
13
  const sample = entity.multilingualSample;
14
- if (!entity.multilingual || !sample) return;
14
+ if (!entity.multilingual || !sample || !entity.route) return;
15
15
 
16
16
  test(`${entity.name}: ${sample.language} translation overlays on read`, async ({ page }) => {
17
17
  await page.goto(manifest.standaloneShell + entity.route);
@@ -26,20 +26,23 @@ export function multilingualFlow(manifest, entity) {
26
26
  // Set the language key and reload, waiting for the list's own data GET (the paged getAll on the
27
27
  // entity's controller) so the assertion starts once the rows are on their way, and answering what
28
28
  // that read asked for and got back - which is what tells a slow render from a missing translation.
29
+ // The wait is registered only once the reload has committed: registered before it, it also matched
30
+ // a list GET still in flight from the document the reload replaced, whose body is gone (#7584). The
31
+ // new document issues its own GET only after its scripts have loaded, so it cannot slip past.
29
32
  async function reloadIn(page, manifest, entity, language) {
30
33
  await page.evaluate(([key, lang]) => localStorage.setItem(key, lang), [LANGUAGE_KEY, language]);
31
34
  const controllerPath = manifest.restBase + entity.api;
32
- const listRead = page.waitForResponse(
33
- (response) => response.request().method() === 'GET' && new URL(response.url()).pathname === controllerPath,
35
+ await page.reload({ waitUntil: 'commit' });
36
+ const response = await page.waitForResponse(
37
+ (candidate) => candidate.request().method() === 'GET' && new URL(candidate.url()).pathname === controllerPath,
34
38
  { timeout: OVERLAY_TIMEOUT },
35
39
  );
36
- await page.reload();
37
- const response = await listRead;
38
40
  return {
39
41
  language,
40
42
  acceptLanguage: await response.request().headerValue('accept-language'),
41
43
  status: response.status(),
42
- body: await response.text(),
44
+ // Only the diagnostic reads the body, so a body the browser no longer holds must not fail the flow.
45
+ body: await response.text().catch(() => null),
43
46
  };
44
47
  }
45
48
 
@@ -52,9 +55,14 @@ async function expectRowShowing(page, text, read) {
52
55
  throw new Error(
53
56
  `no list row shows '${text}' after reloading in '${read.language}': the list GET sent Accept-Language ` +
54
57
  `${read.acceptLanguage ?? '(none)'} and answered ${read.status}, ` +
55
- `${read.body.includes(text) ? 'WITH' : 'WITHOUT'} '${text}' in its body; the table's first row reads ` +
58
+ `${describeBody(read.body, text)}; the table's first row reads ` +
56
59
  `${firstRow ? `'${firstRow}'` : '(no rows)'}`,
57
60
  { cause: error },
58
61
  );
59
62
  }
60
63
  }
64
+
65
+ function describeBody(body, text) {
66
+ if (body === null) return 'its body unavailable';
67
+ return `${body.includes(text) ? 'WITH' : 'WITHOUT'} '${text}' in its body`;
68
+ }
package/test/flows/my.js CHANGED
@@ -2,6 +2,7 @@ import { makeApi } from '../api.js';
2
2
  import { expect, test } from '../fixtures.js';
3
3
  import { resolveRelationSamples } from '../form.js';
4
4
  import { freshUniques, sampleRecord } from '../sample-values.js';
5
+ import { CALENDAR_OR_SLOTS } from './list.js';
5
6
 
6
7
  // Wave 2 (UI parity): the personal PAGE renders what the wire serves. Driven by the same
7
8
  // own-row lifecycle as the wire test (created through the scoped controller, removed in the
@@ -28,7 +29,7 @@ function myUiTest(manifest, entity, mine, buildContext, idProperty) {
28
29
  const layout = entity.personal.layout ?? 'list';
29
30
  if (layout === 'calendar' || layout === 'slots') {
30
31
  await page.goto(manifest.standaloneShell + entity.personal.route);
31
- await expect(page.locator('[x-h-calendar], [x-h-slot-picker]').first()).toBeVisible();
32
+ await expect(page.locator(CALENDAR_OR_SLOTS).first()).toBeVisible();
32
33
  return;
33
34
  }
34
35
  if (layout === 'document-chat' || layout === 'document') {
@@ -40,7 +41,9 @@ function myUiTest(manifest, entity, mine, buildContext, idProperty) {
40
41
  await page.getByRole('button', { name: /send/i }).first().click();
41
42
  await expect(page.getByText(message).first(), 'the sent message must render as a bubble').toBeVisible();
42
43
  } else {
43
- await expect(page.locator('[x-h-toolbar-title]').first()).toBeVisible();
44
+ // visible only: the document carries a second toolbar title (its items table's), which can
45
+ // be the first in DOM order while hidden (dirigible #7545)
46
+ await expect(page.locator('[x-h-toolbar-title]').filter({ visible: true }).first()).toBeVisible();
44
47
  }
45
48
  return;
46
49
  }
@@ -1,7 +1,7 @@
1
1
  import { makeApi } from '../api.js';
2
2
  import { expect, test } from '../fixtures.js';
3
3
  import { resolveRelationSamples } from '../form.js';
4
- import { freshUniques, handleField, sampleRecord } from '../sample-values.js';
4
+ import { freshUniqueKeys, freshUniques, handleField, sampleRecord } from '../sample-values.js';
5
5
 
6
6
  // The same contract over the generated REST controllers, no browser: isolates backend
7
7
  // failures from UI failures and verifies the manifest's field names bind.
@@ -16,7 +16,10 @@ export function restFlow(manifest, entity, opts = {}) {
16
16
  const handle = handleField(entity);
17
17
  // same resolution the UI flow uses: cross-model targets via apiAbsolute, entityStatus
18
18
  // relations left to their init: DB default
19
- for (const sample of await resolveRelationSamples(api, manifest, entity)) {
19
+ const samples = await resolveRelationSamples(api, manifest, entity);
20
+ const taken = await freshUniqueKeys(client, entity, payload, samples);
21
+ test.skip(!!taken, `every ${entity.name} combination of the unique key [${taken}] tried already has a row`);
22
+ for (const sample of samples) {
20
23
  payload[sample.relation.name] = sample.id;
21
24
  }
22
25
  await freshUniques(client, entity, payload);
@@ -4,6 +4,7 @@ import { expect, test } from '../fixtures.js';
4
4
  // under its nav group and opens the module SPA in the embedded iframe. Requires the
5
5
  // navigation module that defines the groups, so it is opt-in: APPTEST_SHELL=1.
6
6
  export function shellFlow(manifest, entity) {
7
+ if (!entity.route) return; // no power page, no menu item
7
8
  test(`${entity.name}: shared shell menu item opens the module`, async ({ page }) => {
8
9
  test.skip(!process.env.APPTEST_SHELL, 'APPTEST_SHELL=1 enables the shared shell flow');
9
10
  await page.goto('/services/web/application/');
package/test/form.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { makeApi } from './api.js';
2
+ import { expect } from './fixtures.js';
2
3
  import { editableFields, handleField } from './sample-values.js';
3
4
 
4
5
  // Generated form controls: every field input has id="f_<Name>"; a to-one relation is a
@@ -34,15 +35,25 @@ async function relationTrigger(page, relation) {
34
35
 
35
36
  export async function pickDropdown(page, relation, optionText) {
36
37
  const trigger = await relationTrigger(page, relation);
37
- await trigger.click();
38
- const option = page.getByRole('option', { name: optionText }).first();
39
- try {
40
- await option.click({ timeout: 10_000 });
41
- } catch {
42
- // the option list re-rendered mid-click (async option load reflow) - reopen and retry
38
+ const input = page.locator('#f_' + relation.name);
39
+ // visible only: an option list that is closing, or another select's, must not be the one clicked
40
+ const option = page.getByRole('option', { name: optionText }).filter({ visible: true }).first();
41
+ for (let attempt = 0; ; attempt++) {
43
42
  await trigger.click();
44
- await option.click({ force: true });
43
+ try {
44
+ await option.click({ timeout: 10_000 });
45
+ } catch {
46
+ // the option list re-rendered mid-click (async option load reflow) - reopen and retry
47
+ await trigger.click();
48
+ await option.click({ force: true });
49
+ }
50
+ // The list must be closed before the next relation's trigger is clicked: a click landing while
51
+ // it is still open only dismisses it, and that select stays empty - the second of two relations
52
+ // to the same entity was left at its placeholder that way (dirigible #7545).
53
+ await expect(option).toBeHidden();
54
+ if (!(await input.count()) || (await input.inputValue()) !== '' || attempt > 0) break;
45
55
  }
56
+ if (await input.count()) await expect(input, `${relation.name} must hold the picked "${optionText}"`).not.toHaveValue('');
46
57
  }
47
58
 
48
59
  // Resolve a live option for each to-one relation: take the first suitable row of the
@@ -54,7 +65,9 @@ export async function pickDropdown(page, relation, optionText) {
54
65
  // - a where: option filter narrows the candidate rows to matching ones;
55
66
  // - a dependsOn cascade forces CONSISTENT samples: the dependent row is chosen first and
56
67
  // its filterBy FK becomes the trigger sibling's sample (independent first rows would
57
- // pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options).
68
+ // pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options);
69
+ // - a relation an agree check or a composite unique key names keeps a page of candidate rows, so
70
+ // agreeing rows can be chosen here and a free key combination by freshUniqueKeys.
58
71
  export async function resolveRelationSamples(request, manifest, entity) {
59
72
  const api = makeApi(request, manifest);
60
73
  const idProperty = manifest.idProperty ?? 'Id';
@@ -67,11 +80,13 @@ export async function resolveRelationSamples(request, manifest, entity) {
67
80
  return { rows: await api.list(target, limit), labelFrom: relation.labelFrom ?? handleField(target)?.name ?? 'Name' };
68
81
  }
69
82
 
83
+ const constrained = new Set([...(entity.agree ?? []).flatMap((check) => check.relations), ...(entity.uniqueKeys ?? []).flat()]);
70
84
  const samples = [];
71
85
  for (const relation of entity.relations ?? []) {
72
86
  if (relation.entityStatus) continue;
73
- // a filtered picker needs a matching candidate, so fetch a page and filter client-side
74
- const wide = relation.where || relation.leafOnly;
87
+ // a filtered picker needs a matching candidate, so fetch a page and filter client-side; a
88
+ // constrained relation needs alternatives to choose from
89
+ const wide = relation.where || relation.leafOnly || constrained.has(relation.name);
75
90
  const { rows: fetched, labelFrom } = await fetchRows(relation, wide ? 200 : 1);
76
91
  let rows = relation.where ? fetched?.filter((r) => String(r[relation.where.by]) === String(relation.where.value)) : fetched;
77
92
  if (relation.leafOnly && rows?.length) {
@@ -91,12 +106,18 @@ export async function resolveRelationSamples(request, manifest, entity) {
91
106
  // relation unset rather than clicking blind
92
107
  continue;
93
108
  }
94
- samples.push({
109
+ const sample = {
95
110
  relation,
96
- row: rows[0],
97
- id: rows[0][idProperty],
98
- label: label ?? String(rows[0][idProperty]),
99
- });
111
+ rows,
112
+ // re-point the sample at another of its candidate rows
113
+ use(row) {
114
+ this.row = row;
115
+ this.id = row[idProperty];
116
+ this.label = row[labelFrom] ?? String(row[idProperty]);
117
+ },
118
+ };
119
+ sample.use(rows[0]);
120
+ samples.push(sample);
100
121
  }
101
122
 
102
123
  // cascade consistency: re-point each dependsOn trigger at the row the dependent's choice implies
@@ -104,6 +125,9 @@ export async function resolveRelationSamples(request, manifest, entity) {
104
125
  const dependsOn = sample.relation.dependsOn;
105
126
  if (!dependsOn?.filterBy) continue;
106
127
  const trigger = samples.find((s) => s.relation.name === dependsOn.relation);
128
+ // a cascade pair is chosen together - neither side may be swapped for a candidate later
129
+ sample.rows = [sample.row];
130
+ if (trigger) trigger.rows = [trigger.row];
107
131
  const impliedId = sample.row[dependsOn.filterBy];
108
132
  if (!trigger || impliedId == null || trigger.id === impliedId) continue;
109
133
  const path = trigger.relation.apiAbsolute ?? (trigger.relation.api ? manifest.restBase + trigger.relation.api : null);
@@ -111,20 +135,46 @@ export async function resolveRelationSamples(request, manifest, entity) {
111
135
  const row = await api.getPath(`${path}/${impliedId}`);
112
136
  if (!row) continue;
113
137
  trigger.row = row;
138
+ trigger.rows = [row];
114
139
  trigger.id = impliedId;
115
140
  trigger.label = row[trigger.relation.labelFrom ?? 'Name'];
116
141
  }
142
+ alignAgreeing(entity, samples);
117
143
  return samples;
118
144
  }
119
145
 
146
+ // An agree check refuses a record whose two relations point at targets that differ on onProperty
147
+ // (a transfer between stores of two companies), and the first row of each target is an arbitrary
148
+ // pair. Choose the first lead row for which every other side has a candidate carrying the same value.
149
+ function alignAgreeing(entity, samples) {
150
+ for (const check of entity.agree ?? []) {
151
+ const sides = check.relations.map((name) => samples.find((s) => s.relation.name === name));
152
+ if (sides.some((side) => !side)) continue; // an unset side has nothing to disagree about
153
+ const [lead, ...others] = sides;
154
+ for (const row of lead.rows) {
155
+ const value = row[check.onProperty];
156
+ if (value == null) continue;
157
+ const matches = others.map((side) => side.rows.find((candidate) => String(candidate[check.onProperty]) === String(value)));
158
+ if (matches.some((match) => !match)) continue;
159
+ lead.use(row);
160
+ others.forEach((side, i) => side.use(matches[i]));
161
+ break;
162
+ }
163
+ }
164
+ }
165
+
120
166
  export async function fillForm(page, manifest, entity, record, relationSamples, opts = {}) {
121
167
  for (const field of editableFields(entity)) {
122
168
  if (record[field.name] === undefined) continue;
123
169
  await fillField(page, field, record[field.name], opts);
124
170
  }
171
+ // a readOnly relation renders as a disabled combobox (an update-time calculated action, a
172
+ // platform-owned FK) - the server sets it, so there is nothing to pick (dirigible #7554); the
173
+ // REST flows still post its sample, which the calculated action overwrites or checks
174
+ const pickable = relationSamples.filter((s) => !s.relation.readOnly);
125
175
  // cascade order: a dependsOn trigger must be picked BEFORE its dependent, so the narrowed
126
176
  // option list is the one the dependent's sample was chosen from
127
- const triggers = relationSamples.filter((s) => !s.relation.dependsOn);
128
- const dependents = relationSamples.filter((s) => s.relation.dependsOn);
177
+ const triggers = pickable.filter((s) => !s.relation.dependsOn);
178
+ const dependents = pickable.filter((s) => s.relation.dependsOn);
129
179
  for (const sample of [...triggers, ...dependents]) await pickDropdown(page, sample.relation, sample.label);
130
180
  }
@@ -1,4 +1,5 @@
1
1
  const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
2
+ const LOWER = ALPHA.toLowerCase();
2
3
  const DIGITS = '0123456789';
3
4
 
4
5
  function rand(chars, n) {
@@ -33,6 +34,12 @@ export function sampleValue(field) {
33
34
  // full ISO instant: the generated Java entities bind java.time.Instant, which rejects a
34
35
  // zone-less value; the UI fill slices this to the datetime-local shape
35
36
  return '2026-07-08T10:00:00Z';
37
+ // the input shapes of an <input type="month|week"> - a month column is VARCHAR(7), so the
38
+ // default marker overflows it (dirigible #7545)
39
+ case 'month':
40
+ return '2026-07';
41
+ case 'week':
42
+ return '2026-W28';
36
43
  default:
37
44
  return 'APPTEST-' + rand(ALPHA + DIGITS, 6);
38
45
  }
@@ -61,6 +68,10 @@ function shaped(field, value) {
61
68
  `apptest.${token}@apptest.example.com`, // format: email and other address-shaped patterns
62
69
  token, // letters and digits, no separator
63
70
  rand(DIGITS, 10), // a numeric code
71
+ // a letters-only code (an ISO language or country code, ^[a-z]{2,3}$) - kept short, the shape
72
+ // such a pattern declares, and within the declared length (dirigible #7545)
73
+ rand(LOWER, Math.min(field.length ?? 3, 3)),
74
+ rand(ALPHA, Math.min(field.length ?? 3, 3)),
64
75
  ];
65
76
  const match = candidates.find((candidate) => candidate.length <= (field.length ?? 255) && regex.test(candidate));
66
77
  return match ?? (field.required ? value : undefined);
@@ -87,6 +98,10 @@ function uniqueValue(field) {
87
98
  const at = new Date(Date.UTC(2100, 0, 1) + (n % 36500) * 86400000);
88
99
  return field.type === 'date' ? at.toISOString().slice(0, 10) : at.toISOString().replace(/\.\d{3}Z$/, 'Z');
89
100
  }
101
+ case 'month':
102
+ return `${2100 + (n % 900)}-${String(1 + (n % 12)).padStart(2, '0')}`;
103
+ case 'week':
104
+ return `${2100 + (n % 900)}-W${String(1 + (n % 52)).padStart(2, '0')}`;
90
105
  default:
91
106
  return sampleValue({ ...field, unique: false });
92
107
  }
@@ -118,6 +133,65 @@ export async function freshUniques(client, entity, record) {
118
133
  return record;
119
134
  }
120
135
 
136
+ // A composite unique key (`unique: [ProjectTimesheet, Employee]` - one timesheet per employee per
137
+ // project-month) refuses a second row carrying the same combination with 409, and the first row of
138
+ // each relation is the combination a seed most likely holds already (dirigible #7545). Walk the
139
+ // candidate rows of the key's relations (and redraw its own fields) until the controller's filtered
140
+ // read finds no row with that combination, keeping every agree check satisfied. Answers the key no
141
+ // free combination was found for - the flow skips with it - or null when the record is free. Call it
142
+ // before the relation samples are copied into the record.
143
+ export async function freshUniqueKeys(client, entity, record, samples) {
144
+ for (const key of entity.uniqueKeys ?? []) {
145
+ const related = key.map((name) => samples.find((s) => s.relation.name === name)).filter(Boolean);
146
+ const drawn = editableFields(entity).filter((f) => key.includes(f.name) && f.name in record);
147
+ // a member left unset is NULL, and a NULL never collides
148
+ if (related.length + drawn.length < key.length) continue;
149
+ let free = false;
150
+ for (const combination of combinations(related.map((s) => s.rows), related.length ? 50 : 5)) {
151
+ combination.forEach((row, i) => related[i].use(row));
152
+ if (!agreeing(entity, samples)) continue;
153
+ const value = (name) => related.find((s) => s.relation.name === name)?.id ?? record[name];
154
+ let rows;
155
+ try {
156
+ rows = await client.search(entity, key.map((name) => ({ propertyName: name, operator: 'EQ', value: String(value(name)) })));
157
+ } catch {
158
+ free = true; // an older controller without /search - keep the combination, as freshUniques does
159
+ break;
160
+ }
161
+ if (!rows?.length) {
162
+ free = true;
163
+ break;
164
+ }
165
+ for (const field of drawn) record[field.name] = field.type === 'string' ? sampleValue(field) : uniqueValue(field);
166
+ }
167
+ if (!free) return key;
168
+ if (drawn.length) steerCompares(entity, record);
169
+ }
170
+ return null;
171
+ }
172
+
173
+ // The first `limit` entries of the cartesian product of the candidate lists, in order - so the first
174
+ // combination is the one the samples started with. No lists yields `limit` empty combinations: a key
175
+ // of own fields only is retried by redrawing them.
176
+ function* combinations(lists, limit) {
177
+ const index = lists.map(() => 0);
178
+ for (let produced = 0; produced < limit; produced++) {
179
+ yield lists.map((list, i) => list[index[i]]);
180
+ let i = lists.length - 1;
181
+ while (i >= 0 && ++index[i] === lists[i].length) index[i--] = 0;
182
+ if (lists.length && i < 0) return;
183
+ }
184
+ }
185
+
186
+ // Whether the relation samples satisfy every agree check of the entity (an unset side has nothing to
187
+ // disagree about).
188
+ export function agreeing(entity, samples) {
189
+ return (entity.agree ?? []).every((check) => {
190
+ const values = check.relations.map((name) => samples.find((s) => s.relation.name === name)?.row?.[check.onProperty]);
191
+ return values.some((value) => value == null) || values.every((value) => String(value) === String(values[0]));
192
+ });
193
+ }
194
+
121
195
  export function editableFields(entity) {
122
196
  return (entity.fields ?? []).filter((f) => !f.readOnly && !f.primaryKey && !f.generated);
123
197
  }