@camox/api-contract 0.44.0 → 0.46.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/analytics.d.ts +19 -0
- package/dist/analytics.js +18 -0
- package/dist/drafts.d.ts +82 -0
- package/dist/drafts.js +145 -0
- package/dist/icon-catalog.d.ts +7 -0
- package/dist/icon-catalog.js +48 -0
- package/dist/icons-BAxwOBBX.d.ts +19 -0
- package/dist/icons-Bk4igP2j.js +15 -0
- package/dist/ids.d.ts +26 -0
- package/dist/ids.js +40 -0
- package/dist/index.d.ts +12358 -8035
- package/dist/index.js +64 -96
- package/dist/projects.d.ts +28 -0
- package/dist/projects.js +50 -0
- package/dist/query-keys.d.ts +58 -42
- package/dist/query-keys.js +118 -32
- package/dist/threads.d.ts +277 -0
- package/dist/threads.js +263 -0
- package/package.json +53 -6
- package/src/analytics.ts +22 -0
- package/src/artifact.ts +38 -0
- package/src/drafts.ts +197 -0
- package/src/icon-catalog.ts +60 -0
- package/src/icons.ts +0 -57
- package/src/ids.ts +43 -0
- package/src/index.ts +5 -1
- package/src/projects.ts +67 -0
- package/src/query-keys.ts +64 -30
- package/src/reference-query.ts +48 -0
- package/src/threads.ts +327 -0
- package/dist/icons.d.ts +0 -20
- package/dist/icons.js +0 -72
- package/src/comments.ts +0 -33
package/src/drafts.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { Router } from "./index.ts";
|
|
2
|
+
|
|
3
|
+
/** Request header naming the draft a request reads and writes (ADR 0011). Absent means Live. */
|
|
4
|
+
export const DRAFT_HEADER = "x-camox-draft";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Request header naming, by its tag, the locale a request reads and writes content in (ADR
|
|
8
|
+
* 0015). Absent means the project's default locale.
|
|
9
|
+
*/
|
|
10
|
+
export const LOCALE_HEADER = "x-camox-locale";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Request header asking the API to create the draft named by `DRAFT_HEADER` if it doesn't
|
|
14
|
+
* exist yet. Its value is `"1"`.
|
|
15
|
+
*/
|
|
16
|
+
export const DRAFT_CREATE_HEADER = "x-camox-draft-create";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The `x-camox-draft` value naming the caller's own development draft in the project a request
|
|
20
|
+
* acts on (ADR 0006, ADR 0011). Dev mode and the CLI always send it.
|
|
21
|
+
*/
|
|
22
|
+
export const DEVELOPMENT_DRAFT = "development";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* oRPC error code for a request naming the development draft before dev mode created it: the
|
|
26
|
+
* first dev-mode sync after login does, so the site has to run locally once first.
|
|
27
|
+
*/
|
|
28
|
+
export const NO_DEVELOPMENT_DRAFT_ERROR = "NO_DEVELOPMENT_DRAFT";
|
|
29
|
+
|
|
30
|
+
/** URL search param carrying the current draft's id in the studio and the rendered site. */
|
|
31
|
+
export const DRAFT_SEARCH_PARAM = "camox-draft";
|
|
32
|
+
|
|
33
|
+
/** A regular draft is a named set of edits; a development draft backs a dev server. */
|
|
34
|
+
export type DraftKind = "regular" | "development";
|
|
35
|
+
|
|
36
|
+
/** How a regular draft ended. */
|
|
37
|
+
export type DraftOutcome = "published" | "discarded";
|
|
38
|
+
|
|
39
|
+
/** Who ended a draft, how and when, from its ended-drafts record (ADR 0009, ADR 0011). */
|
|
40
|
+
export type EndedDraft = {
|
|
41
|
+
draftId: string;
|
|
42
|
+
outcome: DraftOutcome;
|
|
43
|
+
endedBy: { id: string; name: string };
|
|
44
|
+
endedAt: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The oRPC error code a request naming an ended draft fails with. Its `data` is an `EndedDraft`,
|
|
49
|
+
* so a page can render Live and say who published or discarded the draft.
|
|
50
|
+
*/
|
|
51
|
+
export const DRAFT_ENDED_ERROR_CODE = "DRAFT_ENDED";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* oRPC error code for a publish whose token no longer matches Live: Live changed since the
|
|
55
|
+
* preview, so nothing was applied and the caller reviews the new preview.
|
|
56
|
+
*/
|
|
57
|
+
export const CONFLICTS_CHANGED_ERROR = "CONFLICTS_CHANGED";
|
|
58
|
+
|
|
59
|
+
/** oRPC error code for a publish refused because the draft still has blockers. */
|
|
60
|
+
export const PUBLISH_BLOCKED_ERROR = "PUBLISH_BLOCKED";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* oRPC error code for a publish refused because the draft still has pending translations: every
|
|
64
|
+
* locale goes live together, unless the caller publishes anyway (ADR 0017).
|
|
65
|
+
*/
|
|
66
|
+
export const TRANSLATIONS_PENDING_ERROR = "TRANSLATIONS_PENDING";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Why the publish tool published nothing of a draft with changes, and returned a fresh preview to
|
|
70
|
+
* review again, by the error code of each: Live changed since the preview, the draft has blockers,
|
|
71
|
+
* or translations are still running.
|
|
72
|
+
*/
|
|
73
|
+
export const NOT_PUBLISHED_CODES = {
|
|
74
|
+
conflictsChanged: CONFLICTS_CHANGED_ERROR,
|
|
75
|
+
blocked: PUBLISH_BLOCKED_ERROR,
|
|
76
|
+
translating: TRANSLATIONS_PENDING_ERROR,
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
export type NotPublishedReason = keyof typeof NOT_PUBLISHED_CODES;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Who Camox's own work is by: the creator of the drafts it opens to catch translations up, their
|
|
83
|
+
* publisher on Live, and who ended them. Shown as "Camox".
|
|
84
|
+
*/
|
|
85
|
+
export const CAMOX_ACTOR = "camox";
|
|
86
|
+
|
|
87
|
+
/** The name Camox's own work shows under. */
|
|
88
|
+
export const CAMOX_NAME = "Camox";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* oRPC error code for a content write that names no draft. Live is read-only (ADR 0003): content
|
|
92
|
+
* changes reach it only by publishing a draft, or through a release.
|
|
93
|
+
*/
|
|
94
|
+
export const LIVE_READ_ONLY_ERROR = "LIVE_READ_ONLY";
|
|
95
|
+
|
|
96
|
+
// Whether each procedure of a router that follows drafts writes content. Live is read-only
|
|
97
|
+
// (ADR 0003), so the API rejects a content write naming no draft, and the studio starts a draft
|
|
98
|
+
// for one on Live. Every procedure is listed, so a new one fails the type check until it's
|
|
99
|
+
// classified and can't be forgotten. Procedures that only fill a generated value (ADR 0012),
|
|
100
|
+
// like `generateSummary`, write no content. A synced block's shared content is written through
|
|
101
|
+
// `blocks` and `repeatableItems`. Releases and dev-mode sync aren't listed.
|
|
102
|
+
const BLOCK_WRITES: Record<keyof Router["blocks"], boolean> = {
|
|
103
|
+
get: false,
|
|
104
|
+
getPageMarkdown: false,
|
|
105
|
+
getLayoutMarkdown: false,
|
|
106
|
+
getUsageCounts: false,
|
|
107
|
+
getPagesUsing: false,
|
|
108
|
+
getSyncedContent: false,
|
|
109
|
+
create: true,
|
|
110
|
+
updateContent: true,
|
|
111
|
+
updateSettings: true,
|
|
112
|
+
updatePosition: true,
|
|
113
|
+
delete: true,
|
|
114
|
+
deleteMany: true,
|
|
115
|
+
generateSummary: false,
|
|
116
|
+
duplicate: true,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const COLLECTION_WRITES: Record<keyof Router["collectionDefinitions"], boolean> = {
|
|
120
|
+
list: false,
|
|
121
|
+
get: false,
|
|
122
|
+
listRecords: false,
|
|
123
|
+
getRecord: false,
|
|
124
|
+
getRecordUsageCount: false,
|
|
125
|
+
findRecordsByField: false,
|
|
126
|
+
listRecordPageValues: false,
|
|
127
|
+
recordMarkdown: false,
|
|
128
|
+
createRecord: true,
|
|
129
|
+
editRecord: true,
|
|
130
|
+
deleteRecord: true,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Uploads and replacements are content writes too, outside oRPC.
|
|
134
|
+
const FILE_WRITES: Record<keyof Router["files"], boolean> = {
|
|
135
|
+
list: false,
|
|
136
|
+
get: false,
|
|
137
|
+
getUsageCount: false,
|
|
138
|
+
setAlt: true,
|
|
139
|
+
setFilename: true,
|
|
140
|
+
delete: true,
|
|
141
|
+
deleteMany: true,
|
|
142
|
+
// Switching AI metadata on or off changes content (ADR 0012); generating only fills it.
|
|
143
|
+
setAiMetadata: true,
|
|
144
|
+
generateMetadata: false,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const PAGE_WRITES: Record<keyof Router["pages"], boolean> = {
|
|
148
|
+
getByPath: false,
|
|
149
|
+
getStructure: false,
|
|
150
|
+
list: false,
|
|
151
|
+
listBySlug: false,
|
|
152
|
+
get: false,
|
|
153
|
+
create: true,
|
|
154
|
+
update: true,
|
|
155
|
+
delete: true,
|
|
156
|
+
// Switching AI SEO on or off changes content (ADR 0012), like writing either field.
|
|
157
|
+
setSeo: true,
|
|
158
|
+
setLayout: true,
|
|
159
|
+
generateSeo: false,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const ITEM_WRITES: Record<keyof Router["repeatableItems"], boolean> = {
|
|
163
|
+
get: false,
|
|
164
|
+
create: true,
|
|
165
|
+
updateContent: true,
|
|
166
|
+
updateSettings: true,
|
|
167
|
+
updatePosition: true,
|
|
168
|
+
duplicate: true,
|
|
169
|
+
generateSummary: false,
|
|
170
|
+
delete: true,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Correcting the default locale's tag changes no content, so it needs no draft (ADR 0015).
|
|
174
|
+
const LOCALE_WRITES: Record<keyof Router["locales"], boolean> = {
|
|
175
|
+
list: false,
|
|
176
|
+
add: true,
|
|
177
|
+
remove: true,
|
|
178
|
+
setDefaultTag: false,
|
|
179
|
+
listMissing: false,
|
|
180
|
+
addBack: true,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// `definitions` is absent: its sync is a release or a dev sync, never a studio content write.
|
|
184
|
+
const CONTENT_WRITES: Partial<Record<keyof Router, Record<string, boolean>>> = {
|
|
185
|
+
blocks: BLOCK_WRITES,
|
|
186
|
+
locales: LOCALE_WRITES,
|
|
187
|
+
collectionDefinitions: COLLECTION_WRITES,
|
|
188
|
+
files: FILE_WRITES,
|
|
189
|
+
pages: PAGE_WRITES,
|
|
190
|
+
repeatableItems: ITEM_WRITES,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Whether the procedure at `path` (`[router, procedure]`) writes content. */
|
|
194
|
+
export function isContentWrite(path: readonly string[]): boolean {
|
|
195
|
+
if (path.length !== 2) return false;
|
|
196
|
+
return CONTENT_WRITES[path[0] as keyof Router]?.[path[1]] === true;
|
|
197
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { IconifyJSON } from "@iconify/types";
|
|
2
|
+
import { getIconData, iconToSVG } from "@iconify/utils";
|
|
3
|
+
|
|
4
|
+
import { ICON_CATALOG_VERSION, type IconSvgData } from "./icons.ts";
|
|
5
|
+
|
|
6
|
+
// Kept out of the root entry so `@iconify/utils` never reaches client bundles.
|
|
7
|
+
const collections = new Map<string, Promise<IconifyJSON>>();
|
|
8
|
+
|
|
9
|
+
export async function loadIconCollection(prefix: string): Promise<IconifyJSON> {
|
|
10
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(prefix)) throw new Error("Invalid icon collection");
|
|
11
|
+
const existing = collections.get(prefix);
|
|
12
|
+
if (existing) return existing;
|
|
13
|
+
const pending = (async () => {
|
|
14
|
+
const response = await fetch(
|
|
15
|
+
`https://unpkg.com/@iconify/json@${ICON_CATALOG_VERSION}/json/${prefix}.json`,
|
|
16
|
+
{ signal: AbortSignal.timeout(30_000) },
|
|
17
|
+
);
|
|
18
|
+
if (!response.ok) throw new Error(`Unable to load icon collection ${prefix}`);
|
|
19
|
+
const data = (await response.json()) as IconifyJSON;
|
|
20
|
+
if (data.prefix !== prefix || !data.icons || !data.info?.license)
|
|
21
|
+
throw new Error("Invalid icon catalog or missing license");
|
|
22
|
+
return data;
|
|
23
|
+
})();
|
|
24
|
+
collections.set(prefix, pending);
|
|
25
|
+
// Bound worker memory; failed fetches must be retryable.
|
|
26
|
+
if (collections.size > 8) collections.delete(collections.keys().next().value!);
|
|
27
|
+
try {
|
|
28
|
+
return await pending;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
collections.delete(prefix);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function iconCollectionIds(data: IconifyJSON): string[] {
|
|
36
|
+
return [...Object.keys(data.icons), ...Object.keys(data.aliases ?? {})]
|
|
37
|
+
.filter((name) => getIconData(data, name) !== null)
|
|
38
|
+
.sort()
|
|
39
|
+
.map((name) => `${data.prefix}:${name}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveIconSvg(data: IconifyJSON, id: string): IconSvgData {
|
|
43
|
+
const [prefix, name, extra] = id.split(":");
|
|
44
|
+
if (prefix !== data.prefix || !name || extra !== undefined) throw new Error("Invalid icon ID");
|
|
45
|
+
const icon = getIconData(data, name);
|
|
46
|
+
if (!icon) throw new Error(`Unknown icon: ${id}`);
|
|
47
|
+
const svg = iconToSVG(icon);
|
|
48
|
+
return {
|
|
49
|
+
body: svg.body,
|
|
50
|
+
attributes: svg.attributes,
|
|
51
|
+
attribution: data.info
|
|
52
|
+
? {
|
|
53
|
+
collection: data.info.name,
|
|
54
|
+
author: data.info.author.name,
|
|
55
|
+
license: data.info.license.title,
|
|
56
|
+
url: data.info.license.url,
|
|
57
|
+
}
|
|
58
|
+
: undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/icons.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import type { IconifyJSON } from "@iconify/types";
|
|
2
|
-
import { getIconData, iconToSVG } from "@iconify/utils";
|
|
3
|
-
|
|
4
1
|
// One immutable snapshot for declarations, schemas, picker and SVG resolution.
|
|
5
2
|
export const ICON_CATALOG_VERSION = "2.2.532";
|
|
6
3
|
export type IconSvgData = {
|
|
@@ -8,60 +5,6 @@ export type IconSvgData = {
|
|
|
8
5
|
attributes: Record<string, string>;
|
|
9
6
|
attribution?: { collection: string; author: string; license: string; url?: string };
|
|
10
7
|
};
|
|
11
|
-
const collections = new Map<string, Promise<IconifyJSON>>();
|
|
12
|
-
|
|
13
|
-
export async function loadIconCollection(prefix: string): Promise<IconifyJSON> {
|
|
14
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(prefix)) throw new Error("Invalid icon collection");
|
|
15
|
-
const existing = collections.get(prefix);
|
|
16
|
-
if (existing) return existing;
|
|
17
|
-
const pending = (async () => {
|
|
18
|
-
const response = await fetch(
|
|
19
|
-
`https://unpkg.com/@iconify/json@${ICON_CATALOG_VERSION}/json/${prefix}.json`,
|
|
20
|
-
{ signal: AbortSignal.timeout(30_000) },
|
|
21
|
-
);
|
|
22
|
-
if (!response.ok) throw new Error(`Unable to load icon collection ${prefix}`);
|
|
23
|
-
const data = (await response.json()) as IconifyJSON;
|
|
24
|
-
if (data.prefix !== prefix || !data.icons || !data.info?.license)
|
|
25
|
-
throw new Error("Invalid icon catalog or missing license");
|
|
26
|
-
return data;
|
|
27
|
-
})();
|
|
28
|
-
collections.set(prefix, pending);
|
|
29
|
-
// Bound worker memory; failed fetches must be retryable.
|
|
30
|
-
if (collections.size > 8) collections.delete(collections.keys().next().value!);
|
|
31
|
-
try {
|
|
32
|
-
return await pending;
|
|
33
|
-
} catch (error) {
|
|
34
|
-
collections.delete(prefix);
|
|
35
|
-
throw error;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function iconCollectionIds(data: IconifyJSON): string[] {
|
|
40
|
-
return [...Object.keys(data.icons), ...Object.keys(data.aliases ?? {})]
|
|
41
|
-
.filter((name) => getIconData(data, name) !== null)
|
|
42
|
-
.sort()
|
|
43
|
-
.map((name) => `${data.prefix}:${name}`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function resolveIconSvg(data: IconifyJSON, id: string): IconSvgData {
|
|
47
|
-
const [prefix, name, extra] = id.split(":");
|
|
48
|
-
if (prefix !== data.prefix || !name || extra !== undefined) throw new Error("Invalid icon ID");
|
|
49
|
-
const icon = getIconData(data, name);
|
|
50
|
-
if (!icon) throw new Error(`Unknown icon: ${id}`);
|
|
51
|
-
const svg = iconToSVG(icon);
|
|
52
|
-
return {
|
|
53
|
-
body: svg.body,
|
|
54
|
-
attributes: svg.attributes,
|
|
55
|
-
attribution: data.info
|
|
56
|
-
? {
|
|
57
|
-
collection: data.info.name,
|
|
58
|
-
author: data.info.author.name,
|
|
59
|
-
license: data.info.license.title,
|
|
60
|
-
url: data.info.license.url,
|
|
61
|
-
}
|
|
62
|
-
: undefined,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
8
|
|
|
66
9
|
export function scopeIconSvgIds(body: string, prefix: string): string {
|
|
67
10
|
const ids = [...body.matchAll(/\sid="([^\s"]+)"/g)].map((match) => match[1]!);
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** The id of a content entity: one UUIDv7, kept in Live and every draft (ADR 0004). */
|
|
4
|
+
export const uuidv7Schema = /* @__PURE__ */ z.uuidv7();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Project ID: a UUIDv7 in lowercase only, the form the project's Worker is named by, so an
|
|
8
|
+
* uppercase one fails here rather than silently naming no project.
|
|
9
|
+
*/
|
|
10
|
+
export const projectIdSchema = /* @__PURE__ */ z
|
|
11
|
+
.uuidv7()
|
|
12
|
+
.regex(/^[0-9a-f-]+$/, "A Project ID is a UUIDv7 in lowercase");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `uuidv7Schema` as JSON Schema, for content schemas. Written out rather than derived, so site
|
|
16
|
+
* bundles, which need only this, leave zod out.
|
|
17
|
+
*/
|
|
18
|
+
export const uuidv7JsonSchema = {
|
|
19
|
+
type: "string",
|
|
20
|
+
format: "uuid",
|
|
21
|
+
pattern:
|
|
22
|
+
"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-7[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A text link to a page: this prefix, then the page's UUIDv7. */
|
|
26
|
+
export const PAGE_TEXT_LINK_PREFIX = "camox:page:";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mints a UUIDv7 (RFC 9562): a 48-bit millisecond timestamp, then random bits.
|
|
30
|
+
* The server mints ids; clients mint one only for optimistic inserts.
|
|
31
|
+
*/
|
|
32
|
+
export function generateUuidv7(): string {
|
|
33
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
34
|
+
let timestamp = Date.now();
|
|
35
|
+
for (let index = 5; index >= 0; index--) {
|
|
36
|
+
bytes[index] = timestamp % 256;
|
|
37
|
+
timestamp = Math.floor(timestamp / 256);
|
|
38
|
+
}
|
|
39
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x70;
|
|
40
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
41
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
42
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
43
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export * from "./artifact.ts";
|
|
1
2
|
export * from "./icons.ts";
|
|
2
|
-
export * from "./
|
|
3
|
+
export * from "./ids.ts";
|
|
4
|
+
export * from "./reference-query.ts";
|
|
3
5
|
export type { Router } from "../../../apps/api/src/router";
|
|
6
|
+
export type { PublishPreview } from "../../../apps/api/src/domains/drafts/publish";
|
|
7
|
+
export type { EntityKind } from "../../../apps/api/src/domains/drafts/overlay";
|
package/src/projects.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project Slugs Camox keeps for its own hosts and pages, so no project's `<slug>.camox.site`
|
|
5
|
+
* looks like one of them.
|
|
6
|
+
*/
|
|
7
|
+
export const RESERVED_PROJECT_SLUGS: ReadonlySet<string> = new Set([
|
|
8
|
+
"www",
|
|
9
|
+
"api",
|
|
10
|
+
"app",
|
|
11
|
+
"dashboard",
|
|
12
|
+
"studio",
|
|
13
|
+
"sites",
|
|
14
|
+
"site",
|
|
15
|
+
"docs",
|
|
16
|
+
"blog",
|
|
17
|
+
"help",
|
|
18
|
+
"support",
|
|
19
|
+
"status",
|
|
20
|
+
"admin",
|
|
21
|
+
"auth",
|
|
22
|
+
"login",
|
|
23
|
+
"mail",
|
|
24
|
+
"email",
|
|
25
|
+
"cdn",
|
|
26
|
+
"static",
|
|
27
|
+
"assets",
|
|
28
|
+
"files",
|
|
29
|
+
"camox",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A Project Slug: one lowercase DNS label, the only depth Universal SSL covers on `camox.site`,
|
|
34
|
+
* made of letters, digits and inner hyphens.
|
|
35
|
+
*/
|
|
36
|
+
export const projectSlugSchema = /* @__PURE__ */ z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1, "Enter a slug")
|
|
39
|
+
.max(63, "Use at most 63 characters")
|
|
40
|
+
.regex(
|
|
41
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
42
|
+
"Use lowercase letters, digits and hyphens, without a hyphen at either end",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Why a slug can't be given to a new project. */
|
|
46
|
+
export type UnavailableSlugReason = "invalid" | "reserved" | "taken";
|
|
47
|
+
|
|
48
|
+
/** What a member is told when a slug can't be used, by reason. */
|
|
49
|
+
export const UNAVAILABLE_SLUG_MESSAGES: Record<UnavailableSlugReason, string> = {
|
|
50
|
+
invalid: "Use lowercase letters, digits and hyphens, without a hyphen at either end.",
|
|
51
|
+
reserved: "This slug is reserved by Camox. Choose another.",
|
|
52
|
+
taken: "This slug is already taken. Choose another.",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where a project is in its life: `settingUp` from its creation until its first release
|
|
57
|
+
* succeeds, then `live`. `setupFailed` when its setup gave up: Camox is told and fixes it.
|
|
58
|
+
*/
|
|
59
|
+
export type ProjectStatus = "settingUp" | "live" | "setupFailed";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The steps a project's setup goes through before it's live, as its page lists them: copying the
|
|
63
|
+
* Template into its repo, then its first release building it and serving it from its Worker.
|
|
64
|
+
*/
|
|
65
|
+
export type ProjectSetupStep = "copyTemplate" | "build" | "serve";
|
|
66
|
+
|
|
67
|
+
export const PROJECT_SETUP_STEPS: readonly ProjectSetupStep[] = ["copyTemplate", "build", "serve"];
|
package/src/query-keys.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { EndedDraft } from "./drafts";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Use TypeScript to enforce 'camox' as the first query key to ensure they're all namespaced.
|
|
3
5
|
* This is because the Tanstack Query client on the frontend may be shared with the user's routes,
|
|
@@ -7,56 +9,88 @@ export type QueryKey = [first: "camox", ...rest: Array<string | number>];
|
|
|
7
9
|
type QueryKeyGroup = Record<string, QueryKey | ((...args: any[]) => QueryKey)>;
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* source select is instant once both have been seeded.
|
|
14
|
-
*
|
|
15
|
-
* Block / page-read query keys include the source as the trailing key segment.
|
|
16
|
-
* Omitting it on `invalidateQueries` is a prefix invalidation that hits every
|
|
17
|
-
* source — used for navigation events and other source-agnostic invalidations.
|
|
18
|
-
* Edit-time invalidations (which only touch draft data) pass `'draft'`
|
|
19
|
-
* explicitly so the `'live'` cache stays untouched.
|
|
12
|
+
* The key prefixes the API's realtime invalidations name (ADR 0011). The studio caches each read
|
|
13
|
+
* under one of them, with the scope appended, so an invalidation reaches every read it should;
|
|
14
|
+
* the studio's own keys live with its reads, in the SDK.
|
|
20
15
|
*/
|
|
21
|
-
export type ReadSource = "draft" | "live";
|
|
22
|
-
|
|
23
16
|
export const queryKeys = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
collections: {
|
|
18
|
+
record: (projectUuid: string, collectionId: string, id: string) => [
|
|
19
|
+
"camox",
|
|
20
|
+
"collections",
|
|
21
|
+
"record",
|
|
22
|
+
projectUuid,
|
|
23
|
+
collectionId,
|
|
24
|
+
id,
|
|
25
|
+
],
|
|
26
|
+
records: (projectUuid: string, collectionId: string) => [
|
|
27
|
+
"camox",
|
|
28
|
+
"collections",
|
|
29
|
+
"records",
|
|
30
|
+
projectUuid,
|
|
31
|
+
collectionId,
|
|
32
|
+
],
|
|
27
33
|
},
|
|
28
34
|
pages: {
|
|
29
35
|
list: ["camox", "pages", "list"],
|
|
30
|
-
getByPath: (path: string,
|
|
31
|
-
source
|
|
32
|
-
? ["camox", "pages", "getByPath", path, source]
|
|
33
|
-
: ["camox", "pages", "getByPath", path],
|
|
36
|
+
getByPath: (path: string) => ["camox", "pages", "getByPath", path],
|
|
34
37
|
getByPathAll: ["camox", "pages", "getByPath"],
|
|
35
|
-
getById: (id:
|
|
38
|
+
getById: (id: string) => ["camox", "pages", "getById", id],
|
|
36
39
|
},
|
|
37
40
|
files: {
|
|
38
41
|
list: ["camox", "files", "list"],
|
|
39
|
-
get: (id:
|
|
42
|
+
get: (id: string) => ["camox", "files", "get", id],
|
|
40
43
|
},
|
|
41
44
|
blocks: {
|
|
42
|
-
get: (id:
|
|
43
|
-
|
|
45
|
+
get: (id: string) => ["camox", "blocks", "get", id],
|
|
46
|
+
// Every placement change refreshes what's under this prefix: counts, pages using a block.
|
|
44
47
|
getUsageCounts: ["camox", "blocks", "getUsageCounts"],
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
get: (id: number) => ["camox", "repeatableItems", "get", id],
|
|
48
|
+
// Under the usage counts prefix, so placing or removing the block refreshes it too.
|
|
49
|
+
getSyncedContent: (type: string) => ["camox", "blocks", "getUsageCounts", "synced", type],
|
|
50
|
+
getPageMarkdown: (pageId: string) => ["camox", "blocks", "getPageMarkdown", pageId],
|
|
49
51
|
},
|
|
50
52
|
layouts: {
|
|
51
53
|
all: ["camox", "layouts"],
|
|
52
54
|
},
|
|
53
|
-
|
|
54
|
-
all: ["camox", "
|
|
55
|
-
|
|
55
|
+
drafts: {
|
|
56
|
+
all: ["camox", "drafts"],
|
|
57
|
+
// Open drafts and Live's latest publish: the same for every viewer, whatever they're in.
|
|
58
|
+
list: ["camox", "drafts", "list"],
|
|
59
|
+
// The draft a viewer is in, scoped to it like content.
|
|
60
|
+
get: ["camox", "drafts", "get"],
|
|
56
61
|
},
|
|
57
62
|
} satisfies Record<string, QueryKeyGroup>;
|
|
58
63
|
|
|
64
|
+
/** The last segment of a query key: what the query reads, Live or a draft (ADR 0011). */
|
|
65
|
+
export const LIVE_KEY_SCOPE = "live";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A query key read from Live (`draftId` null) or from a draft. The keys above are prefixes naming
|
|
69
|
+
* what a query reads; the cache holds them with the scope appended, so each draft's queries are
|
|
70
|
+
* kept and invalidated apart.
|
|
71
|
+
*/
|
|
72
|
+
export function inScope(key: QueryKey, draftId: string | null): QueryKey {
|
|
73
|
+
return [...key, draftId ?? LIVE_KEY_SCOPE];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The scope a scoped query key ends in: `"live"` or a draft's id. */
|
|
77
|
+
export function scopeOfKey(key: readonly unknown[]): unknown {
|
|
78
|
+
return key[key.length - 1];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Refetch the queries under these prefixes. `draftId` names the draft the change came from, so
|
|
83
|
+
* only that draft's queries refetch; a Live change (`null`) refetches every draft's too, since
|
|
84
|
+
* drafts read Live wherever they change nothing.
|
|
85
|
+
*/
|
|
59
86
|
export type InvalidationMessage = {
|
|
60
87
|
type: "invalidate";
|
|
88
|
+
draftId: string | null;
|
|
61
89
|
targets: QueryKey[];
|
|
62
90
|
};
|
|
91
|
+
|
|
92
|
+
/** A draft was published or discarded: its viewers go back to Live. */
|
|
93
|
+
export type DraftEndedMessage = { type: "draftEnded" } & EndedDraft;
|
|
94
|
+
|
|
95
|
+
/** Everything the project room broadcasts. */
|
|
96
|
+
export type ProjectRoomMessage = InvalidationMessage | DraftEndedMessage;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rules for query-backed reference lists, shared by the SDK (builders) and the API (definition
|
|
3
|
+
* sync), so both accept and reject the same queries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The most records a query-backed list resolves to, and its default `limit`. */
|
|
7
|
+
export const QUERY_LIMIT_CAP = 100;
|
|
8
|
+
|
|
9
|
+
/** Record metadata a query can order by. Collections cannot name fields after them. */
|
|
10
|
+
export type SystemOrderKey = "createdAt" | "publishedAt";
|
|
11
|
+
export const SYSTEM_ORDER_KEYS: readonly SystemOrderKey[] = ["createdAt", "publishedAt"];
|
|
12
|
+
|
|
13
|
+
export function isSystemOrderKey(key: string): key is SystemOrderKey {
|
|
14
|
+
return (SYSTEM_ORDER_KEYS as readonly string[]).includes(key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether a reference list field is query-backed: code defines its records, nothing is stored. */
|
|
18
|
+
export function isQueryBacked(field: object | null | undefined) {
|
|
19
|
+
return !!field && (field as { query?: unknown }).query !== undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Why a query is invalid, if it is. `fieldTypeOf` gives the collection field type an ordering
|
|
24
|
+
* key names; keys must name a `String` field or system metadata.
|
|
25
|
+
*/
|
|
26
|
+
export function referenceQueryProblem(
|
|
27
|
+
query: unknown,
|
|
28
|
+
fieldTypeOf: (key: string) => unknown,
|
|
29
|
+
): string | undefined {
|
|
30
|
+
if (!query || typeof query !== "object" || Array.isArray(query)) return "query must be an object";
|
|
31
|
+
const { orderBy, limit, ...rest } = query as Record<string, unknown>;
|
|
32
|
+
if (Object.keys(rest).length) return "query only supports orderBy and limit";
|
|
33
|
+
const validLimit =
|
|
34
|
+
typeof limit === "number" && Number.isInteger(limit) && limit >= 1 && limit <= QUERY_LIMIT_CAP;
|
|
35
|
+
if (limit !== undefined && !validLimit) {
|
|
36
|
+
return `limit must be an integer from 1 to ${QUERY_LIMIT_CAP}`;
|
|
37
|
+
}
|
|
38
|
+
if (orderBy === undefined) return;
|
|
39
|
+
const entries =
|
|
40
|
+
orderBy && typeof orderBy === "object" && !Array.isArray(orderBy)
|
|
41
|
+
? Object.entries(orderBy)
|
|
42
|
+
: [];
|
|
43
|
+
if (entries.length !== 1) return "orderBy takes a single key";
|
|
44
|
+
const [[key, direction]] = entries;
|
|
45
|
+
if (direction !== "asc" && direction !== "desc") return 'order must be "asc" or "desc"';
|
|
46
|
+
if (isSystemOrderKey(key)) return;
|
|
47
|
+
if (fieldTypeOf(key) !== "String") return `cannot order by "${key}"`;
|
|
48
|
+
}
|