@lemonadecms/client 0.1.0 → 0.1.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/transport.js CHANGED
@@ -3,16 +3,24 @@
3
3
  // selected factory supplies the credential and route; callers cannot switch modes
4
4
  // through list options. Responses are checked as unknown data before their envelope
5
5
  // is converted from API snake_case to SDK camelCase. No automatic retries occur.
6
- import { CMSConfigurationError, CMSRequestError, CMSResponseError, CMSTimeoutError } from "./errors.js";
6
+ import { CMSConfigurationError, CMSRequestError, CMSResponseError, CMSTimeoutError, CMSNotFoundError } from "./errors.js";
7
7
  const identifier = /^[a-z][a-z0-9_]{0,47}$/;
8
8
  const cursor = (value) => uuid.test(value) || (value.length <= 8192 && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value));
9
9
  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
10
  const serviceURL = "https://lemonadecms.com";
11
+ const localeTag = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
11
12
  // Narrow unknown JSON to an object before accessing properties. Arrays and null
12
13
  // are not valid objects for the API envelope even though typeof reports object.
13
14
  function record(value) {
14
15
  return typeof value === "object" && value !== null && !Array.isArray(value);
15
16
  }
17
+ function readOptions(value, allowed, method) {
18
+ const controls = ["limit", "page", "pageSize", "after", "before", "maxPages", "select", "sort", "filters", "includeRemote", "ids", "search"];
19
+ if (!record(value) || Object.keys(value).some(key => !allowed.includes(key)) ||
20
+ controls.some(key => !allowed.includes(key) && key in value)) {
21
+ throw new TypeError(`${method} supports ${allowed.join(", ")}.`);
22
+ }
23
+ }
16
24
  // Only configured deadlines need a local controller/race. Keep the default
17
25
  // fetch signal unchanged, while rejecting already-cancelled work before I/O.
18
26
  // Native composition preserves fetch-style cancellation even when another caller
@@ -61,9 +69,50 @@ function withinDeadline(timeoutMs, callerSignal, execute) {
61
69
  }
62
70
  });
63
71
  }
