@byline/client 6.7.0 → 6.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -49,8 +49,10 @@ export declare class BylineClient<TCollections extends CollectionRegistry = Regi
49
49
  * actor cannot `read` are excluded; `beforeRead` row scoping applies per
50
50
  * collection; `hydrate: true` attaches a shaped `ClientDocument` per hit.
51
51
  * A public search (the default `status: 'published'`) also re-checks each
52
- * hit's locale against the advertised-locale policy, and reports a
53
- * page-local `total` with no facets, as `CollectionHandle.search()` does.
52
+ * hit's locale against the advertised-locale policy. The provider `total`
53
+ * and facets pass through unless an actor-specific restriction applies
54
+ * (unreadable members or `beforeRead` scoping), as `CollectionHandle.search()`
55
+ * does.
54
56
  * For homogeneous, single-collection search use
55
57
  * `client.collection(path).search()`.
56
58
  */
package/dist/client.js CHANGED
@@ -123,8 +123,10 @@ export class BylineClient {
123
123
  * actor cannot `read` are excluded; `beforeRead` row scoping applies per
124
124
  * collection; `hydrate: true` attaches a shaped `ClientDocument` per hit.
125
125
  * A public search (the default `status: 'published'`) also re-checks each
126
- * hit's locale against the advertised-locale policy, and reports a
127
- * page-local `total` with no facets, as `CollectionHandle.search()` does.
126
+ * hit's locale against the advertised-locale policy. The provider `total`
127
+ * and facets pass through unless an actor-specific restriction applies
128
+ * (unreadable members or `beforeRead` scoping), as `CollectionHandle.search()`
129
+ * does.
128
130
  * For homogeneous, single-collection search use
129
131
  * `client.collection(path).search()`.
130
132
  */
@@ -59,11 +59,13 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
59
59
  * hits into shaped `ClientDocument`s in their own locale (no source
60
60
  * substitution) and attaches them as `hit.document`. Consequences:
61
61
  *
62
- * - for every public search, and under row scoping, `total` is the
63
- * surviving hit count for this page and facets are omitted rather than
64
- * leaking provider-wide aggregates;
65
- * - only an editorial search (`status: 'any'`) with no `beforeRead` hook
66
- * passes the provider `total` and facets through unchanged;
62
+ * - under `beforeRead` row scoping, `total` is the surviving hit count
63
+ * for this page and facets are omitted rather than leaking
64
+ * provider-wide aggregates of rows this actor can't see;
65
+ * - otherwise — including every unscoped public search — the provider
66
+ * `total` and facets pass through unchanged (public locale eligibility
67
+ * is actor-independent and applied at indexing, so a hit the re-check
68
+ * drops is a stale index entry, logged at warn level);
67
69
  * - a page of hits can come back shorter than `limit` when candidates
68
70
  * are dropped; paginate on `offset`, not on received length.
69
71
  *
@@ -180,11 +180,13 @@ export class CollectionHandle {
180
180
  * hits into shaped `ClientDocument`s in their own locale (no source
181
181
  * substitution) and attaches them as `hit.document`. Consequences:
182
182
  *
183
- * - for every public search, and under row scoping, `total` is the
184
- * surviving hit count for this page and facets are omitted rather than
185
- * leaking provider-wide aggregates;
186
- * - only an editorial search (`status: 'any'`) with no `beforeRead` hook
187
- * passes the provider `total` and facets through unchanged;
183
+ * - under `beforeRead` row scoping, `total` is the surviving hit count
184
+ * for this page and facets are omitted rather than leaking
185
+ * provider-wide aggregates of rows this actor can't see;
186
+ * - otherwise — including every unscoped public search — the provider
187
+ * `total` and facets pass through unchanged (public locale eligibility
188
+ * is actor-independent and applied at indexing, so a hit the re-check
189
+ * drops is a stale index entry, logged at warn level);
188
190
  * - a page of hits can come back shorter than `limit` when candidates
189
191
  * are dropped; paginate on `offset`, not on received length.
190
192
  *
@@ -222,10 +224,12 @@ export class CollectionHandle {
222
224
  });
223
225
  // Row-level authorization, exact-locale eligibility (+ optional
224
226
  // hydration) — the shared finishing pipeline the zone entry point uses
225
- // too. Public searches always re-check every hit and always follow the
226
- // restricted-result convention; only an editorial search without a
227
- // beforeRead predicate or hydration passes hits through untouched.
228
- const { hits, eligibilityRestricted } = await finalizeSearchHits({
227
+ // too. Public searches re-check every hit; an editorial search without a
228
+ // beforeRead predicate or hydration passes hits through untouched. Only
229
+ // actor-specific restriction (beforeRead) replaces the provider
230
+ // aggregate — public locale eligibility is actor-independent and already
231
+ // applied at indexing, so provider total and facets pass through.
232
+ const hits = await finalizeSearchHits({
229
233
  client: this.client,
230
234
  requestContext,
231
235
  hits: results.hits,
@@ -236,7 +240,7 @@ export class CollectionHandle {
236
240
  localeVisibility: resolveLocaleVisibility(readMode),
237
241
  readContext: readCtx,
238
242
  });
239
- return aggregateRestricted || eligibilityRestricted
243
+ return aggregateRestricted
240
244
  ? { hits, total: hits.length }
241
245
  : { hits, total: results.total, facets: results.facets };
242
246
  }
package/dist/search.d.ts CHANGED
@@ -17,6 +17,15 @@
17
17
  * into shaped `ClientDocument`s in the same query. Under `'public'` locale
18
18
  * visibility every hit is also re-checked for exact-locale eligibility, so a
19
19
  * stale index entry for a withheld translation is dropped whole.
20
+ *
21
+ * Aggregates: the provider's `total` and facets pass through unless an
22
+ * *actor-specific* restriction applies (a `beforeRead` predicate, or zone
23
+ * members the actor cannot read) — then the restricted-result convention
24
+ * (retained-hit `total`, no facets) prevents leaking counts of rows this
25
+ * reader can't see. Public locale eligibility is actor-independent and is
26
+ * the same rule indexing applies, so the provider aggregate already is the
27
+ * public aggregate; a stale entry the re-check drops is an index-maintenance
28
+ * lag, logged at warn level, not a reason to degrade the response.
20
29
  */
21
30
  import type { RequestContext } from '@byline/auth';
22
31
  import type { LocaleVisibility, ReadContext, ReadMode, SearchHit, SearchProvider } from '@byline/core';
@@ -40,21 +49,6 @@ export interface FinalizeSearchHitsParams {
40
49
  /** Shared per-request read context (beforeRead cache). Created when omitted. */
41
50
  readContext?: ReadContext;
42
51
  }
43
- /** Finished hits, plus whether a post-ranking eligibility policy applied. */
44
- export interface FinalizedSearchHits {
45
- hits: HydratedSearchHit[];
46
- /**
47
- * `true` whenever the public exact-locale eligibility policy applies — that
48
- * is, for every `'public'` search, including an empty page. The provider's
49
- * `total` and facets count its whole index, which can hold slices the
50
- * policy would reject on other pages, so a page-only check cannot certify
51
- * them. The caller follows the restricted-result convention (retained-hit
52
- * `total`, no provider facets), exactly as for `beforeRead` row scoping,
53
- * which also restricts on the presence of the policy, not on an observed
54
- * removal.
55
- */
56
- eligibilityRestricted: boolean;
57
- }
58
52
  /**
59
53
  * Authorise (and optionally hydrate) provider hits, per collection:
60
54
  *
@@ -82,7 +76,7 @@ export interface FinalizedSearchHits {
82
76
  * dropped with a debug log (an index can outlive a collection). Original
83
77
  * ranking order is preserved.
84
78
  */
85
- export declare function finalizeSearchHits(params: FinalizeSearchHitsParams): Promise<FinalizedSearchHits>;
79
+ export declare function finalizeSearchHits(params: FinalizeSearchHitsParams): Promise<HydratedSearchHit[]>;
86
80
  /**
87
81
  * Cross-collection search over a named zone. Membership is resolved from
88
82
  * the runtime collection definitions (`search.zones`, defaulting to the
package/dist/search.js CHANGED
@@ -50,9 +50,8 @@ export function assertSearchFieldScope(provider, fieldScope) {
50
50
  */
51
51
  export async function finalizeSearchHits(params) {
52
52
  const { client, requestContext, hits, locale, status, hydrate, bypassBeforeRead, localeVisibility, } = params;
53
- const eligibilityRestricted = localeVisibility === 'public';
54
53
  if (hits.length === 0)
55
- return { hits, eligibilityRestricted };
54
+ return hits;
56
55
  const readCtx = params.readContext ?? createReadContext();
57
56
  // Group hit ids by collection, preserving overall ranking order for the
58
57
  // final reassembly.
@@ -97,6 +96,7 @@ export async function finalizeSearchHits(params) {
97
96
  ...(bypassBeforeRead ? { _bypassBeforeRead: true } : {}),
98
97
  });
99
98
  documentsByCollection.set(collectionPath, new Map(result.docs.map((d) => [d.id, d])));
99
+ await warnOnStaleHits(params, readCtx, collectionPath, group, new Set(result.docs.map((d) => d.id)));
100
100
  continue;
101
101
  }
102
102
  if (localeVisibility === 'public') {
@@ -114,7 +114,9 @@ export async function finalizeSearchHits(params) {
114
114
  _readContext: readCtx,
115
115
  ...(bypassBeforeRead ? { _bypassBeforeRead: true } : {}),
116
116
  });
117
- allowedByCollection.set(collectionPath, new Set(result.docs.map((d) => d.id)));
117
+ const allowed = new Set(result.docs.map((d) => d.id));
118
+ allowedByCollection.set(collectionPath, allowed);
119
+ await warnOnStaleHits(params, readCtx, collectionPath, group, allowed);
118
120
  continue;
119
121
  }
120
122
  if (bypassBeforeRead) {
@@ -158,7 +160,31 @@ export async function finalizeSearchHits(params) {
158
160
  finished.push(hit);
159
161
  }
160
162
  }
161
- return { hits: finished, eligibilityRestricted };
163
+ return finished;
164
+ }
165
+ /**
166
+ * Warn about hits the re-check dropped for a reason other than `beforeRead`
167
+ * scoping — a stale index entry (deleted document, withdrawn translation,
168
+ * unpublished version) that index maintenance hasn't caught up with yet.
169
+ * Drops under an active `beforeRead` predicate are expected and not logged.
170
+ */
171
+ async function warnOnStaleHits(params, readCtx, collectionPath, group, resolved) {
172
+ const dropped = group.filter((h) => !resolved.has(h.documentId));
173
+ if (dropped.length === 0)
174
+ return;
175
+ const { client, requestContext, bypassBeforeRead } = params;
176
+ if (!bypassBeforeRead) {
177
+ const definition = client.collections.find((c) => c.path === collectionPath);
178
+ const filters = definition == null
179
+ ? null
180
+ : await resolveReadSecurityFilters(client, definition, requestContext, readCtx);
181
+ if (filters != null)
182
+ return;
183
+ }
184
+ client.logger.warn({
185
+ collectionPath,
186
+ dropped: dropped.map((h) => ({ documentId: h.documentId, locale: h.locale })),
187
+ }, 'search: dropped index entries that no longer resolve for this read; reindex the affected documents');
162
188
  }
