@byline/client 4.15.0 → 4.17.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,6 +7,7 @@
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
12
  import { assertSearchQueryLength, finalizeSearchHits } from './search.js';
12
13
  function toScheduleInfo(schedule) {
@@ -240,7 +241,7 @@ export class CollectionHandle {
240
241
  */
241
242
  async reindex() {
242
243
  const requestContext = await this.client.resolveRequestContext();
243
- assertActorCanPerform(requestContext, this.definition.path, 'reindex');
244
+ assertActorCanPerform(requestContext, this.definition, 'reindex');
244
245
  const provider = this.client.searchProvider;
245
246
  const result = { collectionPath: this.definition.path, documents: 0, indexed: 0 };
246
247
  if (provider == null || this.definition.search == null)
@@ -612,6 +613,7 @@ export class CollectionHandle {
612
613
  * `null` without revealing ownership.
613
614
  */
614
615
  async findByVersion(versionId, options = {}) {
616
+ const boundOptions = options;
615
617
  const readCtx = options._readContext ?? createReadContext();
616
618
  const requestContext = await this.resolveAndAssertRead('any', readCtx);
617
619
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
@@ -619,6 +621,7 @@ export class CollectionHandle {
619
621
  const locale = options.locale ?? this.client.defaultLocale;
620
622
  const raw = await this.client.db.queries.documents.getDocumentByVersion({
621
623
  document_version_id: versionId,
624
+ document_id: boundOptions[expectedDocumentId],
622
625
  locale,
623
626
  collection_id: collectionId,
624
627
  filters,
@@ -824,7 +827,7 @@ export class CollectionHandle {
824
827
  */
825
828
  async resolveAndAssertRead(readMode, readContext) {
826
829
  const requestContext = await resolveReadRequestContext(this.client, readContext, readMode);
827
- assertActorCanPerform(requestContext, this.definition.path, 'read');
830
+ assertActorCanPerform(requestContext, this.definition, 'read');
828
831
  return requestContext;
829
832
  }
830
833
  /**
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.js CHANGED
@@ -152,7 +152,7 @@ export async function zoneSearch(client, options) {
152
152
  let firstAbilityError;
153
153
  for (const member of members) {
154
154
  try {
155
- assertActorCanPerform(requestContext, member.path, 'read');
155
+ assertActorCanPerform(requestContext, member, 'read');
156
156
  readable.add(member.path);
157
157
  }
158
158
  catch (err) {
@@ -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
@@ -335,6 +335,8 @@ export interface FindByIdOptions<F = Record<string, any>> extends PopulateContro
335
335
  */
336
336
  lenient?: boolean;
337
337
  }
338
+ /** Read controls for `SingletonHandle.get()`. */
339
+ export type GetSingletonOptions<F = Record<string, any>> = FindByIdOptions<F>;
338
340
  export interface FindByPathOptions<F = Record<string, any>> extends PopulateControls, StatusControls, MissingLocaleControls, BeforeReadControls {
339
341
  select?: (keyof F & string)[] | string[];
340
342
  locale?: string;
@@ -417,6 +419,13 @@ export interface UpdateOptions {
417
419
  */
418
420
  availableLocales?: string[];
419
421
  }
422
+ /** Write controls for `SingletonHandle.update()`. */
423
+ export interface UpdateSingletonOptions {
424
+ /** Locale for field value resolution. Defaults to the client's `defaultLocale`. */
425
+ locale?: string;
426
+ /** Reject the save when this is not the slot's current version id. */
427
+ expectedVersionId?: string;
428
+ }
420
429
  /** Optimistic, absolute-time input for `CollectionHandle.schedulePublish()`. */
421
430
  export interface SchedulePublishOptions {
422
431
  /** ISO instant carrying an explicit UTC offset or `Z`. */
@@ -637,6 +646,8 @@ export interface ClientDocument<F = Record<string, any>> {
637
646
  */
638
647
  _localeAgnostic?: boolean;
639
648
  }
649
+ /** A singleton read envelope. Singleton documents have no public URL path. */
650
+ export type SingletonDocument<F = Record<string, any>> = Omit<ClientDocument<F>, 'path'>;
640
651
  export interface FindResult<F = Record<string, any>> {
641
652
  docs: ClientDocument<F>[];
642
653
  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.15.0",
5
+ "version": "4.17.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.15.0",
48
- "@byline/core": "4.15.0"
47
+ "@byline/auth": "4.17.0",
48
+ "@byline/core": "4.17.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/search-postgres": "4.15.0",
62
- "@byline/db-postgres": "4.15.0"
60
+ "@byline/search-postgres": "4.17.0",
61
+ "@byline/db-postgres": "4.17.0"
63
62
  },
64
63
  "publishConfig": {
65
64
  "access": "public",