@mapled/next 0.4.0 → 0.5.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/README.md CHANGED
@@ -47,6 +47,18 @@ posts[0].expanded?.author; // { id, data: { name: "Ada" } } or null
47
47
 
48
48
  Every call reads the latest **published release** — drafts never leak. Fetches are tagged `mapled` and `mapled:<collection>` and default to `revalidate: 3600`; the webhook below refreshes them the moment someone publishes.
49
49
 
50
+ ### Pin a release
51
+
52
+ A page that makes several reads can pin them all to one release, so a publish landing mid-render can't mix two versions:
53
+
54
+ ```ts
55
+ const live = await mapled.getRelease(); // { release: 12, publishedAt } — null before the first publish
56
+ const { records } = await mapled.getRecords("articles", { release: live!.release });
57
+ const about = await mapled.getRecordBySlug("pages", "about", { release: live!.release });
58
+ ```
59
+
60
+ `createClient({ key, release: 12 })` pins every read — a way to hold a site on a known-good release without publishing anything. A release never changes, so pinned reads are cached until the next publish (`revalidate: false` unless you pass your own); a release that doesn't exist fails with a 404 `RELEASE_NOT_FOUND` rather than reading as a missing record. In preview (draft mode) the pin is dropped: drafts are unversioned.
61
+
50
62
  ## Rich text
51
63
 
52
64
  `rich_text` values are Markdown (headings, lists, links, bold/italic, images as `![alt](url)`). Render them with a Markdown component such as `react-markdown` — never inject them as raw HTML.