163
189
  // ---------------------------------------------------------------------------
164
190
  // Zone (cross-collection) entry point
@@ -234,7 +260,7 @@ export async function zoneSearch(client, options) {
234
260
  });
235
261
  // Constrain to readable member collections, then authorise / hydrate.
236
262
  const scoped = results.hits.filter((h) => readable.has(h.collectionPath));
237
- const { hits, eligibilityRestricted } = await finalizeSearchHits({
263
+ const hits = await finalizeSearchHits({
238
264
  client,
239
265
  requestContext,
240
266
  hits: scoped,
@@ -245,7 +271,7 @@ export async function zoneSearch(client, options) {
245
271
  localeVisibility: resolveLocaleVisibility(readMode),
246
272
  readContext: readCtx,
247
273
  });
248
- return aggregateRestricted || eligibilityRestricted
274
+ return aggregateRestricted
249
275
  ? { hits, total: hits.length }
250
276
  : { hits, total: results.total, facets: results.facets };
251
277
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/client",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "6.7.0",
5
+ "version": "6.7.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -44,8 +44,8 @@
44
44
  ],
45
45
  "dependencies": {
46
46
  "uuid": "^14.0.2",
47
- "@byline/auth": "6.7.0",
48
- "@byline/core": "6.7.0"
47
+ "@byline/auth": "6.7.1",
48
+ "@byline/core": "6.7.1"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "2.5.14",
@@ -57,8 +57,8 @@
57
57
  "tsc-alias": "^1.9.5",
58
58
  "typescript": "^7.0.2",
59
59
  "vitest": "^5.0.1",
60
- "@byline/search-postgres": "6.7.0",
61
- "@byline/db-postgres": "6.7.0"
60
+ "@byline/db-postgres": "6.7.1",
61
+ "@byline/search-postgres": "6.7.1"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public",