@lde/search-typesense 0.27.4 → 0.29.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.
@@ -19,11 +19,14 @@ export type BlueGreenRebuildOptions = RebuildOptions;
19
19
  * creates the versioned collection (`${name}_<timestamp>`) with the schema
20
20
  * derived from the {@link SearchType}.
21
21
  * - `write` streams documents into the fresh collection, batched across write
22
- * calls, each stamped with its `source` (the dataset IRI).
22
+ * calls, each recording the dataset it came from ({@link openDocuments}).
23
23
  * - `flush` on a **failed** dataset, and `reset` (the pipeline’s dump-fallback
24
- * discard), roll that dataset’s documents back out of the not-yet-live
25
- * collection by `source`, so a swap never ships a half-processed dataset;
26
- * a successful flush leaves the streamed documents in place.
24
+ * discard), roll that dataset back out of the not-yet-live collection, so a
25
+ * swap never ships a half-processed dataset; a successful flush leaves the
26
+ * streamed documents in place. On a canonically keyed collection the
27
+ * rollback drops the failed dataset’s *membership*, so a document another
28
+ * dataset also contributed stays – with a reference from that dataset that
29
+ * still resolves.
27
30
  * - `commit` imports the remainder, atomically repoints the `name` alias to
28
31
  * the new collection, drops the collection it superseded, and releases the
29
32
  * lock. Until commit, the live alias never points at a partial build.