@@ -119,9 +131,9 @@ The server key reaches only operational collections with access class `public`/`
119
131
 
120
132
  ## API
121
133
 
122
- - `createClient({ key, apiUrl? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`
134
+ - `createClient({ key, apiUrl?, release? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`, `getRelease()`
123
135
  - list `opts`: `filter` (a value for equality, or `{ eq, ne, lt, lte, gt, gte, in, contains }` — text takes eq/ne/in/contains, numbers and dates comparisons, booleans eq/ne, image and relation ids eq/ne/in; a one-to-many relation matches when it includes the id), `sort` (`"field"` / `"-field"`, not on relations or images), `limit` (≤100), `offset`
124
- - every read: `expand` (relation keys, dotted for a second level), `fields` (data keys to keep), `revalidate`, `tags`; a query the schema can't answer fails with a 400 `INVALID_QUERY` message
136
+ - every read: `expand` (relation keys, dotted for a second level), `fields` (data keys to keep), `release` (pin to a published release), `revalidate`, `tags`; a query the schema can't answer fails with a 400 `INVALID_QUERY` message, and errors carry the API's `code` on `MapledError`
125
137
  - `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
126
138
  - `verifySignature(secret, body, signature)` — if you'd rather build your own handler
127
139
 
package/dist/index.d.ts CHANGED
@@ -31,6 +31,12 @@ export type ReadOptions = {
31
31
  revalidate?: number | false;
32
32
  /** Extra cache tags on top of "mapled" and "mapled:<collection>". */
33
33
  tags?: string[];
34
+ /** Read this release instead of the latest one: pin a render to the
35
+ number its first read returned so a publish landing mid-render can't
36
+ mix two versions, or hold a site on a known-good release. A release
37
+ never changes, so pinned reads stay cached until the next publish
38
+ (`revalidate: false` unless you say otherwise). */
39
+ release?: number;
34
40
  };
35
41
  export type ListOptions = ReadOptions & {
36
42
  /** Filters on top-level fields, ANDed: { slug: "about", price: { gte: 10 } }. */
@@ -63,7 +69,9 @@ export type ImageOptions = {
63
69
  export declare function assetUrl(id: string, opts?: ImageOptions): string;
64
70
  export declare class MapledError extends Error {
65
71
  status: number;
66
- constructor(status: number, message: string);
72
+ /** The API's error code — "NOT_FOUND", "INVALID_QUERY", "RELEASE_NOT_FOUND", … */
73
+ code: string | undefined;
74
+ constructor(status: number, message: string, code?: string);
67
75
  }
68
76
  export type MapledClient = ReturnType<typeof createClient>;
69
77
  export type MapledAppClient = ReturnType<typeof createAppClient>;
@@ -82,6 +90,7 @@ export type AppListOptions = {
82
90
  export declare function createClient(config: {
83
91
  key: string;
84
92
  apiUrl?: string;
93
+ release?: number;
85
94
  }): {
86
95
  /** Published records of a collection, with filters/sort/pagination. */
87
96
  getRecords<T = Record<string, unknown>>(collection: string, opts?: ListOptions): Promise<ListResult<T>>;
@@ -91,6 +100,14 @@ export declare function createClient(config: {
91
100
  getRecordBySlug<T = Record<string, unknown>>(collection: string, slug: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
92
101
  /** The record of a single (e.g. "homepage"), or null before publish. */
93
102
  getSingle<T = Record<string, unknown>>(key: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
103
+ /** The release the site is reading right now — the latest published
104
+ one, with the moment it went live — or null before the first
105
+ publish. Never cached: it's the one short-lived pointer of the
106
+ delivery API; pass its number as `release` to pin a render. */
107
+ getRelease(): Promise<{
108
+ release: number;
109
+ publishedAt: string;
110
+ } | null>;
94
111
  };
95
112
  /** Live data (operational collections): the site's backend reads and
96
113
  writes records that take effect immediately — no publish involved.
package/dist/index.js CHANGED
@@ -21,12 +21,17 @@ export function assetUrl(id, opts = {}) {
21
21
  }
22
22
  export class MapledError extends Error {
23
23
  status;
24
- constructor(status, message) {
24
+ /** The API's error code — "NOT_FOUND", "INVALID_QUERY", "RELEASE_NOT_FOUND", … */
25
+ code;
26
+ constructor(status, message, code) {
25
27
  super(message);
26
28
  this.name = "MapledError";
27
29
  this.status = status;
30
+ this.code = code;
28
31
  }
29
32
  }
33
+ const isRelease = (n) => Number.isInteger(n) && n >= 1;
34
+ const badRelease = () => new Error("Mapled: release must be a positive whole number.");
30
35
  /** The draft session cookie set by the preview route. When Next draft
31
36
  mode is on, every read switches to live drafts, uncached. Outside a
32
37
  Next request context this quietly resolves to null. */
@@ -46,9 +51,33 @@ export function createClient(config) {
46
51
  const base = (config.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
47
52
  if (!config.key)
48
53
  throw new Error("Mapled: a delivery key is required.");
54
+ if (config.release !== undefined && !isRelease(config.release))
55
+ throw badRelease();
56
+ /** The API's error envelope as a MapledError. */
57
+ async function failure(res) {
58
+ let message = `Mapled request failed (${res.status}).`;
59
+ let code;
60
+ try {
61
+ const parsed = (await res.json());
62
+ if (parsed.error?.message)
63
+ message = parsed.error.message;
64
+ if (typeof parsed.error?.code === "string")
65
+ code = parsed.error.code;
66
+ }
67
+ catch {
68
+ /* non-JSON error body */
69
+ }
70
+ return new MapledError(res.status, message, code);
71
+ }
49
72
  async function call(path, search, opts, cacheTags) {
50
- const qs = search.toString();
51
73
  const draft = await draftSession();
74
+ const release = opts.release ?? config.release;
75
+ if (release !== undefined && !isRelease(release))
76
+ throw badRelease();
77
+ // drafts are unversioned: a pin only shapes published reads
78
+ if (release !== undefined && !draft)
79
+ search.set("release", String(release));
80
+ const qs = search.toString();
52
81
  const init = draft
53
82
  ? {
54
83
  headers: { "x-mapled-key": config.key, "x-mapled-draft": draft },
@@ -57,23 +86,15 @@ export function createClient(config) {
57
86
  : {
58
87
  headers: { "x-mapled-key": config.key },
59
88
  next: {
60
- revalidate: opts.revalidate ?? 3600,
89
+ // a release never changes: a pinned read stays cached until
90
+ // the publish webhook revalidates the tags
91
+ revalidate: opts.revalidate ?? (release !== undefined ? false : 3600),
61
92
  tags: ["mapled", ...cacheTags, ...(opts.tags ?? [])],
62
93
  },
63
94
  };
64
95
  const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, init);
65
- if (!res.ok) {
66
- let message = `Mapled request failed (${res.status}).`;
67
- try {
68
- const parsed = (await res.json());
69
- if (parsed.error?.message)
70
- message = parsed.error.message;
71
- }
72
- catch {
73
- /* non-JSON error body */
74
- }
75
- throw new MapledError(res.status, message);
76
- }
96
+ if (!res.ok)
97
+ throw await failure(res);
77
98
  return { status: res.status, body: (await res.json()) };
78
99
  }
79
100
  /** expand / fields on every read. */
@@ -90,8 +111,10 @@ export function createClient(config) {
90
111
  return body.record;
91
112
  }
92
113
  catch (err) {
93
- if (err instanceof MapledError && err.status === 404)
114
+ // a missing record is null; a missing release is a configuration error
115
+ if (err instanceof MapledError && err.status === 404 && err.code !== "RELEASE_NOT_FOUND") {
94
116
  return null;
117
+ }
95
118
  throw err;
96
119
  }
97
120
  };
@@ -133,6 +156,22 @@ export function createClient(config) {
133
156
  getSingle(key, opts = {}) {
134
157
  return one(`/v1/delivery/singles/${encodeURIComponent(key)}`, opts, `mapled:${key}`);
135
158
  },
159
+ /** The release the site is reading right now — the latest published
160
+ one, with the moment it went live — or null before the first
161
+ publish. Never cached: it's the one short-lived pointer of the
162
+ delivery API; pass its number as `release` to pin a render. */
163
+ async getRelease() {
164
+ const res = await fetch(`${base}/v1/delivery/release`, {
165
+ headers: { "x-mapled-key": config.key },
166
+ cache: "no-store",
167
+ });
168
+ if (!res.ok)
169
+ throw await failure(res);
170
+ const body = (await res.json());
171
+ if (body.release === null || body.publishedAt === null)
172
+ return null;
173
+ return { release: body.release, publishedAt: body.publishedAt };
174
+ },
136
175
  };
137
176
  }
138
177
  /** Live data (operational collections): the site's backend reads and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mapled/next",
3
- "version": "0.4.0",
4
- "description": "Read published Mapled content in a Next.js site — delivery client, ISR tags, and a revalidation webhook handler.",
3
+ "version": "0.5.0",
4
+ "description": "Read published Mapled content in a Next.js site \u2014 delivery client, ISR tags, and a revalidation webhook handler.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mapled.io",
7
7
  "keywords": [
@@ -47,6 +47,6 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "typescript": "^5.7.2",
50
- "vitest": "^3.0.5"
50
+ "vitest": "^4.1.11"
51
51
  }
52
52
  }