@byline/client 4.16.0 → 4.18.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.
package/dist/client.d.ts CHANGED
@@ -8,14 +8,15 @@
8
8
  import { type RequestContext } from '@byline/auth';
9
9
  import type { BylineLogger, CollectionDefinition, CollectionRegistry, IDbAdapter, IStorageProvider, RichTextPopulateFn, RichTextToTextFn, SearchProvider, SlugifierFn } from '@byline/core';
10
10
  import { CollectionHandle } from './collection-handle.js';
11
- import type { RegisteredCollections } from './register.js';
11
+ import { SingletonHandle } from './singleton-handle.js';
12
+ import type { RegisteredCollections, RegisteredSingletons } from './register.js';
12
13
  import type { BylineClientConfig, ClientSearchResults, ZoneSearchOptions } from './types.js';
13
14
  /**
14
15
  * The main Byline client instance. Holds the database adapter, collection
15
16
  * definitions, and optional storage provider. Use `collection(path)` to get
16
17
  * a scoped handle for querying and mutating documents.
17
18
  */
18
- export declare class BylineClient<TRegistry extends CollectionRegistry = RegisteredCollections> {
19
+ export declare class BylineClient<TCollections extends CollectionRegistry = RegisteredCollections, TSingletons extends CollectionRegistry = RegisteredSingletons> {
19
20
  readonly db: IDbAdapter;
20
21
  readonly collections: readonly CollectionDefinition[];
21
22
  readonly storage: IStorageProvider | undefined;
@@ -55,7 +56,9 @@ export declare class BylineClient<TRegistry extends CollectionRegistry = Registe
55
56
  * Get a handle scoped to a single collection. All subsequent read/write
56
57
  * operations on the handle are performed against this collection.
57
58
  */
58
- collection<TPath extends keyof TRegistry & string>(path: TPath): CollectionHandle<TRegistry[TPath]>;
59
+ collection<TPath extends keyof TCollections & string>(path: TPath): CollectionHandle<TCollections[TPath]>;
60
+ /** Get the deliberately narrow, document-ID-free handle for a singleton slot. */
61
+ singleton<TPath extends keyof TSingletons & string>(path: TPath): SingletonHandle<TSingletons[TPath]>;
59
62
  /**
60
63
  * Resolve a collection path to its database row id and current schema
61
64
  * version. Cached for the lifetime of this client instance. The version
@@ -72,8 +75,8 @@ export declare class BylineClient<TRegistry extends CollectionRegistry = Registe
72
75
  resolveCollectionId(path: string): Promise<string>;
73
76
  }
74
77
  /**
75
- * Create a new Byline client instance. The registry generic defaults to
76
- * `RegisteredCollections`, so in an app whose generated types augment
77
- * `Register` the returned client is fully typed with no explicit generic.
78
+ * Create a new Byline client instance. Both registry generics default through
79
+ * `Register`, so an app's generated collection and singleton types make the
80
+ * returned client fully typed with no explicit generic.
78
81
  */
79
- export declare function createBylineClient<TRegistry extends CollectionRegistry = RegisteredCollections>(config: BylineClientConfig): BylineClient<TRegistry>;
82
+ export declare function createBylineClient<TCollections extends CollectionRegistry = RegisteredCollections, TSingletons extends CollectionRegistry = RegisteredSingletons>(config: BylineClientConfig): BylineClient<TCollections, TSingletons>;
package/dist/client.js CHANGED
@@ -6,9 +6,10 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { ERR_UNAUTHENTICATED } from '@byline/auth';
9
- import { ERR_NOT_FOUND, getLogger } from '@byline/core';
9
+ import { ERR_NOT_FOUND, ERR_VALIDATION, getLogger, isSingleton } from '@byline/core';
10
10
  import { CollectionHandle } from './collection-handle.js';
11
11
  import { zoneSearch } from './search.js';
12
+ import { SingletonHandle } from './singleton-handle.js';
12
13
  /**
13
14
  * Resolve a logger for the client in priority order:
14
15
  * 1. explicit `config.logger`
@@ -138,12 +139,40 @@ export class BylineClient {
138
139
  message: `Collection not found: '${path}'`,
139
140
  details: {
140
141
  collectionPath: path,
141
- available: this.collections.map((c) => c.path),
142
+ available: this.collections
143
+ .filter((definition) => !isSingleton(definition))
144
+ .map((c) => c.path),
142
145
  },
143
146
  });
144
147
  }
148
+ if (isSingleton(definition)) {
149
+ throw ERR_VALIDATION({
150
+ message: `'${path}' is a singleton; use client.singleton('${path}')`,
151
+ details: { path, actualKind: 'singleton', expectedKind: 'collection' },
152
+ });
153
+ }
145
154
  return new CollectionHandle(this, definition);
146
155
  }
156
+ /** Get the deliberately narrow, document-ID-free handle for a singleton slot. */
157
+ singleton(path) {
158
+ const definition = this.collections.find((c) => c.path === path);
159
+ if (!definition) {
160
+ throw ERR_NOT_FOUND({
161
+ message: `Singleton not found: '${path}'`,
162
+ details: {
163
+ singletonPath: path,
164
+ available: this.collections.filter(isSingleton).map((c) => c.path),
165
+ },
166
+ });
167
+ }
168
+ if (!isSingleton(definition)) {
169
+ throw ERR_VALIDATION({
170
+ message: `'${path}' is a collection; use client.collection('${path}')`,
171
+ details: { path, actualKind: 'collection', expectedKind: 'singleton' },
172
+ });
173
+ }
174
+ return new SingletonHandle(this, definition);
175
+ }
147
176
  /**
148
177
  * Resolve a collection path to its database row id and current schema
149
178
  * version. Cached for the lifetime of this client instance. The version
@@ -174,9 +203,9 @@ export class BylineClient {
174
203
  }
175
204
  }
176
205
  /**
177
- * Create a new Byline client instance. The registry generic defaults to
178
- * `RegisteredCollections`, so in an app whose generated types augment
179
- * `Register` the returned client is fully typed with no explicit generic.
206
+ * Create a new Byline client instance. Both registry generics default through
207
+ * `Register`, so an app's generated collection and singleton types make the
208
+ * returned client fully typed with no explicit generic.
180
209
  */
181
210
  export function createBylineClient(config) {
182
211
  return new BylineClient(config);
@@ -7,8 +7,9 @@
7
7
  */
8
8
  import { applyAfterRead, assertActorCanPerform, buildSearchDocument, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, createDocument, createReadContext, createRichTextDocumentReader, deleteDocument, ERR_VALIDATION, getDocumentScheduledPublish, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, resolveIdentityField, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, } from '@byline/core';
9
9
  import { getReadSecurityDomain, resolveReadRequestContext, resolveReadSecurityFilters, } from './read-context.js';
10
+ import { expectedDocumentId } from './read-internals.js';
10
11
  import { shapeDocument, shapePopulatedInPlace } from './response.js';
11
- import { assertSearchQueryLength, finalizeSearchHits } from './search.js';
12
+ import { assertSearchFieldScope, assertSearchQueryLength, finalizeSearchHits } from './search.js';
12
13
  function toScheduleInfo(schedule) {
13
14
  const { executionToken: _executionToken, executionExpiresAt: _executionExpiresAt, ...info } = schedule;
14
15
  return info;
@@ -147,8 +148,10 @@ export class CollectionHandle {
147
148
  'see `@byline/search-postgres` → `postgresSearch()` for the built-in driver.',
148
149
  });
149
150
  }
151
+ assertSearchFieldScope(provider, options.fieldScope);
150
152
  const results = await provider.search({
151
153
  query: options.query,
154
+ fieldScope: options.fieldScope,
152
155
  matching: options.matching,
153
156
  collectionPath: this.definition.path,
154
157
  locale: options.locale ?? this.client.defaultLocale,
@@ -240,7 +243,7 @@ export class CollectionHandle {
240
243
  */
241
244
  async reindex() {
242
245
  const requestContext = await this.client.resolveRequestContext();
243
- assertActorCanPerform(requestContext, this.definition.path, 'reindex');
246
+ assertActorCanPerform(requestContext, this.definition, 'reindex');
244
247
  const provider = this.client.searchProvider;
245
248
  const result = { collectionPath: this.definition.path, documents: 0, indexed: 0 };
246
249
  if (provider == null || this.definition.search == null)
@@ -612,6 +615,7 @@ export class CollectionHandle {
612
615
  * `null` without revealing ownership.
613
616
  */
614
617
  async findByVersion(versionId, options = {}) {
618
+ const boundOptions = options;
615
619
  const readCtx = options._readContext ?? createReadContext();
616
620
  const requestContext = await this.resolveAndAssertRead('any', readCtx);
617
621
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
@@ -619,6 +623,7 @@ export class CollectionHandle {
619
623
  const locale = options.locale ?? this.client.defaultLocale;
620
624
  const raw = await this.client.db.queries.documents.getDocumentByVersion({
621
625
  document_version_id: versionId,
626
+ document_id: boundOptions[expectedDocumentId],
622
627
  locale,
623
628
  collection_id: collectionId,
624
629
  filters,
@@ -824,7 +829,7 @@ export class CollectionHandle {
824
829
  */
825
830
  async resolveAndAssertRead(readMode, readContext) {
826
831
  const requestContext = await resolveReadRequestContext(this.client, readContext, readMode);
827
- assertActorCanPerform(requestContext, this.definition.path, 'read');
832
+ assertActorCanPerform(requestContext, this.definition, 'read');
828
833
  return requestContext;
829
834
  }
830
835
  /**
package/dist/index.d.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- export { type AuditLogEntry, type AuditLogPage, type ChangeStatusResult, type CreateDocumentResult, type CycleRelationValue, createReadContext, type DeleteDocumentResult, ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateSpec, type ReadContext, type ReadMode, type RestoreVersionResult, type UnpublishResult, type UnresolvedRelationValue, type UpdateDocumentResult, } from '@byline/core';
8
+ export { type AuditLogEntry, type AuditLogPage, type ChangeStatusResult, type CreateDocumentResult, type CycleRelationValue, createReadContext, type DeleteDocumentResult, ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateSpec, type ReadContext, type ReadMode, type RestoreVersionResult, type SingletonSaveResult, type UnpublishResult, type UnresolvedRelationValue, type UpdateDocumentResult, } from '@byline/core';
9
9
  export { BylineClient, createBylineClient } from './client.js';
10
10
  export { CollectionHandle } from './collection-handle.js';
11
- export type { Register, RegisteredCollections } from './register.js';
12
- export type { AuditLogOptions, BylineClientConfig, ClientDocument, ClientSearchResults, CollectionSearchOptions, ConfirmScheduledPublishOptions, CreateOptions, DocumentPublishScheduleInfo, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, GetTreeParentOptions, HydratedSearchHit, PlaceTreeNodeOptions, PopulatedRelation, ReindexResult, RemoveFromTreeOptions, SchedulePublishOptions, SearchFacetBucket, SearchHit, SearchResults, SortDirection, SortSpec, TreeNode, TreeParentResult, UpdateOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, ZoneSearchOptions, } from './types.js';
11
+ export { SingletonHandle } from './singleton-handle.js';
12
+ export type { Register, RegisteredCollections, RegisteredSingletons } from './register.js';
13
+ export type { AuditLogOptions, BylineClientConfig, ClientDocument, ClientSearchResults, CollectionSearchOptions, ConfirmScheduledPublishOptions, CreateOptions, DocumentPublishScheduleInfo, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSingletonOptions, GetSubtreeOptions, GetTreeParentOptions, HydratedSearchHit, PlaceTreeNodeOptions, PopulatedRelation, ReindexResult, RemoveFromTreeOptions, SchedulePublishOptions, SearchFacetBucket, SearchHit, SearchResults, SingletonDocument, SortDirection, SortSpec, TreeNode, TreeParentResult, UpdateOptions, UpdateSingletonOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, ZoneSearchOptions, } from './types.js';
package/dist/index.js CHANGED
@@ -8,3 +8,4 @@
8
8
  export { createReadContext, ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '@byline/core';
9
9
  export { BylineClient, createBylineClient } from './client.js';
10
10
  export { CollectionHandle } from './collection-handle.js';
11
+ export { SingletonHandle } from './singleton-handle.js';
@@ -0,0 +1,5 @@
1
+ /** Internal ownership constraint for singleton historical reads. */
2
+ export declare const expectedDocumentId: unique symbol;
3
+ export interface DocumentBoundFindByVersionOptions {
4
+ [expectedDocumentId]?: string;
5
+ }
@@ -0,0 +1,2 @@
1
+ /** Internal ownership constraint for singleton historical reads. */
2
+ export const expectedDocumentId = Symbol('byline.expectedDocumentId');
@@ -15,14 +15,16 @@ import type { CollectionRegistry } from '@byline/core';
15
15
  * declare module '@byline/client' {
16
16
  * interface Register {
17
17
  * collections: CollectionFieldsByPath
18
+ * singletons: SingletonFieldsByPath
18
19
  * }
19
20
  * }
20
21
  * ```
21
22
  *
22
23
  * Once merged, every bare `BylineClient` in the app's program — including
23
24
  * the getters on `@byline/client/server` — resolves to
24
- * `BylineClient<CollectionFieldsByPath>`, so `client.collection('news')`
25
- * autocompletes collection paths and returns the generated field shapes
25
+ * `BylineClient<CollectionFieldsByPath, SingletonFieldsByPath>`, so
26
+ * `client.collection('news')` and `client.singleton('site-settings')`
27
+ * autocomplete their respective paths and return the generated field shapes
26
28
  * with no per-call generics and no type assertions.
27
29
  *
28
30
  * Exactly one application per TypeScript program can augment this
@@ -42,3 +44,12 @@ export interface Register {
42
44
  export type RegisteredCollections = Register extends {
43
45
  collections: infer TCollections extends CollectionRegistry;
44
46
  } ? TCollections : CollectionRegistry;
47
+ /**
48
+ * The app's registered singleton registry, or the same loose
49
+ * `CollectionRegistry` fallback used by collections when no augmentation is
50
+ * present. Keeping this as a conditional lookup preserves the deliberately
51
+ * empty declaration-merge target above.
52
+ */
53
+ export type RegisteredSingletons = Register extends {
54
+ singletons: infer TSingletons extends CollectionRegistry;
55
+ } ? TSingletons : CollectionRegistry;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expectTypeOf, it } from 'vitest';
9
+ describe('unaugmented client registration fallback', () => {
10
+ it('keeps both path registries loose outside an application program', () => {
11
+ expectTypeOf().toEqualTypeOf();
12
+ expectTypeOf().toEqualTypeOf();
13
+ expectTypeOf().toEqualTypeOf();
14
+ expectTypeOf().toEqualTypeOf();
15
+ });
16
+ });
package/dist/search.d.ts CHANGED
@@ -17,9 +17,11 @@
17
17
  * into shaped `ClientDocument`s in the same query.
18
18
  */
19
19
  import type { RequestContext } from '@byline/auth';
20
- import type { ReadContext, ReadMode, SearchHit } from '@byline/core';
20
+ import type { ReadContext, ReadMode, SearchHit, SearchProvider } from '@byline/core';
21
21
  import type { BylineClient } from './client.js';
22
22
  import type { ClientSearchResults, HydratedSearchHit, ZoneSearchOptions } from './types.js';
23
+ /** Validate the optional logical body-field scope before calling a provider. */
24
+ export declare function assertSearchFieldScope(provider: SearchProvider, fieldScope: string[] | undefined): void;
23
25
  export interface FinalizeSearchHitsParams {
24
26
  client: BylineClient<any>;
25
27
  requestContext: RequestContext;
package/dist/search.js CHANGED
@@ -7,6 +7,20 @@
7
7
  */
8
8
  import { assertActorCanPerform, createReadContext, ERR_VALIDATION, getCollectionAdminConfig, MAX_SEARCH_QUERY_LENGTH, resolveSearchZones, } from '@byline/core';
9
9
  import { resolveReadRequestContext, resolveReadSecurityFilters } from './read-context.js';
10
+ /** Validate the optional logical body-field scope before calling a provider. */
11
+ export function assertSearchFieldScope(provider, fieldScope) {
12
+ if (fieldScope == null)
13
+ return;
14
+ if (fieldScope.length === 0 ||
15
+ fieldScope.some((field) => typeof field !== 'string' || field.trim().length === 0)) {
16
+ throw ERR_VALIDATION({ message: 'Search fieldScope must contain one or more field names.' });
17
+ }
18
+ if (!provider.capabilities.fullText.fieldScope) {
19
+ throw ERR_VALIDATION({
20
+ message: 'The registered search provider does not support field-scoped full-text queries.',
21
+ });
22
+ }
23
+ }
10
24
  /**
11
25
  * Authorise (and optionally hydrate) provider hits, per collection:
12
26
  *
@@ -152,7 +166,7 @@ export async function zoneSearch(client, options) {
152
166
  let firstAbilityError;
153
167
  for (const member of members) {
154
168
  try {
155
- assertActorCanPerform(requestContext, member.path, 'read');
169
+ assertActorCanPerform(requestContext, member, 'read');
156
170
  readable.add(member.path);
157
171
  }
158
172
  catch (err) {
@@ -163,6 +177,7 @@ export async function zoneSearch(client, options) {
163
177
  if (readable.size === 0) {
164
178
  throw firstAbilityError;
165
179
  }
180
+ assertSearchFieldScope(provider, options.fieldScope);
166
181
  let aggregateRestricted = readable.size !== members.length;
167
182
  if (!options._bypassBeforeRead) {
168
183
  for (const member of members) {
@@ -176,6 +191,7 @@ export async function zoneSearch(client, options) {
176
191
  }
177
192
  const results = await provider.search({
178
193
  query: options.query,
194
+ fieldScope: options.fieldScope,
179
195
  matching: options.matching,
180
196
  zone: options.zone,
181
197
  locale: options.locale ?? client.defaultLocale,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { ChangeStatusResult, SingletonDefinition, SingletonSaveResult, UnpublishResult } from '@byline/core';
9
+ import type { BylineClient } from './client.js';
10
+ import type { ClientDocument, ConfirmScheduledPublishOptions, DocumentPublishScheduleInfo, FindByVersionOptions, FindResult, GetSingletonOptions, HistoryOptions, SchedulePublishOptions, SingletonDocument, UpdateSingletonOptions } from './types.js';
11
+ /**
12
+ * A document-ID-free handle for one registered singleton slot.
13
+ *
14
+ * Reads deliberately delegate to the collection document reader only after
15
+ * the singleton read ability and slot mapping have been resolved in that
16
+ * order. This keeps reconstruction, hooks, populate, and response shaping
17
+ * identical without exposing collection-only operations on this handle.
18
+ */
19
+ export declare class SingletonHandle<TFields extends Record<string, any> = Record<string, any>> {
20
+ private readonly client;
21
+ private readonly definition;
22
+ private readonly reader;
23
+ constructor(client: BylineClient<any, any>, definition: SingletonDefinition);
24
+ get(options?: GetSingletonOptions<TFields>): Promise<SingletonDocument<TFields> | null>;
25
+ update(data: TFields, options?: UpdateSingletonOptions): Promise<SingletonSaveResult>;
26
+ changeStatus(nextStatus: string): Promise<ChangeStatusResult>;
27
+ unpublish(): Promise<UnpublishResult>;
28
+ schedulePublish(options: SchedulePublishOptions): Promise<DocumentPublishScheduleInfo>;
29
+ confirmScheduledPublish(options: ConfirmScheduledPublishOptions): Promise<DocumentPublishScheduleInfo>;
30
+ cancelScheduledPublish(): Promise<DocumentPublishScheduleInfo | null>;
31
+ getScheduledPublish(): Promise<DocumentPublishScheduleInfo | null>;
32
+ history<F = TFields>(options?: HistoryOptions): Promise<FindResult<F>>;
33
+ findByVersion<F = TFields>(versionId: string, options?: FindByVersionOptions<F>): Promise<ClientDocument<F> | null>;
34
+ restoreVersion(sourceVersionId: string): Promise<SingletonSaveResult>;
35
+ copyToLocale(args: {
36
+ sourceLocale: string;
37
+ targetLocale: string;
38
+ overwrite?: boolean;
39
+ }): Promise<SingletonSaveResult>;
40
+ private authorizeRead;
41
+ private buildAuthorizedLifecycleContext;
42
+ private resolveMappedDocumentId;
43
+ private resolveRequiredDocument;
44
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { assertActorCanPerform, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copySingletonToLocale, createReadContext, ERR_NOT_FOUND, getDocumentScheduledPublish, resolveSingletonDocumentId, restoreSingletonVersion, scheduleDocumentPublish, unpublishDocument, updateSingleton, } from '@byline/core';
9
+ import { CollectionHandle } from './collection-handle.js';
10
+ import { resolveReadRequestContext } from './read-context.js';
11
+ import { expectedDocumentId } from './read-internals.js';
12
+ function toScheduleInfo(schedule) {
13
+ const { executionToken: _executionToken, executionExpiresAt: _executionExpiresAt, ...info } = schedule;
14
+ return info;
15
+ }
16
+ /**
17
+ * A document-ID-free handle for one registered singleton slot.
18
+ *
19
+ * Reads deliberately delegate to the collection document reader only after
20
+ * the singleton read ability and slot mapping have been resolved in that
21
+ * order. This keeps reconstruction, hooks, populate, and response shaping
22
+ * identical without exposing collection-only operations on this handle.
23
+ */
24
+ export class SingletonHandle {
25
+ client;
26
+ definition;
27
+ reader;
28
+ constructor(client, definition) {
29
+ this.client = client;
30
+ this.definition = definition;
31
+ this.reader = new CollectionHandle(client, definition);
32
+ }
33
+ async get(options = {}) {
34
+ const readContext = options._readContext ?? createReadContext();
35
+ await this.authorizeRead(options.status ?? 'published', readContext);
36
+ const documentId = await this.resolveMappedDocumentId();
37
+ if (documentId == null)
38
+ return null;
39
+ const document = await this.reader.findById(documentId, {
40
+ ...options,
41
+ _readContext: readContext,
42
+ });
43
+ if (document == null)
44
+ return null;
45
+ const { path: _path, ...singleton } = document;
46
+ return singleton;
47
+ }
48
+ async update(data, options = {}) {
49
+ const ctx = await this.buildAuthorizedLifecycleContext(['update']);
50
+ return updateSingleton(ctx, { data, ...options });
51
+ }
52
+ async changeStatus(nextStatus) {
53
+ const abilities = ['changeStatus'];
54
+ if (nextStatus === 'published')
55
+ abilities.push('publish');
56
+ const { ctx, documentId } = await this.resolveRequiredDocument(abilities);
57
+ return changeDocumentStatus(ctx, { documentId, nextStatus });
58
+ }
59
+ async unpublish() {
60
+ const { ctx, documentId } = await this.resolveRequiredDocument(['changeStatus']);
61
+ return unpublishDocument(ctx, { documentId });
62
+ }
63
+ async schedulePublish(options) {
64
+ const { ctx, documentId } = await this.resolveRequiredDocument(['changeStatus', 'publish']);
65
+ return toScheduleInfo(await scheduleDocumentPublish(ctx, { documentId, ...options }));
66
+ }
67
+ async confirmScheduledPublish(options) {
68
+ const { ctx, documentId } = await this.resolveRequiredDocument(['changeStatus', 'publish']);
69
+ return toScheduleInfo(await confirmDocumentScheduledPublish(ctx, { documentId, ...options }));
70
+ }
71
+ async cancelScheduledPublish() {
72
+ const { ctx, documentId } = await this.resolveRequiredDocument(['changeStatus', 'publish']);
73
+ const schedule = await cancelDocumentScheduledPublish(ctx, { documentId });
74
+ return schedule == null ? null : toScheduleInfo(schedule);
75
+ }
76
+ async getScheduledPublish() {
77
+ const ctx = await this.buildAuthorizedLifecycleContext(['changeStatus', 'publish']);
78
+ const documentId = await resolveSingletonDocumentId(ctx);
79
+ if (documentId == null)
80
+ return null;
81
+ const schedule = await getDocumentScheduledPublish(ctx, { documentId });
82
+ return schedule == null ? null : toScheduleInfo(schedule);
83
+ }
84
+ async history(options = {}) {
85
+ const readContext = options._readContext ?? createReadContext();
86
+ await this.authorizeRead('any', readContext);
87
+ const documentId = await this.resolveMappedDocumentId();
88
+ if (documentId == null) {
89
+ const page = options.page ?? 1;
90
+ const pageSize = options.pageSize ?? 20;
91
+ return { docs: [], meta: { total: 0, page, pageSize, totalPages: 0 } };
92
+ }
93
+ return this.reader.history(documentId, { ...options, _readContext: readContext });
94
+ }
95
+ async findByVersion(versionId, options = {}) {
96
+ const readContext = options._readContext ?? createReadContext();
97
+ await this.authorizeRead('any', readContext);
98
+ const documentId = await this.resolveMappedDocumentId();
99
+ if (documentId == null)
100
+ return null;
101
+ const boundOptions = {
102
+ ...options,
103
+ _readContext: readContext,
104
+ [expectedDocumentId]: documentId,
105
+ };
106
+ return this.reader.findByVersion(versionId, boundOptions);
107
+ }
108
+ async restoreVersion(sourceVersionId) {
109
+ const ctx = await this.buildAuthorizedLifecycleContext(['update']);
110
+ return restoreSingletonVersion(ctx, { sourceVersionId });
111
+ }
112
+ async copyToLocale(args) {
113
+ const ctx = await this.buildAuthorizedLifecycleContext(['update']);
114
+ return copySingletonToLocale(ctx, { ...args, overwrite: args.overwrite ?? false });
115
+ }
116
+ async authorizeRead(readMode, readContext) {
117
+ const requestContext = await resolveReadRequestContext(this.client, readContext, readMode);
118
+ assertActorCanPerform(requestContext, this.definition, 'read');
119
+ return requestContext;
120
+ }
121
+ async buildAuthorizedLifecycleContext(abilities) {
122
+ const requestContext = await this.client.resolveRequestContext();
123
+ for (const ability of abilities) {
124
+ assertActorCanPerform(requestContext, this.definition, ability);
125
+ }
126
+ const { id: collectionId, version: collectionVersion } = await this.client.resolveCollectionRecord(this.definition.path);
127
+ return {
128
+ db: this.client.db,
129
+ definition: this.definition,
130
+ collectionId,
131
+ collectionVersion,
132
+ collectionPath: this.definition.path,
133
+ storage: this.client.storage,
134
+ logger: this.client.logger,
135
+ defaultLocale: this.client.defaultLocale,
136
+ slugifier: this.client.slugifier,
137
+ requestContext,
138
+ };
139
+ }
140
+ async resolveMappedDocumentId() {
141
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
142
+ return this.client.db.queries.singletons.getMappedDocumentId(collectionId);
143
+ }
144
+ async resolveRequiredDocument(abilities) {
145
+ const ctx = await this.buildAuthorizedLifecycleContext(abilities);
146
+ const documentId = await resolveSingletonDocumentId(ctx);
147
+ if (documentId == null) {
148
+ throw ERR_NOT_FOUND({
149
+ message: `singleton '${this.definition.path}' has not been saved`,
150
+ details: { singletonPath: this.definition.path },
151
+ });
152
+ }
153
+ return { ctx, documentId };
154
+ }
155
+ }
package/dist/types.d.ts CHANGED
@@ -183,6 +183,8 @@ interface StatusControls {
183
183
  export interface CollectionSearchOptions extends BeforeReadControls {
184
184
  /** Free-text query string. */
185
185
  query: string;
186
+ /** Restrict full-text matching to named fields from the collection's `search.body`. */
187
+ fieldScope?: string[];
186
188
  /** Provider-neutral term and phrase matching semantics. */
187
189
  matching?: SearchMatching;
188
190
  /** Restrict to a single content locale (defaults to the client default). */
@@ -217,6 +219,8 @@ export interface CollectionSearchOptions extends BeforeReadControls {
217
219
  export interface ZoneSearchOptions extends BeforeReadControls {
218
220
  /** Free-text query string. */
219
221
  query: string;
222
+ /** Restrict full-text matching to body-field names shared by participating collections. */
223
+ fieldScope?: string[];
220
224
  /** Provider-neutral term and phrase matching semantics. */
221
225
  matching?: SearchMatching;
222
226
  /** The zone (named cross-collection scope) to search. */
@@ -335,6 +339,8 @@ export interface FindByIdOptions<F = Record<string, any>> extends PopulateContro
335
339
  */
336
340
  lenient?: boolean;
337
341
  }
342
+ /** Read controls for `SingletonHandle.get()`. */
343
+ export type GetSingletonOptions<F = Record<string, any>> = FindByIdOptions<F>;
338
344
  export interface FindByPathOptions<F = Record<string, any>> extends PopulateControls, StatusControls, MissingLocaleControls, BeforeReadControls {
339
345
  select?: (keyof F & string)[] | string[];
340
346
  locale?: string;
@@ -417,6 +423,13 @@ export interface UpdateOptions {
417
423
  */
418
424
  availableLocales?: string[];
419
425
  }
426
+ /** Write controls for `SingletonHandle.update()`. */
427
+ export interface UpdateSingletonOptions {
428
+ /** Locale for field value resolution. Defaults to the client's `defaultLocale`. */
429
+ locale?: string;
430
+ /** Reject the save when this is not the slot's current version id. */
431
+ expectedVersionId?: string;
432
+ }
420
433
  /** Optimistic, absolute-time input for `CollectionHandle.schedulePublish()`. */
421
434
  export interface SchedulePublishOptions {
422
435
  /** ISO instant carrying an explicit UTC offset or `Z`. */
@@ -637,6 +650,8 @@ export interface ClientDocument<F = Record<string, any>> {
637
650
  */
638
651
  _localeAgnostic?: boolean;
639
652
  }
653
+ /** A singleton read envelope. Singleton documents have no public URL path. */
654
+ export type SingletonDocument<F = Record<string, any>> = Omit<ClientDocument<F>, 'path'>;
640
655
  export interface FindResult<F = Record<string, any>> {
641
656
  docs: ClientDocument<F>[];
642
657
  meta: {
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": "4.16.0",
5
+ "version": "4.18.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -44,22 +44,21 @@
44
44
  ],
45
45
  "dependencies": {
46
46
  "uuid": "^14.0.2",
47
- "@byline/auth": "4.16.0",
48
- "@byline/core": "4.16.0"
47
+ "@byline/auth": "4.18.0",
48
+ "@byline/core": "4.18.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@biomejs/biome": "2.5.9",
52
- "@types/node": "^26.2.0",
51
+ "@biomejs/biome": "2.5.10",
52
+ "@types/node": "^26.4.0",
53
53
  "chokidar": "^5.0.0",
54
54
  "chokidar-cli": "^3.0.0",
55
55
  "dotenv": "^17.4.2",
56
56
  "npm-run-all": "^4.1.5",
57
57
  "tsc-alias": "^1.9.2",
58
- "tsx": "^4.23.12",
59
58
  "typescript": "^7.0.2",
60
59
  "vitest": "^4.1.11",
61
- "@byline/db-postgres": "4.16.0",
62
- "@byline/search-postgres": "4.16.0"
60
+ "@byline/search-postgres": "4.18.0",
61
+ "@byline/db-postgres": "4.18.0"
63
62
  },
64
63
  "publishConfig": {
65
64
  "access": "public",