@@ -50,10 +53,6 @@ export declare class BlueGreenRebuild<TDocument extends {
50
53
  */
51
54
  readonly collectionName: string;
52
55
  private readonly resolved;
53
- /** The column this collection carries its documents’ dataset IRI in: the
54
- * type’s declared dataset field, or the private `source` when it declares
55
- * none. Both the stamp and the per-dataset rollback filter read it. */
56
- private readonly sourceField;
57
56
  constructor(client: Client, searchType: SearchType, options?: BlueGreenRebuildOptions);
58
57
  openRun(context: RunContext): Promise<RunWriter<TDocument>>;
59
58
  /** The collection an alias currently points at, or `undefined` if unset. */
@@ -1 +1 @@
1
- {"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAUvB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAiB1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAjB7B;;;;;;;OAOG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD;;4EAEwE;IACxE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;gBAGlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,uBAA4B;IAajC,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAwHjE,4EAA4E;YAC9D,WAAW;CAW1B"}
1
+ {"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AASvB,OAAO,EAEL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAa1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAb7B;;;;;;;OAOG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBAG/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,uBAA4B;IAUjC,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAsGjE,4EAA4E;YAC9D,WAAW;CAW1B"}
@@ -1,8 +1,7 @@
1
1
  import { buildCollectionDefinition } from './collection-definition.js';
2
- import { BatchImporter } from './import.js';
3
2
  import { httpStatus, openLockedRun, releaseLock } from './lock.js';
4
- import { SOURCE_FIELD, provenanceField, sourceDocumentsFilter, } from './sweep.js';
5
- import { assertNoReservedFields, assertSweepableProvenanceField, deleteByFilter, resolveRebuildOptions, stampDocuments, } from './rebuild-support.js';
3
+ import { assertWritableType, openDocuments, withBookkeeping, } from './documents.js';
4
+ import { resolveRebuildOptions, } from './rebuild-support.js';
6
5
  /**
7
6
  * Blue/green Rebuild (build a fresh index alongside the live one, then swap to
8
7
  * it atomically) as a transactional `Writer`: each run builds the search
@@ -16,11 +15,14 @@ import { assertNoReservedFields, assertSweepableProvenanceField, deleteByFilter,
16
15
  * creates the versioned collection (`${name}_<timestamp>`) with the schema
17
16
  * derived from the {@link SearchType}.
18
17
  * - `write` streams documents into the fresh collection, batched across write
19
- * calls, each stamped with its `source` (the dataset IRI).
18
+ * calls, each recording the dataset it came from ({@link openDocuments}).
20
19
  * - `flush` on a **failed** dataset, and `reset` (the pipeline’s dump-fallback
21
- * discard), roll that dataset’s documents back out of the not-yet-live
22
- * collection by `source`, so a swap never ships a half-processed dataset;
23
- * a successful flush leaves the streamed documents in place.
20
+ * discard), roll that dataset back out of the not-yet-live collection, so a
21
+ * swap never ships a half-processed dataset; a successful flush leaves the
22
+ * streamed documents in place. On a canonically keyed collection the
23
+ * rollback drops the failed dataset’s *membership*, so a document another
24
+ * dataset also contributed stays – with a reference from that dataset that
25
+ * still resolves.
24
26
  * - `commit` imports the remainder, atomically repoints the `name` alias to
25
27
  * the new collection, drops the collection it superseded, and releases the
26
28
  * lock. Until commit, the live alias never points at a partial build.
@@ -45,19 +47,12 @@ export class BlueGreenRebuild {
45
47
  */
46
48
  collectionName;
47
49
  resolved;
48
- /** The column this collection carries its documents’ dataset IRI in: the
49
- * type’s declared dataset field, or the private `source` when it declares
50
- * none. Both the stamp and the per-dataset rollback filter read it. */
51
- sourceField;
52
50
  constructor(client, searchType, options = {}) {
53
51
  this.client = client;
54
52
  this.searchType = searchType;
55
- // `source` is stamped on every document for per-dataset rollback.
56
- assertNoReservedFields(searchType, [SOURCE_FIELD]);
57
- // Rollback filters by a known dataset IRI rather than enumerating the
58
- // indexed ones, so a declared field need not be facetable here.
59
- assertSweepableProvenanceField(searchType, { requireFacetable: false });
60
- this.sourceField = provenanceField(searchType);
53
+ // Rollback drops a known dataset rather than enumerating the indexed ones,
54
+ // so a declared dataset field need not be facetable here.
55
+ assertWritableType(searchType, { requireFacetable: false });
61
56
  this.resolved = resolveRebuildOptions(searchType, options);
62
57
  this.collectionName =
63
58
  this.resolved.definitionOptions.collectionNameFor(searchType);
@@ -65,7 +60,6 @@ export class BlueGreenRebuild {
65
60
  async openRun(context) {
66
61
  const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
67
62
  const name = this.collectionName;
68
- const sourceField = this.sourceField;
69
63
  return openLockedRun(this.client, name, lockTtlMs, async () => {
70
64
  // Create the fresh (blue) collection up front, so a failure surfaces
71
65
  // before any dataset is processed. startedAt orders the versioned names;
@@ -87,30 +81,22 @@ export class BlueGreenRebuild {
87
81
  ...definitionOptions,
88
82
  collectionNameFor: versioned,
89
83
  });
90
- await this.client.collections().create({
91
- ...definition,
92
- // The private `source` only when the type declares no dataset field of
93
- // its own; a declared one is already in the definition and carries the
94
- // same IRI.
95
- fields: [
96
- ...(definition.fields ?? []),
97
- ...(sourceField === SOURCE_FIELD
98
- ? [{ name: SOURCE_FIELD, type: 'string' }]
99
- : []),
100
- ],
84
+ await this.client
85
+ .collections()
86
+ .create(withBookkeeping(definition, this.searchType));
87
+ const documents = openDocuments(this.client, collection, {
88
+ searchType: this.searchType,
89
+ runId: context.runId,
90
+ startedAt: context.startedAt,
91
+ batchSize,
101
92
  });
102
- const importer = new BatchImporter(this.client, collection, batchSize);
103
- // Drop a dataset’s streamed documents back out of the not-yet-live
104
- // collection (a failed dataset, or a reset before the dump re-run). Any
105
- // still buffered must land first, so the delete filter sees them.
106
- const rollback = async (dataset) => {
107
- await importer.flush();
108
- await deleteByFilter(this.client, collection, sourceDocumentsFilter(sourceField, dataset.iri.toString()));
109
- };
93
+ // Roll a dataset back out of the not-yet-live collection (a failed
94
+ // dataset, or a reset before the dump re-run). On a keyed collection
95
+ // that drops its membership rather than the documents themselves, so a
96
+ // dataset that succeeded keeps the ones it also contributed.
97
+ const rollback = async (dataset) => documents.dropAll(dataset);
110
98
  return {
111
- write: async (dataset, documents) => importer.add(stampDocuments(documents, {
112
- [sourceField]: dataset.iri.toString(),
113
- })),
99
+ write: async (dataset, items) => documents.add(dataset, items),
114
100
  flush: async (dataset, outcome) => {
115
101
  // A successful dataset keeps its streamed documents (they go live at
116
102
  // the swap); a failed one is rolled back so the swap never ships it.
@@ -120,7 +106,7 @@ export class BlueGreenRebuild {
120
106
  },
121
107
  reset: async (dataset) => rollback(dataset),
122
108
  commit: async () => {
123
- await importer.flush();
109
+ await documents.flush();
124
110
  // The alias swap is the commit point: once it lands the new
125
111
  // collection is live. Everything after it is best-effort cleanup that
126
112
  // must NOT fail the commit – a post-swap rejection would otherwise
@@ -19,11 +19,11 @@ export interface CollectionDefinitionOptions {
19
19
  */
20
20
  readonly collectionNameFor?: (searchType: SearchType) => string;
21
21
  /**
22
- * The Search Schema the type belongs to – required when the type surfaces an
23
- * inline reference (whose nested fields are declared from the
24
- * {@link ReferenceType} the schema resolves), declares a joinable
25
- * reference (whose target collection the schema’s {@link joinGraph}
26
- * resolves). A type doing neither needs no schema, so a caller passes none;
22
+ * The Search Schema the type belongs to – required when the type carries an
23
+ * inline reference (whose nested fields are declared from the reference type
24
+ * the schema resolves), or declares a joinable reference (whose target
25
+ * collection the schema’s {@link joinGraph} resolves).
26
+ * A type doing neither needs no schema, so a caller passes none;
27
27
  * a type that does fails here rather than building a collection that
28
28
  * silently omits the nesting or the reference.
29
29
  *
@@ -82,13 +82,18 @@ export interface CollectionDefinitionOptions {
82
82
  * entirely: they exist only as a projection-time reading device for the
83
83
  * derives, so the collection stores nothing for them.
84
84
  *
85
- * A surfaced (`output`) inline reference is stored as a **nested object** –
86
- * `object` or `object[]` – with one nested Physical Field per output field of
87
- * its {@link ReferenceType} ({@link nestedFieldName}), which turns on
88
- * `enable_nested_fields` for the collection. Nested fields carry `output` only
89
- * (enforced by `searchSchema`), so they are declared `index: false`: each
90
- * referent’s values stay grouped on disk and cost no RAM, exactly like the
91
- * display labels.
85
+ * An inline reference is stored as a **nested object** – `object` or
86
+ * `object[]` – with one nested Physical Field per field of the type it nests
87
+ * ({@link nestedFieldName}), which turns on `enable_nested_fields` for the
88
+ * collection. A nested field is `index: false` unless it declares a query Role,
89
+ * so a display-only nesting stays disk weight and costs no RAM, exactly like
90
+ * the display labels; one that opts into `filterable` or `searchable` is
91
+ * indexed, and its parent object with it (see {@link nestedFields}).
92
+ *
93
+ * An inline reference declaring `filterable` or `facetable` also emits its
94
+ * {@link ReferenceStrategy.identity identity companion} – a flat id field the
95
+ * engine can filter and facet, which a nested object is not
96
+ * ({@link identityCompanionFields}).
92
97
  */
93
98
  export declare function buildCollectionDefinition(searchType: SearchType, options?: CollectionDefinitionOptions): CollectionCreateSchema;
94
99
  //# sourceMappingURL=collection-definition.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAarB,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC;IAChE;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CA2BxB"}
1
+ {"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAcrB,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC;IAChE;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CA2BxB"}
@@ -1,4 +1,4 @@
1
- import { displayFieldPattern, ID_FIELD, isInlineReference, isInternalField, joinGraph, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
1
+ import { displayFieldPattern, ID_FIELD, isInlineReference, isInternalField, joinGraph, localLookupTypeOf, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
2
2
  import { deriveCollectionName } from './collection-name.js';
3
3
  /**
4
4
  * Build a Typesense collection definition from the unified {@link SearchType}, so
@@ -36,13 +36,18 @@ import { deriveCollectionName } from './collection-name.js';
36
36
  * entirely: they exist only as a projection-time reading device for the
37
37
  * derives, so the collection stores nothing for them.
38
38
  *
39
- * A surfaced (`output`) inline reference is stored as a **nested object** –
40
- * `object` or `object[]` – with one nested Physical Field per output field of
41
- * its {@link ReferenceType} ({@link nestedFieldName}), which turns on
42
- * `enable_nested_fields` for the collection. Nested fields carry `output` only
43
- * (enforced by `searchSchema`), so they are declared `index: false`: each
44
- * referent’s values stay grouped on disk and cost no RAM, exactly like the
45
- * display labels.
39
+ * An inline reference is stored as a **nested object** – `object` or
40
+ * `object[]` – with one nested Physical Field per field of the type it nests
41
+ * ({@link nestedFieldName}), which turns on `enable_nested_fields` for the
42
+ * collection. A nested field is `index: false` unless it declares a query Role,
43
+ * so a display-only nesting stays disk weight and costs no RAM, exactly like
44
+ * the display labels; one that opts into `filterable` or `searchable` is
45
+ * indexed, and its parent object with it (see {@link nestedFields}).
46
+ *
47
+ * An inline reference declaring `filterable` or `facetable` also emits its
48
+ * {@link ReferenceStrategy.identity identity companion} – a flat id field the
49
+ * engine can filter and facet, which a nested object is not
50
+ * ({@link identityCompanionFields}).
46
51
  */
47
52
  export function buildCollectionDefinition(searchType, options = {}) {
48
53
  const { defaultLocale, collectionNameFor = deriveCollectionName } = options;
@@ -64,16 +69,19 @@ export function buildCollectionDefinition(searchType, options = {}) {
64
69
  return collection;
65
70
  }
66
71
  /**
67
- * The {@link ReferenceType} a field nests, or `undefined` when it nests
68
- * nothing. Throws when the type surfaces an inline reference the caller gave no
69
- * schema to resolve: the collection would silently store the reference as a
70
- * string the projection never writes, so every document would fail to import.
72
+ * The type a field nests, or `undefined` when it nests nothing. Throws when the
73
+ * type carries an inline reference the caller gave no schema to resolve: the
74
+ * collection would silently store the reference as a string the projection
75
+ * never writes, so every document would fail to import.
71
76
  */
72
77
  function nestedTypeOf(searchType, field, schema) {
73
- const nested = schema === undefined ? undefined : nestedReferenceType(schema, field);
74
- // Reached only for a field carrying a Role, so an inline reference here is a
75
- // surfaced one: `searchSchema` allows an inline reference no Role but
76
- // `output`.
78
+ const nested = schema === undefined
79
+ ? undefined
80
+ : (nestedReferenceType(schema, field) ??
81
+ localLookupTypeOf(field, schema));
82
+ // Reached only for a field carrying a Role, so an inline reference here
83
+ // stores entries: a Role-less one is an internal reading device, pruned
84
+ // before the writer.
77
85
  if (nested === undefined && isInlineReference(field)) {
78
86
  throw new Error(`Building the collection for “${searchType.name}” needs the search schema its surfaced inline reference “${field.name}” resolves against; pass it as the collection-definition option “schema”.`);
79
87
  }
@@ -136,7 +144,10 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
136
144
  }
137
145
  const nested = nestedTypeOf(searchType, field, schema);
138
146
  if (nested !== undefined) {
139
- return nestedFields(field.name, field, nested, schema);
147
+ return [
148
+ ...nestedFields(field.name, field, nested, schema, defaultLocale),
149
+ ...identityCompanionFields(field, schema),
150
+ ];
140
151
  }
141
152
  const names = physicalFields(field, schema);
142
153
  if (field.kind === 'text') {
@@ -223,53 +234,217 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
223
234
  return fields;
224
235
  }
225
236
  /**
226
- * The nested object one surfaced inline reference stores, plus one nested
227
- * Physical Field per output field of its {@link ReferenceType} – recursing for
228
- * a reference type that itself surfaces an inline reference, to the depth the
229
- * schema declares (`searchSchema` rejects inline cycles, so the recursion
230
- * terminates).
237
+ * The nested object one inline reference stores, plus one nested Physical Field
238
+ * per field of the type it nests – recursing for a nested field that itself
239
+ * nests (another inline reference, or a
240
+ * {@link ReferenceStrategy.local local} lookup, which stores its endpoint
241
+ * shaped by the target’s own declaration). `searchSchema` rejects inline
242
+ * cycles, so the recursion terminates.
231
243
  *
232
- * Everything nested is `index: false`: a nested field carries `output` only, so
233
- * it is stored with its referent and read back with it, never searched, faceted
234
- * or sorted on. That keeps the RAM lever intact – a nested document is display
235
- * weight, like the per-language labels – and lets the declared child types be
236
- * exactly what the projection writes without an indexing contract to satisfy.
237
- * Declaring the children rather than only the object is what makes the nesting
238
- * legible in the collection, and what keeps an {@link isInternalField Internal
239
- * Field} inside a Reference Type visibly contributing nothing.
244
+ * **A nested field is indexed exactly when it declares a query Role.** One that
245
+ * declares only `output` keeps `index: false`: stored with its referent, read
246
+ * back with it, costing disk rather than RAM – the same lever the per-language
247
+ * display labels use. `filterable` or `searchable` turns indexing on for that
248
+ * one field, and nothing else.
249
+ *
250
+ * **The parent object must be indexed whenever any descendant is.** Typesense
251
+ * silently ignores an indexed child under an `index: false` parent: no error,
252
+ * no match, every query answering empty. So the parent’s flag is computed from
253
+ * the children rather than declared, and this is the one place that invariant
254
+ * can be got wrong.
240
255
  */
241
- function nestedFields(prefix, reference, referenceType, schema) {
256
+ function nestedFields(prefix, reference, nestedType, schema, defaultLocale, withinArray = false, onPath = new Set()) {
242
257
  const array = reference.array === true;
243
- const fields = [
244
- {
245
- name: prefix,
246
- type: array ? 'object[]' : 'object',
258
+ // A type already on this path is not descended into again. `searchSchema`
259
+ // rejects INLINE cycles, but a {@link ReferenceStrategy.local local} lookup
260
+ // may reach a Root Type that reaches back – `Work → Person → Work` is a
261
+ // reasonable schema and nothing forbids it – and without this the builder
262
+ // recurses until the stack gives out. Stopping here matches where the FRAME
263
+ // stops ({@link inlineFramingDepth}), so the collection declares exactly the
264
+ // depth the projection can fill.
265
+ const walked = new Set(onPath).add(nestedType.name);
266
+ // Typesense flattens a nested object, so a value under a multi-valued
267
+ // ancestor arrives as an array however single-valued the declaration is.
268
+ // That only matters for an INDEXED field, whose declared type is checked
269
+ // against what is stored; an unindexed one is stored as-is.
270
+ const flattensToArray = withinArray || array;
271
+ const children = [];
272
+ // A `local` lookup nests its endpoint’s own document, which carries the `id`
273
+ // that identifies it. The target type does not declare `id` – no type
274
+ // declares its own key – so it is added here or it would be the one stored
275
+ // value the collection never mentions.
276
+ if (nestedType.class !== undefined) {
277
+ children.push({
278
+ name: nestedFieldName(prefix, 'id'),
279
+ type: flattensToArray ? 'string[]' : 'string',
247
280
  index: false,
248
- optional: reference.required !== true,
249
- },
250
- ];
251
- for (const field of referenceType.fields) {
281
+ optional: true,
282
+ });
283
+ }
284
+ for (const field of nestedType.fields) {
252
285
  if (isInternalField(field)) {
253
286
  continue;
254
287
  }
255
- const nested = nestedReferenceType(schema, field);
256
- if (nested !== undefined) {
257
- fields.push(...nestedFields(nestedFieldName(prefix, field.name), field, nested, schema));
288
+ const deeper = nestedTypeOfNestedField(field, schema);
289
+ if (deeper !== undefined && walked.has(deeper.name)) {
290
+ // The cycle stops here, and so does the frame – nothing will ever be
291
+ // stored under this field, so nothing is declared for it. Falling
292
+ // through would declare it as a LEAF, whose scalar type is not what a
293
+ // nested object would be stored as.
258
294
  continue;
259
295
  }
260
- // A nested text field stores its display values exactly as a root one does
261
- // – one `${name}_<lang>` per present language, matched by one pattern
262
- // field – only under the referent’s prefix. It has no search or sort
263
- // companions: those are Roles a nested field cannot declare.
264
- const pattern = field.kind === 'text' ? displayFieldPattern(field) : undefined;
296
+ if (deeper !== undefined) {
297
+ children.push(...nestedFields(nestedFieldName(prefix, field.name), field, deeper, schema, defaultLocale, flattensToArray, walked),
298
+ // A nested field that stores an object cannot be filtered as itself,
299
+ // and its own id is a level deeper than a condition can be welded to –
300
+ // an engine welds conditions on an entry's LEAF fields only. Its
301
+ // identity companion is that leaf, so it sits beside the object rather
302
+ // than inside it.
303
+ ...nestedIdentityFields(prefix, field, schema));
304
+ continue;
305
+ }
306
+ children.push(...nestedLeafFields(prefix, field, defaultLocale, flattensToArray));
307
+ }
308
+ return [
309
+ {
310
+ name: prefix,
311
+ // Flattening applies to an object exactly as it does to a value: a
312
+ // single-valued nesting inside a multi-valued one arrives as a list of
313
+ // objects, so it is only declared `object` where nothing above it is an
314
+ // `object[]`.
315
+ type: flattensToArray ? 'object[]' : 'object',
316
+ // Never hard-coded: see the parent-object rule above.
317
+ index: children.some((child) => child.index !== false),
318
+ optional: reference.required !== true,
319
+ },
320
+ ...children,
321
+ ];
322
+ }
323
+ /**
324
+ * The **identity companion** of an inline reference: the flat field holding the
325
+ * ids its entries reference, which the engine filters and facets in place of
326
+ * the nested object it cannot. Empty for an inline reference declaring no
327
+ * `identity` – a display-only nesting keeps costing no RAM.
328
+ *
329
+ * A second, narrowed field joins it where the target declares a facet policy,
330
+ * exactly as it does for a top-level facetable reference: the facet then reads
331
+ * the admitted subset, so an excluded id is never seen by the engine rather
332
+ * than merely unlabelled.
333
+ */
334
+ function identityCompanionFields(field, schema) {
335
+ const names = physicalFields(field, schema);
336
+ if (names.identity === undefined) {
337
+ return [];
338
+ }
339
+ const fields = [
340
+ {
341
+ name: names.identity,
342
+ type: 'string[]',
343
+ facet: names.facet === names.identity,
344
+ optional: true,
345
+ },
346
+ ];
347
+ if (names.facet !== undefined && names.facet !== names.identity) {
348
+ fields.push({
349
+ name: names.facet,
350
+ type: 'string[]',
351
+ facet: true,
352
+ optional: true,
353
+ });
354
+ }
355
+ return fields;
356
+ }
357
+ /** The identity companion of a nested field that stores an object: a flat leaf
358
+ * beside it, holding the ids a filter welds on. Empty for a nested field that
359
+ * declares no query Role, which needs none. */
360
+ function nestedIdentityFields(prefix, field, schema) {
361
+ const names = physicalFields(field, schema);
362
+ if (names.identity === undefined) {
363
+ return [];
364
+ }
365
+ return [
366
+ {
367
+ name: nestedFieldName(prefix, names.identity),
368
+ // Always a list, whatever the enclosing reference's arity: the projection
369
+ // writes it with `setArray`, and an indexed field's declared type is
370
+ // checked against what is stored – a `string` here rejects every
371
+ // document carrying such an edge, at import.
372
+ type: 'string[]',
373
+ index: true,
374
+ optional: true,
375
+ },
376
+ ];
377
+ }
378
+ /** The type a *nested* field itself nests: another inline reference’s type, or
379
+ * the Root Type a {@link ReferenceStrategy.local local} lookup projects its
380
+ * endpoint through. */
381
+ function nestedTypeOfNestedField(field, schema) {
382
+ return nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
383
+ }
384
+ /**
385
+ * The Physical Fields one non-nesting nested field contributes: exactly what
386
+ * the projection writes under the referent’s prefix, so the collection and the
387
+ * document cannot disagree.
388
+ *
389
+ * A nested `text` field stores its display values as a root one does – one
390
+ * `${name}_<lang>` per present language, matched by a single pattern field,
391
+ * never indexed – plus a folded search companion per locale when it is
392
+ * `searchable`. Anything undeclared here would still arrive in the document,
393
+ * and Typesense indexes what it is not told to leave alone, so the fanout is
394
+ * enumerated rather than left to inference.
395
+ */
396
+ function nestedLeafFields(prefix, field, defaultLocale, flattensToArray) {
397
+ const names = physicalFields(field);
398
+ const searchString = flattensToArray ? 'string[]' : 'string';
399
+ const fields = [];
400
+ if (field.kind === 'text') {
401
+ const pattern = displayFieldPattern(field);
402
+ if (pattern !== undefined) {
403
+ // Display values are never indexed, whatever Roles the field declares:
404
+ // search hits the folded companions below, so the display copies stay on
405
+ // disk with every language they carry.
406
+ fields.push({
407
+ name: nestedFieldName(prefix, pattern),
408
+ type: 'string',
409
+ index: false,
410
+ optional: true,
411
+ });
412
+ }
413
+ fields.push(...names.search.map((name, index) => {
414
+ const locale = field.locales[index] === 'und' ? defaultLocale : field.locales[index];
415
+ return {
416
+ name: nestedFieldName(prefix, name),
417
+ type: searchString,
418
+ optional: true,
419
+ ...(locale !== undefined && { stem: true, locale }),
420
+ };
421
+ }));
422
+ return fields;
423
+ }
424
+ // A nested field can be `filterable` or `searchable`, never `facetable` or
425
+ // `sortable` (`searchSchema`), so `filterable` is what indexes the value
426
+ // itself – its search companion below indexes on its own account.
427
+ const indexed = field.filterable === true;
428
+ const valueType = typesenseValueType(field);
429
+ // Two ways a list arrives: the field declares one, or an ancestor's
430
+ // `object[]` flattens it into one. `typesenseValueType` honours `array` only
431
+ // for the string-shaped kinds, so a multi-valued nested `integer` would
432
+ // otherwise be declared scalar and rejected at import.
433
+ const storesAList = flattensToArray || field.array === true;
434
+ fields.push({
435
+ name: nestedFieldName(prefix, field.name),
436
+ type: indexed && storesAList ? arrayValueType(valueType) : valueType,
437
+ index: indexed,
438
+ // Always optional: `required` is a promise about the *referent* (this value
439
+ // is on every referent), while Typesense’s flag is about the document.
440
+ // Under a multi-valued reference those are different claims, and only the
441
+ // referent-level one is the declaration’s.
442
+ optional: true,
443
+ });
444
+ for (const name of names.search) {
265
445
  fields.push({
266
- name: nestedFieldName(prefix, pattern ?? field.name),
267
- type: field.kind === 'text' ? 'string' : typesenseValueType(field),
268
- index: false,
269
- // Always optional: `required` is a promise about the *referent* (this
270
- // value is on every referent), while Typesense’s flag is about the
271
- // document. Under a multi-valued reference those are different claims,
272
- // and only the referent-level one is the declaration’s.
446
+ name: nestedFieldName(prefix, name),
447
+ type: searchString,
273
448
  optional: true,
274
449
  });
275
450
  }
@@ -277,6 +452,30 @@ function nestedFields(prefix, reference, referenceType, schema) {
277
452
  }
278
453
  /** The Typesense field type for a non-localized field, from its `kind`. 64-bit
279
454
  * integers (and dates, stored as Unix seconds) so large counts never overflow. */
455
+ /**
456
+ * The array form of a value type, for a nested field that an ancestor’s
457
+ * `object[]` flattens into a list. Only an **indexed** field needs it: Typesense
458
+ * checks a declared type against what is stored, and a value under a
459
+ * multi-valued ancestor arrives as an array however single-valued its own
460
+ * declaration is. Enumerated rather than string-appended so an invalid
461
+ * combination cannot be built.
462
+ */
463
+ function arrayValueType(type) {
464
+ switch (type) {
465
+ case 'string':
466
+ return 'string[]';
467
+ case 'int64':
468
+ return 'int64[]';
469
+ case 'float':
470
+ return 'float[]';
471
+ case 'bool':
472
+ return 'bool[]';
473
+ // Already a list: a multi-valued declaration under a multi-valued ancestor
474
+ // flattens no further.
475
+ case 'string[]':
476
+ return 'string[]';
477
+ }
478
+ }
280
479
  function typesenseValueType(field) {
281
480
  switch (field.kind) {
282
481
  case 'integer':