@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 +4 -2
- package/dist/client.js +4 -2
- package/dist/collection-handle.d.ts +7 -5
- package/dist/collection-handle.js +14 -10
- package/dist/search.d.ts +10 -16
- package/dist/search.js +32 -6
- package/package.json +5 -5
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
|
|
53
|
-
*
|
|
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
|
|
127
|
-
*
|
|
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
|
-
* -
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* -
|
|
66
|
-
*
|
|
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
|
-
* -
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* -
|
|
187
|
-
*
|
|
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
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
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
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
48
|
-
"@byline/core": "6.7.
|
|
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/
|
|
61
|
-
"@byline/
|
|
60
|
+
"@byline/db-postgres": "6.7.1",
|
|
61
|
+
"@byline/search-postgres": "6.7.1"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public",
|