@startsimpli/integrations 0.2.33 → 0.2.35

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.
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import {
34
34
  DEFAULT_PARTICIPANT_EDGES,
35
+ FOLDER_ROOT,
35
36
  DEFAULT_RUN_KEY,
36
37
  DEFAULT_TYPE_KEYS,
37
38
  MAX_ID_LIST,
@@ -40,11 +41,14 @@ import {
40
41
  indexParticipants,
41
42
  toCommitment,
42
43
  toExtractionRun,
44
+ toFolder,
43
45
  toMoneyEvent,
44
46
  toParty,
45
47
  toSourceDocument,
46
48
  } from './entities';
47
- import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
49
+ import type {
50
+ CorpusTypeKeys, TenantEntity, TenantFacetRow, TenantRelationship,
51
+ } from './entities';
48
52
  import {
49
53
  FOCUS_TYPE,
50
54
  IN_FOCUS,
@@ -58,9 +62,11 @@ import type {
58
62
  CommitmentApi,
59
63
  CommitmentQuery,
60
64
  CorpusApi,
65
+ CorpusFolder,
61
66
  CorpusPage,
62
67
  CorpusScope,
63
68
  DocumentQuery,
69
+ FolderQuery,
64
70
  ExtractionRun,
65
71
  ExtractionRunStore,
66
72
  AuthoredItem,
@@ -214,6 +220,17 @@ interface ServerFilterReport {
214
220
  */
215
221
  const TEXT_FILTER_MAX = 500;
216
222
 
223
+ /**
224
+ * The attribute the folder tree is DERIVED from (start-simpli-api#369 ships it
225
+ * as a source-agnostic container: `/Contracts`, never `/Contracts/SOW.pdf`).
226
+ *
227
+ * One name, in one place, because the facet read enumerates it and the document
228
+ * filter narrows on it — and a tree whose nodes came from one attribute while
229
+ * its file lists filtered on another would draw folders that open onto nothing.
230
+ */
231
+ const PATH_ATTRIBUTE = 'path';
232
+
233
+
217
234
  function commitmentParams(query: CommitmentQuery, type: string): Record<string, unknown> {
218
235
  const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
219
236
  const out: Record<string, unknown> = {
@@ -270,6 +287,8 @@ const DOCUMENT_FILTER_PARAMS: Array<[keyof DocumentQuery, string[]]> = [
270
287
  ['partyId', ['party', 'related_to']],
271
288
  ['occurredAfter', ['occurred_after', 'attr.occurred_at__gte']],
272
289
  ['occurredBefore', ['occurred_before', 'attr.occurred_at__lte']],
290
+ ['folder', ['attr.path']],
291
+ ['underFolder', ['attr.path__under']],
273
292
  ];
274
293
 
275
294
  function reportedDocumentFilters(query: DocumentQuery, applied: string[]): string[] {
@@ -351,6 +370,39 @@ function params(query: DocumentQuery, type: string) {
351
370
  if (query.partyId) out.party = query.partyId;
352
371
  if (query.occurredAfter) out.occurred_after = query.occurredAfter;
353
372
  if (query.occurredBefore) out.occurred_before = query.occurredBefore;
373
+ // THE FOLDER BOUNDS, and only ever one of them. `attr.path` is the shelf and
374
+ // `attr.path__under` is the subtree; sending both would AND an exact match
375
+ // with its own subtree and answer with the shelf, which is the exact-match
376
+ // query wearing a second parameter. The composer chooses; this just refuses to
377
+ // send a contradiction.
378
+ if (query.underFolder) out['attr.path__under'] = query.underFolder;
379
+ else if (query.folder) out['attr.path'] = query.folder;
380
+ return out;
381
+ }
382
+
383
+ /**
384
+ * `GET /api/v1/entities/facets/` — its OWN parameter builder, and that is not
385
+ * tidiness (bd startsim-ei8gm.5).
386
+ *
387
+ * THE FACET ACTION REFUSES UNKNOWN PARAMETERS. `_Scoped.initial()` screens it
388
+ * against `_FACET_FILTERS` before any handler runs — the tenant added
389
+ * `extra_list_parameters` precisely so a new list-shaped route could not inherit
390
+ * the old fail-open posture — so handing it `params(query, type)` would 400 the
391
+ * whole read on `occurred_after`, `party` or `source` the moment a filter was
392
+ * set. Its vocabulary is the entity filters PLUS `attr` and `under`, and this
393
+ * sends exactly `type`, `attr`, `under` and the page.
394
+ */
395
+ function facetParams(query: FolderQuery, type: string) {
396
+ const out: Record<string, unknown> = {
397
+ type,
398
+ attr: PATH_ATTRIBUTE,
399
+ // The FULL path the server handed back, verbatim. `/` is the root and means
400
+ // the whole corpus — `subtree_bound()` makes the root's descendant prefix
401
+ // `/`, which every stored path begins with.
402
+ under: query.under || FOLDER_ROOT,
403
+ page: query.page ?? 1,
404
+ };
405
+ if (query.pageSize) out.page_size = query.pageSize;
354
406
  return out;
355
407
  }
356
408
 
@@ -359,9 +411,9 @@ function appliedFilters(
359
411
  query: DocumentQuery,
360
412
  supported: Array<string>,
361
413
  ): string[] {
362
- const requested = (['search', 'source', 'partyId', 'occurredAfter', 'occurredBefore'] as const).filter(
363
- (key) => Boolean(query[key]),
364
- );
414
+ const requested = ([
415
+ 'search', 'source', 'partyId', 'occurredAfter', 'occurredBefore', 'folder', 'underFolder',
416
+ ] as const).filter((key) => Boolean(query[key]));
365
417
  return requested.filter((key) => supported.includes(key));
366
418
  }
367
419
 
@@ -683,6 +735,38 @@ export function createCorpusApi(
683
735
  return page<Party>(types.party, query, (entity) => toParty(entity, scope));
684
736
  },
685
737
 
738
+ /**
739
+ * ONE LEVEL of the folder tree (bd startsim-ei8gm.5).
740
+ *
741
+ * `GET /api/v1/entities/facets/?type=source_document&attr=path&under=/Contracts`
742
+ * -> the CHILD FOLDERS of `/Contracts`, paginated, each row already carrying
743
+ * its descendant count and whether anything sits below it.
744
+ *
745
+ * THE GATE IS THE SERVER'S AND IT STAYS THERE. The tenant answers this out
746
+ * of `EntityViewSet.get_queryset()` itself — the same type gate, row gate,
747
+ * field gate and filters the row read composes — so the folders a member is
748
+ * shown are the folders their own documents are in, and nothing else. On a
749
+ * tenant whose `source_document` is `row_scope: personal` that is the whole
750
+ * of the protection: a folder name leaks plenty on its own
751
+ * (`/Legal/Terminations`, `/Acquisition-TargetCo`). Which is why this method
752
+ * asks for ONE `under=` and returns exactly what came back for it. It does
753
+ * not cache across containers, does not merge two responses, and does not
754
+ * infer a parent from a child's path — any of which would rebuild the leak
755
+ * client-side out of rows the server was careful about.
756
+ */
757
+ async listFolders(query: FolderQuery): Promise<CorpusPage<CorpusFolder>> {
758
+ const response = await http.get<DrfPage<TenantFacetRow> & ServerFilterReport>(
759
+ `${entities}/facets`,
760
+ { params: facetParams(query, types.document) },
761
+ );
762
+ return {
763
+ count: response.count ?? response.results?.length ?? 0,
764
+ next: response.next ?? null,
765
+ previous: response.previous ?? null,
766
+ results: (response.results ?? []).map(toFolder),
767
+ };
768
+ },
769
+
686
770
  async listMoneyEvents(query = {}) {
687
771
  const events = await page<TenantEntity>(types.moneyEvent, query, (entity) => entity);
688
772
  const byRecord = await participantsFor(events.results.map((entity) => entity.id));
@@ -940,6 +1024,11 @@ export function createCorpusApi(
940
1024
  Number(connection.consecutive_failures ?? connection.consecutiveFailures ?? 0) || 0;
941
1025
  const documentCount = counts.get(key) ?? null;
942
1026
  const error = String(connection.check_error ?? connection.checkError ?? '');
1027
+ // WHY THE LAST POLL FAILED — not why the credential did (bd
1028
+ // startsim-ei8gm.2). Both spellings for the same reason the three
1029
+ // timestamps above read both: `@startsimpli/api` camelCases response
1030
+ // keys in the browser and a plain server-side fetch does not.
1031
+ const lastError = String(connection.last_error ?? connection.lastError ?? '');
943
1032
  const status = connection.status ?? '';
944
1033
 
945
1034
  let health: SourceFreshness['status'] = 'ok';
@@ -947,7 +1036,7 @@ export function createCorpusApi(
947
1036
  if (error || status === 'error' || status === 'needs_reauth') {
948
1037
  health = 'error';
949
1038
  detail = error || `Connection status is ${String(status)}.`;
950
- } else if (failures > 0 || (lastAttemptAt && !lastSyncedAt)) {
1039
+ } else if (failures > 0 || lastError || (lastAttemptAt && !lastSyncedAt)) {
951
1040
  // IT TRIED AND IT FAILED. Central's view exists partly so that "a card
952
1041
  // cannot read 'nothing yet' while it is in fact failing on a
953
1042
  // schedule" — a run that FINISHED while nothing has ever finished well
@@ -955,12 +1044,33 @@ export function createCorpusApi(
955
1044
  // when an older sync did land. Note what does NOT appear in this
956
1045
  // condition: `lastCheckedAt`. A verified key is not a delivered
957
1046
  // record, in either direction.
1047
+ //
1048
+ // `lastError` JOINS THAT CONDITION, because the two facts have
1049
+ // different writers: `consecutive_failures` is a counter on the
1050
+ // CONNECTION and the reason is derived from the newest RUN. Central
1051
+ // sends a reason only while that newest run is the failed one, so a
1052
+ // reason with a zero streak behind it is still a source whose last
1053
+ // sync failed — and turning only on the counter reported that as
1054
+ // `stale`, which reads as "nothing to do yet".
958
1055
  health = 'error';
959
- detail =
1056
+ // THE COUNT SURVIVES; IT IS NO LONGER THE WHOLE SENTENCE. "One blip"
1057
+ // and "sixteen days" are different problems, so the streak stays —
1058
+ // what was missing was the half a reader can act on. Composed here
1059
+ // rather than server-side because the streak is a connection field
1060
+ // and the reason is a run field, and central sends them separately
1061
+ // on purpose (bd startsim-1cow: conflating two facts is how a
1062
+ // freshness strip lies).
1063
+ const streak =
960
1064
  failures > 0
961
- ? `${failures} sync${failures === 1 ? '' : 's'} in a row failed` +
962
- `${lastSyncedAt ? '' : ' and nothing has arrived yet'}.`
963
- : 'A sync ran but nothing arrived.';
1065
+ ? `${failures} sync${failures === 1 ? '' : 's'} in a row failed`
1066
+ : lastError
1067
+ ? 'The last sync failed'
1068
+ : 'A sync ran but nothing arrived';
1069
+ const nothingYet =
1070
+ failures > 0 && !lastSyncedAt ? ' and nothing has arrived yet' : '';
1071
+ // An em dash, not a colon: the server's reason usually contains its
1072
+ // own colons ("[config_error] ...") and a second one reads as a typo.
1073
+ detail = lastError ? `${streak}${nothingYet} — ${lastError}` : `${streak}${nothingYet}.`;
964
1074
  } else if (!lastSyncedAt) {
965
1075
  // `never` regardless of `lastCheckedAt`, which is the whole bead.
966
1076
  health = 'never';
@@ -982,6 +1092,10 @@ export function createCorpusApi(
982
1092
  consecutiveFailures: failures,
983
1093
  status: health,
984
1094
  ...(detail ? { detail } : {}),
1095
+ // Carried separately as well as folded into `detail`, so a surface
1096
+ // that wants the reason on its own — a tooltip, a copy button, the
1097
+ // control plane's card — does not have to unpick a composed sentence.
1098
+ ...(lastError ? { lastError } : {}),
985
1099
  };
986
1100
  });
987
1101
  },
@@ -25,6 +25,7 @@
25
25
  import type {
26
26
  Commitment,
27
27
  CommitmentMention,
28
+ CorpusFolder,
28
29
  CorpusScope,
29
30
  DocumentSection,
30
31
  MoneyEvent,
@@ -556,6 +557,14 @@ export function toSourceDocument(
556
557
  ),
557
558
  scope: scopeOf(entity, fallbackScope),
558
559
  ingestedAt: asIso(entity.created_at ?? entity.createdAt),
560
+ // WHERE IT LIVES. `path` is the source-agnostic CONTAINER attribute
561
+ // (start-simpli-api#369) — the folder, not the file — and it is the same
562
+ // attribute the folder tree enumerates, so a row's badge and the tree node
563
+ // it belongs under can never disagree. `folder`/`container` are alternate
564
+ // storage spellings, not vendor knowledge.
565
+ ...(asText(pick(data, 'path', 'folder', 'container')).trim()
566
+ ? { folder: asText(pick(data, 'path', 'folder', 'container')).trim() }
567
+ : {}),
559
568
  ...(withheldFieldsOf(entity).length
560
569
  ? { withheldFields: withheldFieldsOf(entity) }
561
570
  : {}),
@@ -863,3 +872,69 @@ export function toExtractionRun(entity: TenantEntity): import('./types').Extract
863
872
  return null;
864
873
  }
865
874
  }
875
+
876
+ // ---------------------------------------------------------------------------
877
+ // folders (bd startsim-ei8gm.5)
878
+ // ---------------------------------------------------------------------------
879
+
880
+ /**
881
+ * One row of `GET /api/v1/entities/facets/?attr=path&under=…`, as the tenant
882
+ * really spells it.
883
+ */
884
+ export interface TenantFacetRow {
885
+ value?: unknown;
886
+ count?: unknown;
887
+ /** The wire spelling. */
888
+ has_children?: unknown;
889
+ /**
890
+ * …and the spelling the BROWSER sees. `@startsimpli/api`'s fetch wrapper
891
+ * converts every response key from snake_case to camelCase, so this key
892
+ * arrives as `hasChildren` in the app and as `has_children` from a plain
893
+ * server-side fetch. Reading only one is not a nicety: it is the bug that made
894
+ * the Tracker report "this workspace's data API does not filter by status"
895
+ * while the server verifiably was, and here it would render every folder in
896
+ * the corpus as a childless leaf with no disclosure triangle — a tree that
897
+ * silently stops at depth one. The tolerance belongs at the seam.
898
+ */
899
+ hasChildren?: unknown;
900
+ }
901
+
902
+ /**
903
+ * The top of the tree, and the one spelling of it.
904
+ *
905
+ * `/`, never `''`: the tenant's `normalize_container()` says outright that a
906
+ * connector writes `"/"` for a top-level entry and that `""` is the spelling of
907
+ * "we do not know where this is". As `under=` it means the WHOLE corpus, because
908
+ * `subtree_bound()` makes the root's descendant prefix `/`, which every stored
909
+ * path begins with.
910
+ */
911
+ export const FOLDER_ROOT = '/';
912
+
913
+ /**
914
+ * The last segment of a path, for DISPLAY only.
915
+ *
916
+ * The full path is what gets sent back — see `CorpusFolder.path`. This exists so
917
+ * a node reads `2026` rather than `/Contracts/2026`, and it is deliberately the
918
+ * only place in the client that takes a path apart.
919
+ */
920
+ export function folderName(path: string): string {
921
+ const trimmed = String(path ?? '').replace(/\/+$/, '');
922
+ const cut = trimmed.lastIndexOf('/');
923
+ return (cut >= 0 ? trimmed.slice(cut + 1) : trimmed) || '/';
924
+ }
925
+
926
+ /** A facet row -> a tree node. */
927
+ export function toFolder(row: TenantFacetRow): CorpusFolder {
928
+ const path = asText(row?.value);
929
+ const count = Number(row?.count);
930
+ // `has_children` is ABSENT on the flat (no `under=`) read, and absent must
931
+ // read as "no", not as "unknown": a node drawn with a triangle it cannot
932
+ // expand is worse than one drawn without.
933
+ const deeper = row?.has_children ?? row?.hasChildren;
934
+ return {
935
+ path,
936
+ name: folderName(path),
937
+ count: Number.isFinite(count) ? count : 0,
938
+ hasChildren: deeper === true || deeper === 'true' || deeper === 1,
939
+ };
940
+ }
@@ -275,6 +275,10 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
275
275
  occurred_at: '2026-02-01T00:00:00Z',
276
276
  url: 'https://www.dropbox.example/scl/fi/9812f0a4b1/acme-msa-signed.pdf',
277
277
  participant_count: 2,
278
+ // THE CONTAINER, not the file (start-simpli-api#369). A Dropbox file
279
+ // carries its parent's PATH and never its parent's id, which is why the
280
+ // tree is derived from this attribute rather than from folder rows.
281
+ path: '/Contracts/Acme',
278
282
  extracted_text: [
279
283
  'MASTER SERVICES AGREEMENT',
280
284
  '1. Term. This Agreement begins on 1 February 2026 and continues for twelve (12) months.',
@@ -296,10 +300,111 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
296
300
  occurred_at: '2026-01-05T00:00:00Z',
297
301
  url: 'https://docs.google.example/document/d/1QpZv7t9kR',
298
302
  participant_count: 1,
303
+ path: '/Pricing',
299
304
  extracted_text:
300
305
  'Platform: USD 4,000/mo (40 seats). Overage: USD 90/seat/mo. Discounts require VP approval above 15%.',
301
306
  },
302
307
  }),
308
+ // ---- A DRIVE WITH DEPTH, AND WITH THINGS NOBODY READS ----
309
+ //
310
+ // The corpus a file source really produces (bd startsim-h6xnu): folders
311
+ // several levels deep, and a large share of the rows recovered from old decks
312
+ // — fonts, licence files, binaries. They are NOT filtered out, here or on the
313
+ // screen: an extension denylist is wrong the first time somebody's actual work
314
+ // is a .ttf, and categorising by folder makes it unnecessary (bd
315
+ // startsim-nbhno). What they are is TEXTLESS, which is what the row's own
316
+ // control has to be honest about.
317
+ row({
318
+ type: 'source_document', id: 'dbx_c19a', source: 'dropbox', vendorId: 'id:c19a77',
319
+ name: 'Statement of work — phase 2.pdf',
320
+ ownerSub: OWNER_A, createdAt: '2026-02-04T06:00:00Z',
321
+ data: {
322
+ subtype: 'file',
323
+ title: 'Statement of work — phase 2.pdf',
324
+ summary: 'Phase 2 scope: migration, two integrations, eight weeks.',
325
+ occurred_at: '2026-02-03T00:00:00Z',
326
+ path: '/Contracts/Acme/2026',
327
+ participant_count: 0,
328
+ },
329
+ }),
330
+ row({
331
+ type: 'source_document', id: 'dbx_44b0', source: 'dropbox', vendorId: 'id:44b0e2',
332
+ name: 'NDA (countersigned).pdf',
333
+ ownerSub: OWNER_A, createdAt: '2026-01-14T06:00:00Z',
334
+ data: {
335
+ subtype: 'file',
336
+ title: 'NDA (countersigned).pdf',
337
+ summary: 'Mutual NDA, two-year term.',
338
+ occurred_at: '2026-01-13T00:00:00Z',
339
+ path: '/Contracts',
340
+ participant_count: 0,
341
+ },
342
+ }),
343
+ row({
344
+ // A FILE WITH NO TEXT AT ALL — the case the row's "Get text" control exists
345
+ // for, and the one the screen used to answer with a sentence.
346
+ type: 'source_document', id: 'dbx_ttf1', source: 'dropbox', vendorId: 'id:ttf001',
347
+ name: 'Poppins-BoldItalic.ttf',
348
+ ownerSub: OWNER_A, createdAt: '2026-01-02T06:00:00Z',
349
+ data: {
350
+ subtype: 'file',
351
+ title: 'Poppins-BoldItalic.ttf',
352
+ path: '/Decks/2022 pitch/fonts',
353
+ participant_count: 0,
354
+ },
355
+ }),
356
+ row({
357
+ type: 'source_document', id: 'dbx_ttf2', source: 'dropbox', vendorId: 'id:ttf002',
358
+ name: 'Helvetica75.TTF',
359
+ ownerSub: OWNER_A, createdAt: '2026-01-02T06:01:00Z',
360
+ data: {
361
+ subtype: 'file',
362
+ title: 'Helvetica75.TTF',
363
+ path: '/Decks/2022 pitch/fonts',
364
+ participant_count: 0,
365
+ },
366
+ }),
367
+ row({
368
+ type: 'source_document', id: 'dbx_deck1', source: 'dropbox', vendorId: 'id:deck01',
369
+ name: 'Lilo Deck V4.key',
370
+ ownerSub: OWNER_A, createdAt: '2026-01-03T06:00:00Z',
371
+ data: {
372
+ subtype: 'file',
373
+ title: 'Lilo Deck V4.key',
374
+ path: '/Decks/2022 pitch',
375
+ participant_count: 0,
376
+ },
377
+ }),
378
+ row({
379
+ // THE SAME FILENAME IN A DIFFERENT FOLDER — bd startsim-maios's own example
380
+ // of why a flat list cannot be read: "'Lilo Deck V4.key' is five rows and a
381
+ // reader cannot tell them apart". The folder is what tells them apart.
382
+ type: 'source_document', id: 'dbx_deck2', source: 'dropbox', vendorId: 'id:deck02',
383
+ name: 'Lilo Deck V4.key',
384
+ ownerSub: OWNER_A, createdAt: '2026-01-04T06:00:00Z',
385
+ data: {
386
+ subtype: 'file',
387
+ title: 'Lilo Deck V4.key',
388
+ path: '/Decks/2023 pitch',
389
+ participant_count: 0,
390
+ },
391
+ }),
392
+ row({
393
+ // A SIBLING WHOSE NAME IS A PREFIX OF ANOTHER FOLDER'S. `/Contracts-old`
394
+ // must never appear under `/Contracts`, which is the whole reason `__under`
395
+ // is one parameter rather than a `startswith` a client assembled.
396
+ type: 'source_document', id: 'dbx_old1', source: 'dropbox', vendorId: 'id:old001',
397
+ name: 'Superseded MSA 2024.pdf',
398
+ ownerSub: OWNER_A, createdAt: '2025-06-01T06:00:00Z',
399
+ data: {
400
+ subtype: 'file',
401
+ title: 'Superseded MSA 2024.pdf',
402
+ summary: 'Prior MSA, superseded February 2026.',
403
+ occurred_at: '2024-06-01T00:00:00Z',
404
+ path: '/Contracts-old',
405
+ participant_count: 0,
406
+ },
407
+ }),
303
408
  row({
304
409
  // ---- A DIFFERENT CUSTOMER WORKSPACE ----
305
410
  // Deliberately the BEST answer to "what did we agree with Acme on renewal":
@@ -896,6 +1001,137 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
896
1001
  * Every request is recorded, so "did the browser filter it" and "how many round
897
1002
  * trips did one screen cost" are both assertable.
898
1003
  */
1004
+ /**
1005
+ * `normalize_container()` from the tenant's `apps/api/filters.py`, to the letter.
1006
+ *
1007
+ * `/Contracts/` and `/Contracts` are the same folder — a client that built the
1008
+ * prefix by joining segments sends the first — and THE ROOT KEEPS ITS SLASH,
1009
+ * because `""` is the spelling of "we do not know where this is" and stripping
1010
+ * `/` down to it would answer with the wrong thing rather than nothing.
1011
+ */
1012
+ function normalizeContainer(raw: unknown): string {
1013
+ const text = String(raw ?? '').trim();
1014
+ if (!text) return '/';
1015
+ const trimmed = text.replace(/\/+$/, '');
1016
+ return trimmed || '/';
1017
+ }
1018
+
1019
+ /**
1020
+ * `subtree_bound()`: `/Contracts` -> `['/Contracts', '/Contracts/']`.
1021
+ *
1022
+ * The pair, not a prefix, and the fixture copies it exactly because the whole
1023
+ * point of the parameter is that neither half alone is right:
1024
+ * `startsWith('/Contracts')` also matches the sibling `/Contracts-old`, and
1025
+ * `startsWith('/Contracts/')` misses the documents sitting directly in
1026
+ * `/Contracts`. The root is the case naive code gets wrong — its descendant
1027
+ * prefix is `/`, which every stored path begins with, so `under=/` means
1028
+ * EVERYTHING rather than the handful of top-level files.
1029
+ */
1030
+ function subtreeBound(raw: unknown): [string, string] {
1031
+ const prefix = normalizeContainer(raw);
1032
+ return prefix.endsWith('/') ? [prefix, prefix] : [prefix, `${prefix}/`];
1033
+ }
1034
+
1035
+ /** The container attribute as the delivery writes it. */
1036
+ function pathOf(entity: TenantEntity): string {
1037
+ const value = (entity.data as Record<string, unknown> | undefined)?.path;
1038
+ return typeof value === 'string' ? value : '';
1039
+ }
1040
+
1041
+ /**
1042
+ * `GET /api/v1/entities/facets/`, as the tenant really answers it.
1043
+ *
1044
+ * FAITHFUL TO THE PARTS A CLIENT CAN GET WRONG, which is the only reason a
1045
+ * fixture is worth having:
1046
+ *
1047
+ * - UNKNOWN PARAMETERS ARE REFUSED, not ignored. `_Scoped.initial()` screens
1048
+ * this action against `_FACET_FILTERS` before any handler runs. A fixture
1049
+ * that tolerated `occurred_after` here would let a client ship a facet call
1050
+ * built from the document query and 400 in production the first time somebody
1051
+ * set a date filter.
1052
+ * - `value` IS THE FULL CHILD PATH, so it goes straight back into the next
1053
+ * `under=`. Returning a bare segment would make every client re-join paths.
1054
+ * - `count` IS DESCENDANTS, not direct children.
1055
+ * - A DOCUMENT SITTING DIRECTLY IN THE FOLDER IS NOT A CHILD NODE. Its derived
1056
+ * value is the prefix itself and the server excludes it — a file is not a
1057
+ * folder with an empty name.
1058
+ */
1059
+ function facetRead(rows: TenantEntity[], params: Record<string, unknown>) {
1060
+ // `_FACET_FILTERS` = the entity vocabulary + `attr` + `under`, plus the
1061
+ // `attr.` prefix and DRF's own reserved words.
1062
+ const vocabulary = new Set([
1063
+ 'type', 'id', 'id__in', 'external_id', 'external_id__in', 'search', 'source',
1064
+ 'party', 'occurred_after', 'occurred_before', 'related_to', 'related_via',
1065
+ 'related_direction', 'related_depth', 'order', 'attr', 'under',
1066
+ 'page', 'page_size', 'format',
1067
+ ]);
1068
+ for (const key of Object.keys(params)) {
1069
+ if (vocabulary.has(key) || key.startsWith('attr.')) continue;
1070
+ throw Object.assign(new Error(`unknown parameter '${key}'`), { status: 400 });
1071
+ }
1072
+ const attribute = String(params.attr ?? '').trim();
1073
+ if (!attribute) {
1074
+ throw Object.assign(
1075
+ new Error('attr: name the attribute to enumerate, e.g. ?attr=path'),
1076
+ { status: 400 },
1077
+ );
1078
+ }
1079
+ const type = params.type ? String(params.type) : '';
1080
+ const scoped = (type ? rows.filter((entity) => entity.entity_type === type) : rows)
1081
+ .filter((entity) => {
1082
+ const value = (entity.data as Record<string, unknown> | undefined)?.[attribute];
1083
+ return typeof value === 'string' && value !== '';
1084
+ });
1085
+
1086
+ if (params.under === undefined) {
1087
+ const flat = new Map<string, number>();
1088
+ for (const entity of scoped) {
1089
+ const value = String((entity.data as Record<string, unknown>)[attribute]);
1090
+ flat.set(value, (flat.get(value) ?? 0) + 1);
1091
+ }
1092
+ const results = [...flat]
1093
+ .map(([value, count]) => ({ value, count }))
1094
+ .sort((a, b) => a.value.localeCompare(b.value));
1095
+ return { ...slicePage(results, params), applied_filters: ['attr'] };
1096
+ }
1097
+
1098
+ const [self, descendants] = subtreeBound(params.under);
1099
+ const groups = new Map<string, { count: number; deeper: boolean }>();
1100
+ for (const entity of scoped) {
1101
+ const value = String((entity.data as Record<string, unknown>)[attribute]);
1102
+ if (value !== self && !value.startsWith(descendants)) continue;
1103
+ const rest = value.slice(descendants.length);
1104
+ const cut = rest.indexOf('/');
1105
+ const child = `${descendants}${cut < 0 ? rest : rest.slice(0, cut)}`;
1106
+ // The DIRECT FILE BUCKET. A document sitting in the named folder leaves
1107
+ // nothing after the prefix, so its derived value is the prefix — it is a
1108
+ // file, not a child folder with no name, and it is not a node.
1109
+ if (child === descendants) continue;
1110
+ const at = groups.get(child) ?? { count: 0, deeper: false };
1111
+ at.count += 1;
1112
+ at.deeper = at.deeper || cut >= 0;
1113
+ groups.set(child, at);
1114
+ }
1115
+ const results = [...groups]
1116
+ .map(([value, at]) => ({ value, count: at.count, has_children: at.deeper }))
1117
+ .sort((a, b) => a.value.localeCompare(b.value));
1118
+ return { ...slicePage(results, params), applied_filters: ['attr', 'under'] };
1119
+ }
1120
+
1121
+ /** The same DRF envelope `slice()` builds, outside the closure that owns it. */
1122
+ function slicePage<T>(rows: T[], params: Record<string, unknown>) {
1123
+ const page = Number(params.page ?? 1) || 1;
1124
+ const asked = Number(params.page_size);
1125
+ const size = Number.isFinite(asked) && asked > 0 ? Math.min(asked, MAX_ID_LIST) : 50;
1126
+ const start = (page - 1) * size;
1127
+ return {
1128
+ count: rows.length,
1129
+ next: start + size < rows.length ? `?page=${page + 1}` : null,
1130
+ previous: page > 1 ? `?page=${page - 1}` : null,
1131
+ results: rows.slice(start, start + size),
1132
+ };
1133
+ }
1134
+
899
1135
  export function createFixtureCorpusHttp(options: {
900
1136
  entities?: TenantEntity[];
901
1137
  relationships?: TenantRelationship[];
@@ -1020,6 +1256,11 @@ export function createFixtureCorpusHttp(options: {
1020
1256
  ignored_filters: query.ignored.slice().sort(),
1021
1257
  } as T;
1022
1258
  }
1259
+ // `/api/v1/entities/facets/` — BEFORE the detail branch below, which
1260
+ // would otherwise read "facets" as a record id and 404.
1261
+ if (/entities\/facets\/?$/.test(path)) {
1262
+ return facetRead(entities, params) as T;
1263
+ }
1023
1264
  if (path.includes('entities')) {
1024
1265
  const id = /entities\/(.+)$/.exec(path)?.[1];
1025
1266
  if (id) {
@@ -1041,7 +1282,28 @@ export function createFixtureCorpusHttp(options: {
1041
1282
  matchesTenantSearch(term, `${entity.name ?? ''} ${jsonStrings(entity.data)}`),
1042
1283
  );
1043
1284
  }
1044
- return slice(rows, params) as T;
1285
+ // THE FOLDER BOUNDS, as the tenant really applies them
1286
+ // (start-simpli-api#391). `attr.path` is the shelf; `attr.path__under`
1287
+ // is the shelf PLUS its descendants — a union, which is why it is one
1288
+ // parameter and not a prefix the client assembled. A fixture that
1289
+ // treated `__under` as a plain `startsWith` would let a client pass
1290
+ // here and match `/Contracts-old` in production.
1291
+ const applied: string[] = [];
1292
+ const exact = params['attr.path'];
1293
+ const under = params['attr.path__under'];
1294
+ if (exact !== undefined) {
1295
+ rows = rows.filter((entity) => pathOf(entity) === normalizeContainer(exact));
1296
+ applied.push('attr.path');
1297
+ } else if (under !== undefined) {
1298
+ const [self, descendants] = subtreeBound(under);
1299
+ rows = rows.filter((entity) => {
1300
+ const at = pathOf(entity);
1301
+ return at === self || at.startsWith(descendants);
1302
+ });
1303
+ applied.push('attr.path__under');
1304
+ }
1305
+ if (term && options.searchBackend === 'postgres_fulltext') applied.push('search');
1306
+ return { ...slice(rows, params), applied_filters: applied } as T;
1045
1307
  }
1046
1308
  throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
1047
1309
  },
@@ -35,11 +35,13 @@ export type {
35
35
  Citation,
36
36
  CorpusApi,
37
37
  CorpusAskApi,
38
+ CorpusFolder,
38
39
  CorpusLlm,
39
40
  CorpusPage,
40
41
  CorpusScope,
41
42
  DocumentQuery,
42
43
  DocumentSection,
44
+ FolderQuery,
43
45
  Focus,
44
46
  FocusCategory,
45
47
  FocusDraft,
@@ -74,10 +76,15 @@ export {
74
76
  toMoneyEvent,
75
77
  toParty,
76
78
  toPartyRef,
79
+ FOLDER_ROOT,
80
+ toFolder,
81
+ folderName,
77
82
  toSections,
78
83
  toSourceDocument,
79
84
  } from './entities';
80
- export type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
85
+ export type {
86
+ CorpusTypeKeys, TenantEntity, TenantFacetRow, TenantRelationship,
87
+ } from './entities';
81
88
 
82
89
  export {
83
90
  enforceScope,