@startsimpli/integrations 0.2.26 → 0.2.27

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": "@startsimpli/integrations",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,319 @@
1
+ /**
2
+ * OPENING ONE RECORD (bd startsim-4s4um RED / startsim-2lt8y GREEN).
3
+ *
4
+ * REPORTED, with a live URL:
5
+ * `https://tandem.ai.startsimpli.com/library?doc=5ab48b58-8573-4114-b408-215dd0eaf5d7`
6
+ * renders no document.
7
+ *
8
+ * WHAT THE TENANT ACTUALLY DOES, read out of
9
+ * `start-simpli-api/tenant-starter/apps/api/{views,filters}.py` @ origin/main
10
+ * rather than assumed:
11
+ *
12
+ * - `/api/v1/entities/<id>/` is a real DRF detail route. It answers the ONE
13
+ * row, or 404 when the row is absent for this reader — the read gate makes a
14
+ * forbidden row ABSENT rather than refused, deliberately.
15
+ * - `/api/v1/entities/` applies `?type=` plus a fixed vocabulary
16
+ * (`search`, `source`, `party`, `occurred_after`, `occurred_before`,
17
+ * `related_*`, `order`) and `attr.*`. `_note_unknown` sweeps EVERY OTHER
18
+ * KEY into `ignored_filters` and returns the page unnarrowed. `id` is not in
19
+ * that vocabulary, which is why `?id=<uuid>` comes back with count 180.
20
+ *
21
+ * SO THE FAKE BELOW IGNORES UNKNOWN PARAMETERS RATHER THAN ERRORING ON THEM,
22
+ * and that is the whole point of writing a new one instead of reaching for
23
+ * `createFixtureCorpusHttp`: that fixture applies `?search=` when a backend is
24
+ * named and never reports `ignored_filters`, so a lookup expressed as a filter
25
+ * would come back plausibly narrowed there. A fake that answers an unsupported
26
+ * filter with `[]` — or with a filtered page — makes this suite green while the
27
+ * deployed Library stays blank, which is the exact trap this bead names.
28
+ *
29
+ * WHAT IS NOT THE BUG, established before any of this was written. The DEPLOYED
30
+ * bundle on tandem.ai.startsimpli.com already asks the detail route:
31
+ *
32
+ * getDocument(e){let[a,r]=await Promise.all([
33
+ * t.get(`${m}/${encodeURIComponent(e)}`),f()]);...}
34
+ *
35
+ * and that slash-less path resolves — `curl` returns 401 with
36
+ * `allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS`, the detail route's own verb
37
+ * set, not a redirect and not the list's. The request shape is right. What is
38
+ * missing is everything that happens when it does not come back: the client
39
+ * accepts whatever it is handed without checking it is the record it asked for,
40
+ * and the composer turns every failure into an empty screen.
41
+ */
42
+ import { describe, expect, it } from 'vitest';
43
+ import { render, screen, within } from '@testing-library/react';
44
+
45
+ import { LibraryPage } from '../components/pages/LibraryPage';
46
+ import { createCorpusApi } from '../corpus/client';
47
+ import type { CorpusHttp } from '../corpus/client';
48
+ import type { TenantEntity } from '../corpus/entities';
49
+ import type { CorpusScope } from '../corpus/types';
50
+
51
+ const SCOPE: CorpusScope = { orgId: 'org_tandem' };
52
+
53
+ /** The record the user deep-linked, live, when they reported this. */
54
+ const OPENED = '5ab48b58-8573-4114-b408-215dd0eaf5d7';
55
+ /** A well-formed id for a row that is simply not there. */
56
+ const ABSENT = '00000000-0000-4000-8000-000000000000';
57
+
58
+ function document(id: string, title: string, index: number): TenantEntity {
59
+ return {
60
+ id,
61
+ entity_type: 'source_document',
62
+ external_id: `granola:${id}`,
63
+ name: title,
64
+ org_id: SCOPE.orgId,
65
+ created_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T10:00:00Z`,
66
+ data: {
67
+ source: 'granola',
68
+ title,
69
+ summary: `Summary of ${title}.`,
70
+ occurred_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T09:00:00Z`,
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * A corpus the size of the reported one, with the deep-linked record buried
77
+ * where the composer's on-page shortcut cannot reach it.
78
+ *
79
+ * THE POSITION MATTERS. The record is 100 rows in, so page one does not contain
80
+ * it and `documents.find(...)` misses — which is precisely the state the
81
+ * reported URL lands in and the only state where the lookup is exercised at all.
82
+ */
83
+ const CORPUS: TenantEntity[] = Array.from({ length: 180 }, (_, index) =>
84
+ index === 100
85
+ ? document(OPENED, 'Tandem <> Obwb quarterly review', index)
86
+ : document(`doc-${index}`, `Record ${index}`, index),
87
+ );
88
+
89
+ /** Parameters this tenant recognises on `/entities/`. Everything else is ignored. */
90
+ const HONOURED = new Set([
91
+ 'search', 'source', 'party', 'occurred_after', 'occurred_before',
92
+ 'related_to', 'related_via', 'related_direction', 'related_depth', 'order',
93
+ ]);
94
+ const RESERVED = new Set(['page', 'page_size', 'format', 'type']);
95
+
96
+ interface Recorded {
97
+ path: string;
98
+ params: Record<string, unknown>;
99
+ }
100
+
101
+ /**
102
+ * The tenant, as the tenant is.
103
+ *
104
+ * `detailStatus` stands in for the read gate and the session: the tenant answers
105
+ * an absent row with 404 and an expired bearer with 401, and those are different
106
+ * facts that must not collapse into the same screen.
107
+ */
108
+ function tenantHttp(options: {
109
+ entities?: TenantEntity[];
110
+ pageSize?: number;
111
+ detailStatus?: number;
112
+ } = {}) {
113
+ const rows = options.entities ?? CORPUS;
114
+ const pageSize = options.pageSize ?? 20;
115
+ const requests: Recorded[] = [];
116
+
117
+ const http: CorpusHttp = {
118
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
119
+ const params = init?.params ?? {};
120
+ requests.push({ path, params });
121
+
122
+ if (path.includes('relationships')) {
123
+ return { count: 0, next: null, previous: null, results: [] } as T;
124
+ }
125
+ if (path.includes('connections')) {
126
+ return { count: 0, next: null, previous: null, results: [] } as T;
127
+ }
128
+ if (!path.includes('entities')) {
129
+ throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
130
+ }
131
+
132
+ // BOTH SPELLINGS OF THE DETAIL ROUTE, because both were measured to
133
+ // resolve on the live tenant. A fake that insisted on the trailing slash
134
+ // would manufacture evidence for a path change the wire does not ask for.
135
+ const detail = /entities\/([^/?]+)\/?$/.exec(path)?.[1];
136
+ if (detail) {
137
+ if (options.detailStatus) {
138
+ throw Object.assign(new Error(`${path} responded ${options.detailStatus}`), {
139
+ status: options.detailStatus,
140
+ });
141
+ }
142
+ const found = rows.find((row) => String(row.id) === decodeURIComponent(detail));
143
+ // ABSENT, NOT REFUSED — `readable_types_q` / `readable_rows_q` narrow the
144
+ // queryset, so a row this reader may not see 404s exactly like one that
145
+ // was never written.
146
+ if (!found) throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
147
+ return found as T;
148
+ }
149
+
150
+ // The LIST. `?type=` narrows; every other key is swept into
151
+ // `ignored_filters` and the page comes back exactly as wide as it was.
152
+ const type = params.type ? String(params.type) : '';
153
+ const matching = type ? rows.filter((row) => row.entity_type === type) : rows;
154
+ const size = Number(params.page_size ?? pageSize) || pageSize;
155
+ const page = Number(params.page ?? 1) || 1;
156
+ const start = (page - 1) * size;
157
+ const applied: string[] = [];
158
+ const ignored: string[] = [];
159
+ for (const key of Object.keys(params)) {
160
+ if (RESERVED.has(key)) continue;
161
+ (HONOURED.has(key) ? applied : ignored).push(key);
162
+ }
163
+ return {
164
+ count: matching.length,
165
+ next: start + size < matching.length ? `?page=${page + 1}` : null,
166
+ previous: page > 1 ? `?page=${page - 1}` : null,
167
+ results: matching.slice(start, start + size),
168
+ applied_filters: applied,
169
+ ignored_filters: ignored,
170
+ } as T;
171
+ },
172
+ };
173
+
174
+ return { http, requests };
175
+ }
176
+
177
+ /**
178
+ * The tenant as it answers a lookup that was expressed as a FILTER.
179
+ *
180
+ * `GET /api/v1/entities/?id=5ab48b58-…` was checked against prod for this epic
181
+ * and returned 200 with count 180 — the whole corpus, unnarrowed, because `id`
182
+ * is not a filter this API has. Any client that reaches the entity list instead
183
+ * of the detail route gets THIS: a page envelope where it expected a row.
184
+ *
185
+ * The contract under test is that such a response is a FAILURE, not a document.
186
+ * Mapping it produces a record with no id and no title that renders as a blank
187
+ * panel — an arbitrary answer wearing a real record's clothes, which is worse
188
+ * than an error because nobody can tell it is wrong.
189
+ */
190
+ function pageEnvelopeHttp() {
191
+ const { requests } = tenantHttp();
192
+ const http: CorpusHttp = {
193
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
194
+ requests.push({ path, params: init?.params ?? {} });
195
+ if (path.includes('entities')) {
196
+ return {
197
+ count: CORPUS.length,
198
+ next: '?page=2',
199
+ previous: null,
200
+ results: CORPUS.slice(0, 20),
201
+ ignored_filters: ['id'],
202
+ } as T;
203
+ }
204
+ return { count: 0, next: null, previous: null, results: [] } as T;
205
+ },
206
+ };
207
+ return { http, requests };
208
+ }
209
+
210
+ function corpusOver(http: CorpusHttp) {
211
+ return createCorpusApi({ http, scope: SCOPE, supportedFilters: [] });
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // the client
216
+ // ---------------------------------------------------------------------------
217
+
218
+ describe('a document lookup returns THAT document', () => {
219
+ it('answers with the record that was asked for, not the first of many', async () => {
220
+ const api = corpusOver(tenantHttp().http);
221
+ const found = await api.getDocument(OPENED);
222
+ expect(found.id).toBe(OPENED);
223
+ expect(found.title).toBe('Tandem <> Obwb quarterly review');
224
+ });
225
+
226
+ it('asks the DETAIL route, and never expresses the id as a filter', async () => {
227
+ // The distinction the whole bead turns on. `?id=` is swept into
228
+ // `ignored_filters` by this API, so a lookup spelled that way is handed the
229
+ // unfiltered corpus and has to guess.
230
+ const { http, requests } = tenantHttp();
231
+ await corpusOver(http).getDocument(OPENED);
232
+
233
+ const lookup = requests.find((request) => request.path.includes(OPENED));
234
+ expect(lookup, 'the id belongs in the PATH').toBeTruthy();
235
+ expect(
236
+ requests.every((request) => !('id' in request.params)),
237
+ 'no request may carry the id as a query parameter',
238
+ ).toBe(true);
239
+ });
240
+
241
+ it('refuses a page envelope instead of fabricating a record from it', async () => {
242
+ // A response that is a LIST is not the record, whatever produced it — an
243
+ // ignored `?id=`, a proxy that dropped the path segment, a deployment whose
244
+ // detail route is a list. Mapping it yields id `undefined` and the title
245
+ // "Untitled unknown record": a blank panel that claims to be the document.
246
+ const api = corpusOver(pageEnvelopeHttp().http);
247
+ await expect(api.getDocument(OPENED)).rejects.toMatchObject({ status: 404 });
248
+ });
249
+
250
+ it('surfaces a record that is genuinely absent as not-found', async () => {
251
+ const api = corpusOver(tenantHttp().http);
252
+ await expect(api.getDocument(ABSENT)).rejects.toMatchObject({ status: 404 });
253
+ });
254
+
255
+ it('never answers one id with a different record', async () => {
256
+ const api = corpusOver(tenantHttp().http);
257
+ const found = await api.getDocument('doc-3');
258
+ expect(found.id).toBe('doc-3');
259
+ await expect(api.getDocument(ABSENT)).rejects.toBeTruthy();
260
+ });
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // the screen
265
+ // ---------------------------------------------------------------------------
266
+
267
+ describe('the Library opens the record it was deep-linked to', () => {
268
+ it('renders a record that is not on the first page', async () => {
269
+ const api = corpusOver(tenantHttp().http);
270
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
271
+
272
+ expect(
273
+ await screen.findByRole('region', { name: 'Record detail' }),
274
+ ).toHaveTextContent('Tandem <> Obwb quarterly review');
275
+ });
276
+
277
+ it('SAYS SO when the record cannot be found, instead of rendering nothing', async () => {
278
+ // THE REPORTED SYMPTOM. `?doc=<id>` in the address bar, a list on screen,
279
+ // and no record and no explanation — the composer swallowed the failure into
280
+ // `setSelected(null)`. A screen that asked for a record and shows neither the
281
+ // record nor a reason is the one outcome the user cannot act on.
282
+ const api = corpusOver(tenantHttp().http);
283
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
284
+
285
+ const notice = await screen.findByRole('region', { name: 'Record' });
286
+ expect(notice).toHaveTextContent(/could not be found/i);
287
+ expect(notice).toHaveTextContent(new RegExp(ABSENT));
288
+ });
289
+
290
+ it('calls an expired session an expired session, not a missing record', async () => {
291
+ // "Sign in again" and "that record is gone" send the reader to different
292
+ // places, and only one of them is true at a time.
293
+ const api = corpusOver(tenantHttp({ detailStatus: 401 }).http);
294
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
295
+
296
+ const notice = await screen.findByRole('region', { name: 'Record' });
297
+ expect(notice).toHaveTextContent(/session has expired/i);
298
+ expect(notice).not.toHaveTextContent(/could not be found/i);
299
+ });
300
+
301
+ it('calls a refusal a refusal', async () => {
302
+ const api = corpusOver(tenantHttp({ detailStatus: 403 }).http);
303
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
304
+
305
+ expect(await screen.findByRole('region', { name: 'Record' })).toHaveTextContent(
306
+ /do not have access/i,
307
+ );
308
+ });
309
+
310
+ it('lets the reader dismiss the notice and get back to the list', async () => {
311
+ // Without this the `?doc=` in the URL is a dead end: the panel cannot be
312
+ // closed because there is no panel, only a message.
313
+ const api = corpusOver(tenantHttp().http);
314
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
315
+
316
+ const notice = await screen.findByRole('region', { name: 'Record' });
317
+ expect(within(notice).getByRole('button', { name: /close/i })).toBeInTheDocument();
318
+ });
319
+ });
@@ -85,6 +85,29 @@ function readFailureMessage(err: unknown): string {
85
85
  return (err as { message?: string })?.message || 'Could not load the library.';
86
86
  }
87
87
 
88
+ /**
89
+ * Why the record the screen was asked to open is not on the screen
90
+ * (bd startsim-2lt8y).
91
+ *
92
+ * FOUR ANSWERS, NOT ONE, and the branch is the point: "sign in again", "you may
93
+ * not see this", "that record is not here" and "something went wrong" send the
94
+ * reader to four different places, and only one of them is true at a time.
95
+ * Calling an expired session a missing record is a different lie from the one
96
+ * this bead is fixing, not a smaller version of it.
97
+ *
98
+ * The id is NAMED because it came out of the address bar. A citation or a
99
+ * bookmark that no longer resolves is worth being able to quote back to
100
+ * somebody, and "a record" is not quotable.
101
+ */
102
+ function openFailureMessage(err: unknown, id: string): string {
103
+ const status = (err as { status?: number })?.status;
104
+ if (status === 404) {
105
+ return `That record could not be found in this workspace (${id}). It may have been removed, or it may not be one you can see.`;
106
+ }
107
+ if (status === 401 || status === 403) return readFailureMessage(err);
108
+ return (err as { message?: string })?.message || `That record could not be opened (${id}).`;
109
+ }
110
+
88
111
  function formatDate(value: string | null | undefined): string {
89
112
  if (!value) return 'No date';
90
113
  const at = Date.parse(value);
@@ -160,6 +183,13 @@ export function LibraryPage({
160
183
  const [parties, setParties] = React.useState<Party[]>([]);
161
184
  const [selectedId, setSelectedId] = React.useState<string | null>(initialDocumentId ?? null);
162
185
  const [selected, setSelected] = React.useState<SourceDocument | null>(null);
186
+ /**
187
+ * Why the open record is not open. Held separately from `loadError`, which is
188
+ * about the LIST: a Library that loaded fine and a citation that no longer
189
+ * resolves are different facts, and blanking the list for the second one would
190
+ * be a worse answer than the silence this replaces.
191
+ */
192
+ const [selectError, setSelectError] = React.useState('');
163
193
  const [loading, setLoading] = React.useState(true);
164
194
  const [loadError, setLoadError] = React.useState('');
165
195
  /**
@@ -216,18 +246,22 @@ export function LibraryPage({
216
246
  }, [api, filters, scope]);
217
247
 
218
248
  React.useEffect(() => {
219
- if (!selectedId) { setSelected(null); return; }
249
+ if (!selectedId) { setSelected(null); setSelectError(''); return; }
220
250
  let live = true;
221
251
  void (async () => {
222
252
  // Prefer the row already on screen; fall back to a fetch so a citation
223
253
  // deep-link opens a record that is not on the current page.
224
254
  const onPage = documents.find((document) => document.id === selectedId);
225
- if (onPage) { setSelected(onPage); return; }
255
+ if (onPage) { setSelected(onPage); setSelectError(''); return; }
226
256
  try {
227
257
  const fetched = await api.getDocument(selectedId);
228
- if (live) setSelected(fetched);
229
- } catch {
230
- if (live) setSelected(null);
258
+ if (live) { setSelected(fetched); setSelectError(''); }
259
+ } catch (err) {
260
+ // THE REPORTED BUG (bd startsim-4s4um). This was `setSelected(null)` and
261
+ // nothing else, so `?doc=<id>` in the address bar produced a Library
262
+ // with no record and no explanation — the one outcome a reader cannot
263
+ // act on, and indistinguishable from a page that simply ignored them.
264
+ if (live) { setSelected(null); setSelectError(openFailureMessage(err, selectedId)); }
231
265
  }
232
266
  })();
233
267
  return () => { live = false; };
@@ -235,6 +269,7 @@ export function LibraryPage({
235
269
 
236
270
  const select = React.useCallback((id: string | null) => {
237
271
  setSelectedId(id);
272
+ if (!id) setSelectError('');
238
273
  onSelectDocument?.(id);
239
274
  }, [onSelectDocument]);
240
275
 
@@ -459,6 +494,16 @@ export function LibraryPage({
459
494
  onFilterByParty={(partyId) => update({ partyId })}
460
495
  />
461
496
  ) : null}
497
+
498
+ {/* Asked for, and not shown — said out loud rather than left blank. */}
499
+ {!selected && selectError ? (
500
+ <section className={cn.card} aria-label="Record">
501
+ <div className="flex items-start justify-between gap-4">
502
+ <p className={cn.warning} role="alert">{selectError}</p>
503
+ <button type="button" className={cn.button} onClick={() => select(null)}>Close</button>
504
+ </div>
505
+ </section>
506
+ ) : null}
462
507
  </div>
463
508
  );
464
509
  }
@@ -320,6 +320,38 @@ function appliedFilters(
320
320
  return requested.filter((key) => supported.includes(key));
321
321
  }
322
322
 
323
+ /**
324
+ * Is this response the ROW that was asked for, or something else wearing a 200?
325
+ *
326
+ * Three things are refused, and each of them has been seen answering a
327
+ * single-record request somewhere: a DRF page (what an ignored `?id=` returns —
328
+ * verified on the live tenant, 200 with count 180), a body with no id at all,
329
+ * and a row whose id is somebody else's.
330
+ */
331
+ function isRecord(value: unknown, id: string): value is TenantEntity {
332
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
333
+ // A page is the shape a LIST answers with. It is never one record, whatever
334
+ // its count happens to be.
335
+ if (Array.isArray((value as { results?: unknown }).results)) return false;
336
+ const own = (value as TenantEntity).id;
337
+ if (own === undefined || own === null || String(own).trim() === '') return false;
338
+ // Case-insensitively, because a UUID has two legal spellings and the server
339
+ // answers in its own. Anything else different is a DIFFERENT RECORD, which is
340
+ // the entire reason this function exists.
341
+ return String(own).trim().toLowerCase() === String(id).trim().toLowerCase();
342
+ }
343
+
344
+ /**
345
+ * The tenant's own word for a row that is not there.
346
+ *
347
+ * 404 and not a bespoke error, because the tenant's read gate makes a row this
348
+ * principal may not see ABSENT rather than refused — so callers get one status
349
+ * to branch on, and "not found" is already the honest word for both.
350
+ */
351
+ function notFound(id: string): Error & { status: number } {
352
+ return Object.assign(new Error(`No record ${id} in this workspace.`), { status: 404 });
353
+ }
354
+
323
355
  export function createCorpusApi(
324
356
  options: CreateCorpusApiOptions,
325
357
  ): CorpusApi & CommitmentApi & ExtractionRunStore {
@@ -423,11 +455,34 @@ export function createCorpusApi(
423
455
  };
424
456
  },
425
457
 
458
+ /**
459
+ * ONE record, by the DETAIL ROUTE — never by a filter (bd startsim-2lt8y).
460
+ *
461
+ * `/api/v1/entities/<id>/` is a real route on the tenant and it answers the
462
+ * one row. The entity LIST has no `id` filter: `EntityQuery._note_unknown`
463
+ * sweeps every unrecognised parameter into `ignored_filters` and returns the
464
+ * page exactly as wide as it was, so `?id=<uuid>` comes back 200 with the
465
+ * whole corpus. A lookup spelled that way cannot fail — it just answers with
466
+ * somebody else's record.
467
+ *
468
+ * WHICH IS WHY THE RESPONSE IS CHECKED. Asking the right route is not the
469
+ * same as being answered by it: a proxy that drops the path segment, a
470
+ * deployment whose detail route lists, an `?id=` that got ignored upstream —
471
+ * all hand back a DRF page where a row was expected, and `toSourceDocument`
472
+ * will map it without complaint into a document with the id `undefined` and
473
+ * the title "Untitled unknown record". That renders as a blank panel that
474
+ * claims to be the record, which is worse than an error because nobody
475
+ * downstream can tell it is wrong. So a response that is not the row that
476
+ * was asked for is a 404, reported the same way the tenant reports an absent
477
+ * row — the read gate makes a forbidden row ABSENT rather than refused, so
478
+ * "not found" is already the honest word for both.
479
+ */
426
480
  async getDocument(id) {
427
481
  const [entity, byRecord] = await Promise.all([
428
482
  http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`),
429
483
  participantGraph(),
430
484
  ]);
485
+ if (!isRecord(entity, id)) throw notFound(id);
431
486
  return toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []);
432
487
  },
433
488