72
+ function validateFilters(value) {
73
+ let nodes = 0, leaves = 0, stringUnits = 0;
74
+ const scalar = (v) => {
75
+ if (typeof v === "string") {
76
+ stringUnits += v.length;
77
+ return stringUnits <= 4096 && !v.includes("\0");
78
+ }
79
+ return typeof v === "boolean" || typeof v === "number" && Number.isFinite(v);
80
+ };
81
+ const walk = (node, depth) => {
82
+ if (!record(node) || depth > 4 || ++nodes > 31)
83
+ return false;
84
+ const keys = Object.keys(node);
85
+ if (keys.length === 1 && ["and", "or", "not"].includes(keys[0])) {
86
+ const group = keys[0];
87
+ if (group === "not")
88
+ return walk(node.not, depth + 1);
89
+ const children = node[group];
90
+ return Array.isArray(children) && children.length >= 1 && children.length <= 10 && Array.from(children).every(child => walk(child, depth + 1));
91
+ }
92
+ if (++leaves > 10 || keys.sort().join(",") !== "field,op,value" || typeof node.field !== "string" ||
93
+ !identifier.test(node.field) || typeof node.op !== "string")
94
+ return false;
95
+ const v = node.value;
96
+ if (["eq", "ne"].includes(node.op))
97
+ return v === null || scalar(v);
98
+ if (["in", "not_in"].includes(node.op))
99
+ return Array.isArray(v) && v.length >= 1 && v.length <= 100 && Array.from(v).every(scalar);
100
+ if (["contains_any", "contains_all"].includes(node.op))
101
+ return Array.isArray(v) && v.length >= 1 && v.length <= 100 && new Set(v).size === v.length &&
102
+ Array.from(v).every(option => typeof option === "string" && option.trim().length > 0 && scalar(option));
103
+ if (["gt", "gte", "lt", "lte"].includes(node.op))
104
+ return (typeof v === "string" || typeof v === "number") && scalar(v);
105
+ if (["eqi", "contains", "containsi", "starts_with", "starts_withi", "ends_with", "ends_withi"].includes(node.op))
106
+ return typeof v === "string" && (node.op === "eqi" || v.length > 0) && scalar(v);
107
+ return false;
108
+ };
109
+ const valid = Array.isArray(value) ? value.length <= 10 && Array.from(value).every(node => walk(node, 0)) : walk(value, 0);
110
+ if (!valid)
111
+ throw new TypeError("filters require valid conditions or nonempty and/or/not groups, at most ten leaves, 31 nodes and depth four.");
112
+ }
64
113
  function query(options) {
65
- if (!record(options) || Object.keys(options).some(key => !["select", "limit", "page", "pageSize", "after", "before", "signal", "locale", "filters", "sort", "includeRemote"].includes(key))) {
66
- throw new TypeError("Supported list options are select, limit, page, pageSize, after, before, locale, filters, sort and signal.");
114
+ if (!record(options) || Object.keys(options).some(key => !["select", "limit", "page", "pageSize", "after", "before", "signal", "locale", "filters", "sort", "search", "includeRemote"].includes(key))) {
115
+ throw new TypeError("Supported list options are select, limit, page, pageSize, after, before, locale, filters, sort, search, includeRemote and signal.");
67
116
  }
68
117
  const params = new URLSearchParams();
69
118
  if (options.includeRemote !== undefined) {
@@ -95,7 +144,7 @@ function query(options) {
95
144
  params.set("limit", String(options.limit));
96
145
  }
97
146
  if (options.locale !== undefined) {
98
- if (typeof options.locale !== "string" || !/^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/.test(options.locale)) {
147
+ if (typeof options.locale !== "string" || !localeTag.test(options.locale)) {
99
148
  throw new TypeError("locale must be a language code such as en, es or pt-BR.");
100
149
  }
101
150
  params.set("locale", options.locale);
@@ -113,23 +162,30 @@ function query(options) {
113
162
  }
114
163
  }
115
164
  if (options.filters !== undefined) {
116
- const scalar = (v) => typeof v === "string" && v.length <= 20000 || typeof v === "boolean" || typeof v === "number" && Number.isFinite(v);
117
- const choices = (value) => Array.isArray(value) && value.length >= 1 && value.length <= 100 &&
118
- new Set(value).size === value.length && value.every(option => typeof option === "string" && option.trim().length > 0 && option.length <= 20000);
119
- if (!Array.isArray(options.filters) || options.filters.length > 10 || !options.filters.every(clause => record(clause) && Object.keys(clause).sort().join(",") === "field,op,value" && typeof clause.field === "string" && identifier.test(clause.field) &&
120
- (clause.op === "eq" ? clause.value === null || scalar(clause.value) : clause.op === "in" ? Array.isArray(clause.value) && clause.value.length >= 1 && clause.value.length <= 100 && clause.value.every(scalar) :
121
- ["contains_any", "contains_all"].includes(String(clause.op)) ? choices(clause.value) :
122
- ["gt", "gte", "lt", "lte"].includes(String(clause.op)) && (typeof clause.value === "string" || typeof clause.value === "number" && Number.isFinite(clause.value))))) {
123
- throw new TypeError("filters must contain at most ten valid scalar or multiple-choice AND clauses.");
124
- }
165
+ validateFilters(options.filters);
125
166
  const encoded = JSON.stringify(options.filters);
126
167
  if (new TextEncoder().encode(encoded).length > 4096)
127
- throw new TypeError("filters exceed 4 KB.");
168
+ throw new TypeError("filters exceed 4 KiB.");
128
169
  params.set("filters", encoded);
129
170
  }
171
+ if (options.search !== undefined) {
172
+ const search = options.search;
173
+ if (!record(search) || Object.keys(search).some(key => !["query", "fields"].includes(key)) ||
174
+ typeof search.query !== "string" || search.query.includes("\0") ||
175
+ new TextEncoder().encode(search.query).length > 512 || Array.from(search.query.trim()).length < 1 ||
176
+ Array.from(search.query.trim()).length > 200 || search.query.trim().split(/\s+/u).length > 16 ||
177
+ search.fields !== undefined && (!Array.isArray(search.fields) || search.fields.length < 1 || search.fields.length > 24 ||
178
+ new Set(search.fields).size !== search.fields.length || !Array.from(search.fields).every(key => typeof key === "string" && identifier.test(key)))) {
179
+ throw new TypeError("search requires 1–200 characters/512 UTF-8 bytes, at most 16 terms, and optionally 1–24 unique text field keys.");
180
+ }
181
+ params.set("search", JSON.stringify({ query: search.query.trim(), ...(search.fields !== undefined ? { fields: search.fields } : {}) }));
182
+ }
130
183
  if (options.sort !== undefined) {
131
- if (!record(options.sort) || Object.keys(options.sort).sort().join(",") !== "direction,field" || !identifier.test(String(options.sort.field)) || !["asc", "desc"].includes(String(options.sort.direction)))
132
- throw new TypeError("sort requires a scalar field and asc/desc direction.");
184
+ const orders = Array.isArray(options.sort) ? options.sort : [options.sort];
185
+ if (orders.length < 1 || orders.length > 3 || !Array.from(orders).every(order => record(order) && Object.keys(order).sort().join(",") === "direction,field" &&
186
+ typeof order.field === "string" && identifier.test(order.field) && ["asc", "desc"].includes(String(order.direction))) ||
187
+ new Set(orders.map(order => order.field)).size !== orders.length)
188
+ throw new TypeError("sort requires 1–3 unique scalar fields and asc/desc directions.");
133
189
  params.set("sort", JSON.stringify(options.sort));
134
190
  }
135
191
  return params;
@@ -137,14 +193,14 @@ function query(options) {
137
193
  // Network responses start as unknown even in TypeScript. Check structural
138
194
  // properties before returning them, and translate HTTP snake_case metadata into
139
195
  // SDK camelCase. This does not validate the caller-declared model field types.
140
- function decodePage(value) {
196
+ function decodePage(value, projectId, model, locale) {
141
197
  if (!record(value) || !Array.isArray(value.data) || !record(value.meta) ||
142
198
  typeof value.meta.limit !== "number" || !Number.isInteger(value.meta.limit) ||
143
199
  value.meta.limit < 1 || value.meta.limit > 100 || value.data.length > value.meta.limit ||
144
200
  !(value.meta.next_cursor === null || (typeof value.meta.next_cursor === "string" && cursor(value.meta.next_cursor)))) {
145
201
  throw new CMSResponseError("CMS returned an invalid content page.");
146
202
  }
147
- const data = value.data.map(decodeEntry);
203
+ const data = value.data.map(entry => decodeEntry(entry, projectId, model, locale));
148
204
  const meta = { limit: value.meta.limit, nextCursor: value.meta.next_cursor };
149
205
  if (value.meta.page !== undefined || value.meta.page_size !== undefined) {
150
206
  if (typeof value.meta.page !== "number" || !Number.isInteger(value.meta.page) || value.meta.page < 1 || value.meta.page_size !== value.meta.limit || (value.meta.page - 1) * value.meta.limit > 10000)
@@ -172,11 +228,44 @@ function decodePage(value) {
172
228
  }
173
229
  return { data, meta };
174
230
  }
175
- function decodeEntry(entry) {
231
+ function decodeEntry(entry, projectId, model, locale) {
176
232
  if (!record(entry) || typeof entry.id !== "string" || !uuid.test(entry.id) || !record(entry.fields) ||
177
233
  !(entry.published_at === null || typeof entry.published_at === "string"))
178
234
  throw new CMSResponseError("CMS returned an invalid content entry.");
179
- const result = { id: entry.id, fields: entry.fields, publishedAt: entry.published_at };
235
+ const result = {
236
+ id: entry.id, fields: entry.fields, publishedAt: entry.published_at,
237
+ projectId, contentType: { key: model },
238
+ };
239
+ // Old servers omit this metadata. Retain only known request identity until
240
+ // the server supplies the rest; never infer an environment from a credential.
241
+ if (entry.project_id !== undefined &&
242
+ (typeof entry.project_id !== "string" || entry.project_id.toLowerCase() !== projectId.toLowerCase())) {
243
+ throw new CMSResponseError("CMS returned conflicting workspace metadata.");
244
+ }
245
+ if (entry.content_type !== undefined) {
246
+ const type = entry.content_type;
247
+ if (!record(type) || typeof type.id !== "string" || !uuid.test(type.id) ||
248
+ type.key !== model || typeof type.name !== "string" || !type.name.trim()) {
249
+ throw new CMSResponseError("CMS returned invalid content-type metadata.");
250
+ }
251
+ result.contentType = { id: type.id, key: model, name: type.name };
252
+ }
253
+ if (entry.environment !== undefined) {
254
+ const environment = entry.environment;
255
+ if (!record(environment) || typeof environment.id !== "string" || !uuid.test(environment.id) ||
256
+ typeof environment.key !== "string" || !/^[a-z][a-z0-9_-]{0,31}$/.test(environment.key)) {
257
+ throw new CMSResponseError("CMS returned invalid environment metadata.");
258
+ }
259
+ result.environment = { id: environment.id, key: environment.key };
260
+ }
261
+ if (entry.locale !== undefined) {
262
+ if (typeof entry.locale !== "string" || !localeTag.test(entry.locale) || locale !== undefined && entry.locale !== locale) {
263
+ throw new CMSResponseError("CMS returned invalid locale metadata.");
264
+ }
265
+ result.locale = entry.locale;
266
+ }
267
+ else if (locale !== undefined)
268
+ result.locale = locale;
180
269
  if (entry.remote !== undefined) {
181
270
  const remote = entry.remote;
182
271
  if (remote === null)
@@ -237,7 +326,8 @@ export function createTransport(options, key, mode) {
237
326
  if (typeof fetcher !== "function") {
238
327
  throw new CMSConfigurationError("This environment needs fetch; supply a compatible fetch implementation.");
239
328
  }
240
- const endpoint = `${serviceURL}/api/v1/projects/${options.projectId}/${mode}`;
329
+ const projectId = options.projectId;
330
+ const endpoint = `${serviceURL}/api/v1/projects/${projectId}/${mode}`;
241
331
  return {
242
332
  content(model) {
243
333
  if (typeof model !== "string" || !identifier.test(model)) {
@@ -247,6 +337,54 @@ export function createTransport(options, key, mode) {
247
337
  async list(options = {}) {
248
338
  return await request(options);
249
339
  },
340
+ async search(text, options = {}) {
341
+ if (!record(options) || "search" in options)
342
+ throw new TypeError("search accepts a query string and list options with optional fields.");
343
+ const { fields, ...list } = options;
344
+ return await request({ ...list, search: { query: text, ...(fields !== undefined ? { fields } : {}) } });
345
+ },
346
+ async findOne(options = {}) {
347
+ return await firstMatch(options);
348
+ },
349
+ async findOneOrThrow(options = {}) {
350
+ const entry = await firstMatch(options);
351
+ if (entry === null)
352
+ throw new CMSNotFoundError(model);
353
+ return entry;
354
+ },
355
+ async count(options = {}) {
356
+ return await statistic("count", options);
357
+ },
358
+ async exists(options = {}) {
359
+ return await statistic("exists", options);
360
+ },
361
+ async getMany(ids, options = {}) {
362
+ readOptions(options, ["select", "locale", "signal", "includeRemote"], "getMany");
363
+ if (!Array.isArray(ids) || ids.length > 100 || !Array.from(ids).every(id => typeof id === "string" && uuid.test(id))) {
364
+ throw new TypeError("getMany ids must contain at most 100 entry UUIDs.");
365
+ }
366
+ query(options);
367
+ options.signal?.throwIfAborted();
368
+ const ordered = ids.map(id => id.toLowerCase());
369
+ if (ordered.length === 0)
370
+ return [];
371
+ const requested = new Set(ordered);
372
+ const requestedLocale = options.locale;
373
+ return await send(options, "/_many", body => {
374
+ if (!record(body) || !Array.isArray(body.data) || body.data.length > requested.size) {
375
+ throw new CMSResponseError("CMS returned an invalid bulk response.");
376
+ }
377
+ const entries = new Map();
378
+ for (const value of body.data) {
379
+ const entry = decodeEntry(value, projectId, model, requestedLocale);
380
+ const id = entry.id.toLowerCase();
381
+ if (!requested.has(id) || entries.has(id))
382
+ throw new CMSResponseError("CMS returned unexpected or duplicate bulk entries.");
383
+ entries.set(id, entry);
384
+ }
385
+ return ordered.map(id => entries.get(id) ?? null);
386
+ }, [...requested]);
387
+ },
250
388
  pages(options = {}) {
251
389
  return iteratePages(options);
252
390
  },
@@ -267,6 +405,25 @@ export function createTransport(options, key, mode) {
267
405
  return await request(options, id);
268
406
  },
269
407
  };
408
+ async function firstMatch(options) {
409
+ readOptions(options, ["select", "locale", "filters", "sort", "search", "signal", "includeRemote"], "findOne/findOneOrThrow");
410
+ const page = await request({ ...options, limit: 1 });
411
+ if (page.meta.limit !== 1)
412
+ throw new CMSResponseError("CMS returned an invalid single-match page.");
413
+ return (page.data[0] ?? null);
414
+ }
415
+ async function statistic(operation, options) {
416
+ readOptions(options, ["locale", "filters", "search", "signal"], operation);
417
+ return await send(options, `/_${operation}`, body => {
418
+ if (!record(body) || !record(body.data))
419
+ throw new CMSResponseError("CMS returned an invalid statistic.");
420
+ const value = body.data[operation];
421
+ if (operation === "count" ? typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 : typeof value !== "boolean") {
422
+ throw new CMSResponseError("CMS returned an invalid statistic.");
423
+ }
424
+ return value;
425
+ });
426
+ }
270
427
  // No eager fetch or prefetch: the iterator asks for a page only after the
271
428
  // previous yield is consumed. A break/error closes it without more requests.
272
429
  async function* iteratePages(options = {}) {
@@ -298,7 +455,23 @@ export function createTransport(options, key, mode) {
298
455
  }
299
456
  }
300
457
  async function request(options, id) {
458
+ const requestedLocale = options.locale;
459
+ return await send(options, id ? `/${id}` : "", body => {
460
+ if (id) {
461
+ if (!record(body))
462
+ throw new CMSResponseError("CMS returned an invalid content item.");
463
+ const entry = decodeEntry(body.data, projectId, model, requestedLocale);
464
+ if (entry.id.toLowerCase() !== id.toLowerCase())
465
+ throw new CMSResponseError("CMS returned a different entry than requested.");
466
+ return entry;
467
+ }
468
+ return decodePage(body, projectId, model, requestedLocale);
469
+ });
470
+ }
471
+ async function send(options, path, decode, ids) {
301
472
  const params = query(options);
473
+ if (ids)
474
+ params.set("ids", ids.join(","));
302
475
  const suffix = params.size ? `?${params}` : "";
303
476
  return withinDeadline(timeoutMs, options.signal, async (signal) => {
304
477
  const checkCancellation = () => {
@@ -309,7 +482,7 @@ export function createTransport(options, key, mode) {
309
482
  // Use only bearer authorization: omit browser cookies, refuse redirects that
310
483
  // could change the destination, and request fresh data. Abort and network errors
311
484
  // propagate to the caller; this client does not silently retry a request.
312
- const response = await fetcher(`${endpoint}/${model}${id ? `/${id}` : ""}${suffix}`, {
485
+ const response = await fetcher(`${endpoint}/${model}${path}${suffix}`, {
313
486
  method: "GET",
314
487
  headers: { Accept: "application/json", Authorization: `Bearer ${key}` },
315
488
  credentials: "omit",
@@ -338,12 +511,7 @@ export function createTransport(options, key, mode) {
338
511
  }
339
512
  // Validate the envelope at runtime. Model field types are the caller's
340
513
  // declaration until schema-generated types/runtime validators exist.
341
- if (id) {
342
- if (!record(body))
343
- throw new CMSResponseError("CMS returned an invalid content item.");
344
- return decodeEntry(body.data);
345
- }
346
- return decodePage(body);
514
+ return decode(body);
347
515
  });
348
516
  }
349
517
  },
package/dist/types.d.ts CHANGED
@@ -28,11 +28,11 @@ export type ScalarField<Fields> = {
28
28
  type ScalarContentFilter<Fields> = {
29
29
  [Key in ScalarField<Fields>]: {
30
30
  field: Key;
31
- op: "eq";
31
+ op: "eq" | "ne";
32
32
  value: Extract<Fields[Key], Scalar> | (unknown extends Fields[Key] ? Scalar : never) | null;
33
33
  } | {
34
34
  field: Key;
35
- op: "in";
35
+ op: "in" | "not_in";
36
36
  value: readonly (Extract<Fields[Key], Scalar> | (unknown extends Fields[Key] ? Scalar : never))[];
37
37
  } | (NonNullable<Fields[Key]> extends boolean ? never : {
38
38
  field: Key;
@@ -51,7 +51,28 @@ type ChoicesContentFilter<Fields> = {
51
51
  value: readonly ChoicesValue<Fields[Key]>[];
52
52
  };
53
53
  }[ChoicesField<Fields>];
54
- export type ContentFilter<Fields> = ScalarContentFilter<Fields> | ChoicesContentFilter<Fields>;
54
+ export type TextField<Fields> = {
55
+ [Key in FieldName<Fields>]: unknown extends Fields[Key] ? Key : NonNullable<Fields[Key]> extends string ? Key : never;
56
+ }[FieldName<Fields>];
57
+ type TextContentFilter<Fields> = {
58
+ [Key in TextField<Fields>]: {
59
+ field: Key;
60
+ op: "eqi" | "contains" | "containsi" | "starts_with" | "starts_withi" | "ends_with" | "ends_withi";
61
+ value: string;
62
+ };
63
+ }[TextField<Fields>];
64
+ export type ContentFilter<Fields> = ScalarContentFilter<Fields> | ChoicesContentFilter<Fields> | TextContentFilter<Fields> | {
65
+ and: readonly ContentFilter<Fields>[];
66
+ } | {
67
+ or: readonly ContentFilter<Fields>[];
68
+ } | {
69
+ not: ContentFilter<Fields>;
70
+ };
71
+ export interface ContentSearch<Fields = Record<string, unknown>> {
72
+ query: string;
73
+ /** Top-level text/long_text/markdown fields; the backend validates stored types. */
74
+ fields?: readonly TextField<Fields>[];
75
+ }
55
76
  export interface ContentSort<Fields> {
56
77
  field: ScalarField<Fields>;
57
78
  direction: "asc" | "desc";
@@ -60,9 +81,11 @@ interface BaseListOptions<Key extends string, Fields> {
60
81
  select?: readonly Key[];
61
82
  locale?: string;
62
83
  limit?: number;
63
- /** Up to ten AND clauses, validated against the workspace schema by the server. */
64
- filters?: readonly ContentFilter<Fields>[];
65
- sort?: ContentSort<Fields>;
84
+ /** Ten leaves, depth four, 31 nodes and 4 KiB. Arrays remain implicit AND. */
85
+ filters?: readonly ContentFilter<Fields>[] | ContentFilter<Fields>;
86
+ /** One object or 1–3 unique ordered fields; the UUID breaks remaining ties. */
87
+ sort?: ContentSort<Fields> | readonly ContentSort<Fields>[];
88
+ search?: ContentSearch<Fields>;
66
89
  signal?: AbortSignal;
67
90
  /** Explicit live enrichment, independently cached from the saved publication. */
68
91
  includeRemote?: boolean;
@@ -85,16 +108,44 @@ export type ListOptions<Key extends string = string, Fields = Record<string, unk
85
108
  after?: never;
86
109
  before?: never;
87
110
  });
111
+ /** One first match, with no caller-controlled page size or boundary. */
112
+ export type FindOneOptions<Key extends string = string, Fields = Record<string, unknown>> = Pick<BaseListOptions<Key, Fields>, "select" | "locale" | "filters" | "sort" | "search" | "signal" | "includeRemote">;
113
+ /** Direct statistics have no pagination, projection, sorting or remote enrichment. */
114
+ export type CountOptions<Fields = Record<string, unknown>> = Pick<BaseListOptions<string, Fields>, "locale" | "filters" | "search" | "signal">;
115
+ export type GetManyOptions<Key extends string = string> = Pick<BaseListOptions<Key, Record<string, unknown>>, "select" | "locale" | "signal" | "includeRemote">;
116
+ /** A paginated search; fields scopes matching, select projects the returned fields. */
117
+ export type SearchOptions<Key extends string = string, Fields = Record<string, unknown>> = ListOptions<Key, Fields> & {
118
+ fields?: readonly TextField<Fields>[];
119
+ search?: never;
120
+ };
88
121
  /** Lazy forward traversal, capped at 1,000 pages by default (maximum 10,000). */
89
122
  export type PaginationOptions<Key extends string = string, Fields = Record<string, unknown>> = Omit<ListOptions<Key, Fields>, "before" | "page" | "pageSize"> & {
90
123
  page?: never;
91
124
  pageSize?: never;
92
125
  maxPages?: number;
93
126
  };
94
- export interface ContentEntry<Fields> {
127
+ /** Identity of the selected model; ID/name can be absent on older CMS responses. */
128
+ export interface ContentTypeIdentity<Model extends string = string> {
129
+ key: Model;
130
+ id?: string;
131
+ name?: string;
132
+ }
133
+ export interface ContentEnvironment {
134
+ id: string;
135
+ key: string;
136
+ }
137
+ /** Metadata sits beside modeled fields and is retained by field projections. */
138
+ export interface ContentEntry<Fields, Model extends string = string> {
95
139
  id: string;
96
140
  fields: Fields;
97
141
  publishedAt: string | null;
142
+ /** Optional in this interface to preserve existing manually constructed entries. */
143
+ contentType?: ContentTypeIdentity<Model>;
144
+ projectId?: string;
145
+ /** Resolved request locale; fallback fields can originate in the base language. */
146
+ locale?: string;
147
+ /** Absent when the CMS response predates environment metadata. */
148
+ environment?: ContentEnvironment;
98
149
  remote?: RemoteValue | null;
99
150
  }
100
151
  export interface RemoteValue<Data = Record<string, string | number | boolean | null>> {
@@ -107,8 +158,8 @@ export interface RemoteValue<Data = Record<string, string | number | boolean | n
107
158
  fetchedAt: string | null;
108
159
  expiresAt: string | null;
109
160
  }
110
- export interface ContentPage<Fields> {
111
- data: ContentEntry<Fields>[];
161
+ export interface ContentPage<Fields, Model extends string = string> {
162
+ data: ContentEntry<Fields, Model>[];
112
163
  meta: {
113
164
  nextCursor: string | null;
114
165
  limit: number;
@@ -119,16 +170,28 @@ export interface ContentPage<Fields> {
119
170
  hasPreviousPage?: boolean;
120
171
  };
121
172
  }
122
- export interface ContentCollection<Fields extends object> {
123
- list<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: ListOptions<Key, Fields>): Promise<ContentPage<Pick<Fields, Key>>>;
173
+ export interface ContentCollection<Fields extends object, Model extends string = string> {
174
+ list<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: ListOptions<Key, Fields>): Promise<ContentPage<Pick<Fields, Key>, Model>>;
175
+ /** One page of plain text matches; relevance order unless an explicit sort is supplied. */
176
+ search<const Key extends FieldName<Fields> = FieldName<Fields>>(query: string, options?: SearchOptions<Key, Fields>): Promise<ContentPage<Pick<Fields, Key>, Model>>;
177
+ /** First matching entry or null; all request and response errors still reject. */
178
+ findOne<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: FindOneOptions<Key, Fields>): Promise<ContentEntry<Pick<Fields, Key>, Model> | null>;
179
+ /** Required first match; CMSNotFoundError only for a successful empty query. */
180
+ findOneOrThrow<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: FindOneOptions<Key, Fields>): Promise<ContentEntry<Pick<Fields, Key>, Model>>;
181
+ /** Matching entries across all pages, counted by the backend without downloading fields. */
182
+ count(options?: CountOptions<Fields>): Promise<number>;
183
+ /** Whether any entry matches; backend stops after the first match. */
184
+ exists(options?: CountOptions<Fields>): Promise<boolean>;
185
+ /** At most 100 UUIDs in one request; preserves order/duplicates, missing entries become null. */
186
+ getMany<const Key extends FieldName<Fields> = FieldName<Fields>>(ids: readonly string[], options?: GetManyOptions<Key>): Promise<(ContentEntry<Pick<Fields, Key>, Model> | null)[]>;
124
187
  /** Fetch only as the caller requests another page. Breaking stops network work. */
125
- pages<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: PaginationOptions<Key, Fields>): AsyncIterableIterator<ContentPage<Pick<Fields, Key>>>;
188
+ pages<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: PaginationOptions<Key, Fields>): AsyncIterableIterator<ContentPage<Pick<Fields, Key>, Model>>;
126
189
  /** Flatten pages lazily, retaining at most one page of entries. */
127
- entries<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: PaginationOptions<Key, Fields>): AsyncIterableIterator<ContentEntry<Pick<Fields, Key>>>;
128
- get<const Key extends FieldName<Fields> = FieldName<Fields>>(id: string, options?: Pick<ListOptions<Key, Fields>, "select" | "locale" | "signal" | "includeRemote">): Promise<ContentEntry<Pick<Fields, Key>>>;
190
+ entries<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: PaginationOptions<Key, Fields>): AsyncIterableIterator<ContentEntry<Pick<Fields, Key>, Model>>;
191
+ get<const Key extends FieldName<Fields> = FieldName<Fields>>(id: string, options?: Pick<ListOptions<Key, Fields>, "select" | "locale" | "signal" | "includeRemote">): Promise<ContentEntry<Pick<Fields, Key>, Model>>;
129
192
  }
130
193
  export interface CMSClient<Models extends ModelMap<Models> = UntypedModels> {
131
- content<Model extends Extract<keyof Models, string>>(model: Model): ContentCollection<Models[Model]>;
194
+ content<Model extends Extract<keyof Models, string>>(model: Model): ContentCollection<Models[Model], Model>;
132
195
  }
133
196
  /** A draft may omit required fields or contain nulls until it is published. */
134
197
  export type PreviewModels<Models> = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemonadecms/client",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Framework-independent content delivery and developer tooling for Lemonade CMS",
5
5
  "license": "MIT",
6
6
  "homepage": "https://lemonadecms.com/guides/sdk",