@mapled/next 0.5.0 → 0.7.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 +27 -1
- package/dist/index.d.ts +66 -32
- package/dist/index.js +39 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,32 @@ 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
|
+
### Typed by your schema
|
|
51
|
+
|
|
52
|
+
`npx @mapled/cli types generate` writes `mapled-types.ts`: one interface per collection and single, and a `MapledSchema` that ties them together. Hand it to the client once and every read is typed by its key:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import type { MapledSchema } from "./mapled-types";
|
|
56
|
+
|
|
57
|
+
const mapled = createClient<MapledSchema>({ key: process.env.MAPLED_KEY! });
|
|
58
|
+
|
|
59
|
+
const { records } = await mapled.getRecords("articles"); // records[0].data is an Article
|
|
60
|
+
const home = await mapled.getSingle("homepage"); // Homepage | null
|
|
61
|
+
await mapled.getRecords("artcles"); // type error — no such collection
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Without a schema the client behaves as before: any key, `Record<string, unknown>` data, or the type you name per call (`getRecords<Article>("articles")`). `createAppClient<MapledSchema>` types live data the same way — `db.create("orders", data)` wants an `Order`. `mapled doctor` tells you when the generated file is behind the schema.
|
|
65
|
+
|
|
66
|
+
### Renamed fields
|
|
67
|
+
|
|
68
|
+
When a field's key is renamed in Mapled, nothing breaks: the old key stays as an alias — delivered next to the new one, accepted in `filter`, `sort`, `fields`, `expand` and in live-data writes — until someone removes it in Field settings. Answers that still serve a previous key name it (`aliases: { products: { price: "amount" } }` on a list result), and outside production the client says so once per key:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
[mapled] “price” in “products” was renamed to “amount”. The old key keeps working until its alias is removed in Mapled — use “amount” instead.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Move the code to the new key, run `mapled types generate` (the old key is typed `@deprecated` until then), and remove the alias. In production the client stays silent.
|
|
75
|
+
|
|
50
76
|
### Pin a release
|
|
51
77
|
|
|
52
78
|
A page that makes several reads can pin them all to one release, so a publish landing mid-render can't mix two versions:
|
|
@@ -131,7 +157,7 @@ The server key reaches only operational collections with access class `public`/`
|
|
|
131
157
|
|
|
132
158
|
## API
|
|
133
159
|
|
|
134
|
-
- `createClient({ key, apiUrl?, release? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`, `getRelease()`
|
|
160
|
+
- `createClient({ key, apiUrl?, release? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`, `getRelease()`; `createClient<MapledSchema>(…)` with the generated `MapledSchema` types every read by its key (`MapledClient<S>`, `MapledAppClient<S>` name the typed clients)
|
|
135
161
|
- 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`
|
|
136
162
|
- 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`
|
|
137
163
|
- `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
|
package/dist/index.d.ts
CHANGED
|
@@ -51,6 +51,10 @@ export type ListResult<T = Record<string, unknown>> = {
|
|
|
51
51
|
records: MapledRecord<T>[];
|
|
52
52
|
total: number;
|
|
53
53
|
release: number;
|
|
54
|
+
/** Renamed fields whose previous keys this answer still serves, by
|
|
55
|
+
collection: `{ products: { price: "amount" } }`. Absent when nothing
|
|
56
|
+
was renamed. Outside production the SDK warns about each once. */
|
|
57
|
+
aliases?: Record<string, Record<string, string>>;
|
|
54
58
|
};
|
|
55
59
|
export type ImageOptions = {
|
|
56
60
|
/** Snapped up to the nearest preset (64 … 2560); never upscaled past the source. */
|
|
@@ -73,8 +77,63 @@ export declare class MapledError extends Error {
|
|
|
73
77
|
code: string | undefined;
|
|
74
78
|
constructor(status: number, message: string, code?: string);
|
|
75
79
|
}
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
/** The shape of the `MapledSchema` that `mapled types generate` writes:
|
|
81
|
+
collections and singles by key, each the type of a record's data.
|
|
82
|
+
Pass it as `createClient<MapledSchema>(…)` and every read is typed by
|
|
83
|
+
its key; without it the client accepts any key and answers
|
|
84
|
+
`Record<string, unknown>` (or the type you name per call). */
|
|
85
|
+
export type ContentSchema = {
|
|
86
|
+
collections?: object;
|
|
87
|
+
singles?: object;
|
|
88
|
+
};
|
|
89
|
+
type Data = Record<string, unknown>;
|
|
90
|
+
type AnyMap = Record<string, Data>;
|
|
91
|
+
type CollectionsOf<S extends ContentSchema> = S["collections"] extends object ? S["collections"] : AnyMap;
|
|
92
|
+
type SinglesOf<S extends ContentSchema> = S["singles"] extends object ? S["singles"] : AnyMap;
|
|
93
|
+
/** Any key on an untyped client; on a typed one, a key it knows — so an
|
|
94
|
+
explicit `getRecords<Article>("articles")` keeps working either way. */
|
|
95
|
+
type LooseKey<S extends ContentSchema, M> = ContentSchema extends S ? string : keyof M & string;
|
|
96
|
+
/** Writes on a typed client take the collection's own type, never a guess. */
|
|
97
|
+
type LooseData<S extends ContentSchema, T> = ContentSchema extends S ? T : never;
|
|
98
|
+
export interface MapledClient<S extends ContentSchema = ContentSchema> {
|
|
99
|
+
/** Published records of a collection, with filters/sort/pagination. */
|
|
100
|
+
getRecords<K extends keyof CollectionsOf<S> & string>(collection: K, opts?: ListOptions): Promise<ListResult<CollectionsOf<S>[K]>>;
|
|
101
|
+
getRecords<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, opts?: ListOptions): Promise<ListResult<T>>;
|
|
102
|
+
/** One published record by id, or null when it isn't in the release. */
|
|
103
|
+
getRecord<K extends keyof CollectionsOf<S> & string>(collection: K, id: string, opts?: ReadOptions): Promise<MapledRecord<CollectionsOf<S>[K]> | null>;
|
|
104
|
+
getRecord<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, id: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
105
|
+
/** One published record by the collection's slug field, or null. */
|
|
106
|
+
getRecordBySlug<K extends keyof CollectionsOf<S> & string>(collection: K, slug: string, opts?: ReadOptions): Promise<MapledRecord<CollectionsOf<S>[K]> | null>;
|
|
107
|
+
getRecordBySlug<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, slug: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
108
|
+
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
109
|
+
getSingle<K extends keyof SinglesOf<S> & string>(key: K, opts?: ReadOptions): Promise<MapledRecord<SinglesOf<S>[K]> | null>;
|
|
110
|
+
getSingle<T = Data>(key: LooseKey<S, SinglesOf<S>>, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
111
|
+
/** The release the site is reading right now — the latest published
|
|
112
|
+
one, with the moment it went live — or null before the first
|
|
113
|
+
publish. Never cached: it's the one short-lived pointer of the
|
|
114
|
+
delivery API; pass its number as `release` to pin a render. */
|
|
115
|
+
getRelease(): Promise<{
|
|
116
|
+
release: number;
|
|
117
|
+
publishedAt: string;
|
|
118
|
+
} | null>;
|
|
119
|
+
}
|
|
120
|
+
export interface MapledAppClient<S extends ContentSchema = ContentSchema> {
|
|
121
|
+
list<K extends keyof CollectionsOf<S> & string>(collection: K, opts?: AppListOptions): Promise<{
|
|
122
|
+
records: AppRecord<CollectionsOf<S>[K]>[];
|
|
123
|
+
total: number;
|
|
124
|
+
}>;
|
|
125
|
+
list<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, opts?: AppListOptions): Promise<{
|
|
126
|
+
records: AppRecord<T>[];
|
|
127
|
+
total: number;
|
|
128
|
+
}>;
|
|
129
|
+
get<K extends keyof CollectionsOf<S> & string>(collection: K, id: string): Promise<AppRecord<CollectionsOf<S>[K]> | null>;
|
|
130
|
+
get<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, id: string): Promise<AppRecord<T> | null>;
|
|
131
|
+
create<K extends keyof CollectionsOf<S> & string>(collection: K, data: CollectionsOf<S>[K]): Promise<AppRecord<CollectionsOf<S>[K]>>;
|
|
132
|
+
create<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, data: LooseData<S, T>): Promise<AppRecord<T>>;
|
|
133
|
+
update<K extends keyof CollectionsOf<S> & string>(collection: K, id: string, data: CollectionsOf<S>[K]): Promise<AppRecord<CollectionsOf<S>[K]>>;
|
|
134
|
+
update<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, id: string, data: LooseData<S, T>): Promise<AppRecord<T>>;
|
|
135
|
+
remove(collection: LooseKey<S, CollectionsOf<S>>, id: string): Promise<void>;
|
|
136
|
+
}
|
|
78
137
|
export type AppRecord<T = Record<string, unknown>> = {
|
|
79
138
|
id: string;
|
|
80
139
|
data: T;
|
|
@@ -87,41 +146,16 @@ export type AppListOptions = {
|
|
|
87
146
|
limit?: number;
|
|
88
147
|
offset?: number;
|
|
89
148
|
};
|
|
90
|
-
export declare function createClient(config: {
|
|
149
|
+
export declare function createClient<S extends ContentSchema = ContentSchema>(config: {
|
|
91
150
|
key: string;
|
|
92
151
|
apiUrl?: string;
|
|
93
152
|
release?: number;
|
|
94
|
-
}):
|
|
95
|
-
/** Published records of a collection, with filters/sort/pagination. */
|
|
96
|
-
getRecords<T = Record<string, unknown>>(collection: string, opts?: ListOptions): Promise<ListResult<T>>;
|
|
97
|
-
/** One published record by id, or null when it isn't in the release. */
|
|
98
|
-
getRecord<T = Record<string, unknown>>(collection: string, id: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
99
|
-
/** One published record by the collection's slug field, or null. */
|
|
100
|
-
getRecordBySlug<T = Record<string, unknown>>(collection: string, slug: string, opts?: ReadOptions): Promise<MapledRecord<T> | null>;
|
|
101
|
-
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
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>;
|
|
111
|
-
};
|
|
153
|
+
}): MapledClient<S>;
|
|
112
154
|
/** Live data (operational collections): the site's backend reads and
|
|
113
155
|
writes records that take effect immediately — no publish involved.
|
|
114
156
|
Server-side only: the server key must never reach the browser. */
|
|
115
|
-
export declare function createAppClient(config: {
|
|
157
|
+
export declare function createAppClient<S extends ContentSchema = ContentSchema>(config: {
|
|
116
158
|
serverKey: string;
|
|
117
159
|
apiUrl?: string;
|
|
118
|
-
}):
|
|
119
|
-
|
|
120
|
-
records: AppRecord<T>[];
|
|
121
|
-
total: number;
|
|
122
|
-
}>;
|
|
123
|
-
get<T = Record<string, unknown>>(collection: string, id: string): Promise<AppRecord<T> | null>;
|
|
124
|
-
create<T = Record<string, unknown>>(collection: string, data: T): Promise<AppRecord<T>>;
|
|
125
|
-
update<T = Record<string, unknown>>(collection: string, id: string, data: T): Promise<AppRecord<T>>;
|
|
126
|
-
remove(collection: string, id: string): Promise<void>;
|
|
127
|
-
};
|
|
160
|
+
}): MapledAppClient<S>;
|
|
161
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
/** Mapled delivery client for Next.js sites.
|
|
2
2
|
Reads published content only — pair it with the revalidation webhook
|
|
3
3
|
handler from "@mapled/next/server" for instant updates on publish. */
|
|
4
|
+
/** Renamed fields (previous keys): Mapled keeps serving a renamed field
|
|
5
|
+
under its old key — and accepting it in filters, sorts and writes —
|
|
6
|
+
until the alias is removed in Field settings, and names such keys in
|
|
7
|
+
its answers. Outside production each is reported once per process, so
|
|
8
|
+
the site moves to the new key before the alias goes. */
|
|
9
|
+
const KEY_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
10
|
+
const reported = new Set();
|
|
11
|
+
function reportAliases(body) {
|
|
12
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "production")
|
|
13
|
+
return;
|
|
14
|
+
const aliases = body?.aliases;
|
|
15
|
+
if (!aliases || typeof aliases !== "object")
|
|
16
|
+
return;
|
|
17
|
+
for (const [collection, keys] of Object.entries(aliases)) {
|
|
18
|
+
if (!keys || typeof keys !== "object" || !KEY_RE.test(collection))
|
|
19
|
+
continue;
|
|
20
|
+
for (const [previous, current] of Object.entries(keys)) {
|
|
21
|
+
// keys are the project's content: only what looks like a key is printed
|
|
22
|
+
if (typeof current !== "string" || !KEY_RE.test(previous) || !KEY_RE.test(current))
|
|
23
|
+
continue;
|
|
24
|
+
const id = `${collection}.${previous}`;
|
|
25
|
+
if (reported.has(id))
|
|
26
|
+
continue;
|
|
27
|
+
reported.add(id);
|
|
28
|
+
console.warn(`[mapled] “${previous}” in “${collection}” was renamed to “${current}”. The old key keeps working until its alias is removed in Mapled — use “${current}” instead.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
4
32
|
/** URL of an image or file value (an asset id) — the original, or a
|
|
5
33
|
resized variant made on first request and cached from then on. */
|
|
6
34
|
export function assetUrl(id, opts = {}) {
|
|
@@ -95,7 +123,9 @@ export function createClient(config) {
|
|
|
95
123
|
const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, init);
|
|
96
124
|
if (!res.ok)
|
|
97
125
|
throw await failure(res);
|
|
98
|
-
|
|
126
|
+
const body = (await res.json());
|
|
127
|
+
reportAliases(body);
|
|
128
|
+
return { status: res.status, body };
|
|
99
129
|
}
|
|
100
130
|
/** expand / fields on every read. */
|
|
101
131
|
const readParams = (search, opts) => {
|
|
@@ -118,8 +148,7 @@ export function createClient(config) {
|
|
|
118
148
|
throw err;
|
|
119
149
|
}
|
|
120
150
|
};
|
|
121
|
-
|
|
122
|
-
/** Published records of a collection, with filters/sort/pagination. */
|
|
151
|
+
const client = {
|
|
123
152
|
async getRecords(collection, opts = {}) {
|
|
124
153
|
const search = new URLSearchParams();
|
|
125
154
|
for (const [k, v] of Object.entries(opts.filter ?? {})) {
|
|
@@ -144,22 +173,15 @@ export function createClient(config) {
|
|
|
144
173
|
const { body } = await call(`/v1/delivery/collections/${encodeURIComponent(collection)}/records`, search, opts, [`mapled:${collection}`]);
|
|
145
174
|
return body;
|
|
146
175
|
},
|
|
147
|
-
/** One published record by id, or null when it isn't in the release. */
|
|
148
176
|
getRecord(collection, id, opts = {}) {
|
|
149
177
|
return one(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`, opts, `mapled:${collection}`);
|
|
150
178
|
},
|
|
151
|
-
/** One published record by the collection's slug field, or null. */
|
|
152
179
|
getRecordBySlug(collection, slug, opts = {}) {
|
|
153
180
|
return one(`/v1/delivery/collections/${encodeURIComponent(collection)}/records/by-slug/${encodeURIComponent(slug)}`, opts, `mapled:${collection}`);
|
|
154
181
|
},
|
|
155
|
-
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
156
182
|
getSingle(key, opts = {}) {
|
|
157
183
|
return one(`/v1/delivery/singles/${encodeURIComponent(key)}`, opts, `mapled:${key}`);
|
|
158
184
|
},
|
|
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
185
|
async getRelease() {
|
|
164
186
|
const res = await fetch(`${base}/v1/delivery/release`, {
|
|
165
187
|
headers: { "x-mapled-key": config.key },
|
|
@@ -173,6 +195,8 @@ export function createClient(config) {
|
|
|
173
195
|
return { release: body.release, publishedAt: body.publishedAt };
|
|
174
196
|
},
|
|
175
197
|
};
|
|
198
|
+
// the same functions behind both overloads: the typed one only narrows
|
|
199
|
+
return client;
|
|
176
200
|
}
|
|
177
201
|
/** Live data (operational collections): the site's backend reads and
|
|
178
202
|
writes records that take effect immediately — no publish involved.
|
|
@@ -204,10 +228,12 @@ export function createAppClient(config) {
|
|
|
204
228
|
}
|
|
205
229
|
throw new MapledError(res.status, message);
|
|
206
230
|
}
|
|
207
|
-
|
|
231
|
+
const body = (await res.json());
|
|
232
|
+
reportAliases(body);
|
|
233
|
+
return body;
|
|
208
234
|
}
|
|
209
235
|
const collectionPath = (c) => `/v1/app/collections/${encodeURIComponent(c)}/records`;
|
|
210
|
-
|
|
236
|
+
const client = {
|
|
211
237
|
async list(collection, opts = {}) {
|
|
212
238
|
const search = new URLSearchParams();
|
|
213
239
|
for (const [k, v] of Object.entries(opts.filter ?? {})) {
|
|
@@ -244,4 +270,5 @@ export function createAppClient(config) {
|
|
|
244
270
|
await call("DELETE", `${collectionPath(collection)}/${encodeURIComponent(id)}`);
|
|
245
271
|
},
|
|
246
272
|
};
|
|
273
|
+
return client;
|
|
247
274
|
}
|
package/package.json
CHANGED