@mosetta/content 0.0.0-stage → 0.0.10
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/LICENSE +21 -0
- package/README.md +5 -2
- package/package.json +35 -3
- package/src/articleLists.ts +91 -0
- package/src/content.ts +16 -0
- package/src/editing.ts +1304 -0
- package/src/i18n.ts +177 -0
- package/src/media.ts +69 -0
- package/src/model.ts +300 -0
- package/src/names.ts +50 -0
- package/src/paths.ts +115 -0
- package/src/policy.ts +133 -0
- package/src/queryPolicy.ts +33 -0
- package/src/repository.ts +358 -0
- package/src/roots.ts +105 -0
- package/src/schemas.ts +122 -0
- package/src/transport.ts +142 -0
- package/src/units.ts +396 -0
- package/src/versions.ts +40 -0
package/src/paths.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Source-folder layout of a revisioned entity. Single source of truth for the transport
|
|
2
|
+
// paths — the node provider mirrors these shapes with plain regexes (it cannot import TS at runtime).
|
|
3
|
+
//
|
|
4
|
+
// pages/<name>/page.json prefabs/<name>/prefab.json ← meta
|
|
5
|
+
// pages/<name>/versions/<rev>.json prefabs/<name>/versions/<rev>.json ← published/archived only
|
|
6
|
+
// pages/<name>/drafts/<id>.json prefabs/<name>/drafts/<id>.json ← working copies
|
|
7
|
+
import { ContentKind, ENTITY_NAME_RE } from './model';
|
|
8
|
+
|
|
9
|
+
export const META_FILE: Record<ContentKind, string> = { pages: 'page.json', prefabs: 'prefab.json' };
|
|
10
|
+
|
|
11
|
+
export const metaPath = (kind: ContentKind, name: string) => `${kind}/${name}/${META_FILE[kind]}`;
|
|
12
|
+
|
|
13
|
+
export const versionPath = (kind: ContentKind, name: string, rev: number) => `${kind}/${name}/versions/${rev}.json`;
|
|
14
|
+
|
|
15
|
+
export const draftPath = (kind: ContentKind, name: string, id: string) => `${kind}/${name}/drafts/${id}.json`;
|
|
16
|
+
|
|
17
|
+
/** trailing slash = "list draft ids of this entity" for the transport */
|
|
18
|
+
export const draftsPrefix = (kind: ContentKind, name: string) => `${kind}/${name}/drafts/`;
|
|
19
|
+
|
|
20
|
+
/** the whole entity folder — DELETE target for removing an entity with its full history */
|
|
21
|
+
export const entityPath = (kind: ContentKind, name: string) => `${kind}/${name}`;
|
|
22
|
+
|
|
23
|
+
/** trailing slash = "list entity names of this kind" for the transport */
|
|
24
|
+
export const listPrefix = (kind: ContentKind) => `${kind}/`;
|
|
25
|
+
|
|
26
|
+
// ---- published pages: the route lives in the KEY --------------------------------------------
|
|
27
|
+
//
|
|
28
|
+
// The route table is a LISTING of published pages, not an index: an index would have to be
|
|
29
|
+
// updated on every publish and would need concurrent-publish race handling, while a listing needs
|
|
30
|
+
// no maintenance at all. So the listing alone can answer "which URL is which page", the route is
|
|
31
|
+
// encoded into the entry name:
|
|
32
|
+
//
|
|
33
|
+
// pages/published/<encodeURIComponent(pattern)>~<name>~<rev>
|
|
34
|
+
//
|
|
35
|
+
// `~` never appears in `encodeURIComponent` output, entity names match `^[a-z][A-Za-z0-9]*$`,
|
|
36
|
+
// the revision is an integer: parsing is unambiguous. The prod store puts such an entry on
|
|
37
|
+
// publish (an empty marker object); the dev provider owns the disk and derives the same listing
|
|
38
|
+
// from the metas.
|
|
39
|
+
|
|
40
|
+
export const PUBLISHED_PREFIX = 'pages/published/';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* GONE markers: `pages/gone/<encoded pattern>` — a route whose page was deleted while live. The
|
|
44
|
+
* site answers such an address 410 Gone (a search engine drops it at once, where a 404 is
|
|
45
|
+
* retried for weeks). Written when a live page is deleted, removed when a page is published at
|
|
46
|
+
* the route again; kept forever otherwise.
|
|
47
|
+
*/
|
|
48
|
+
export const GONE_PREFIX = 'pages/gone/';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The GENERATION MARKER: rewritten by every publish and deletion — whatever changes what the live
|
|
52
|
+
* site shows. A reader keeps what it read of the live site (the route table, the config, the
|
|
53
|
+
* prefabs) while the marker stays the same: one conditional request instead of re-reading it all.
|
|
54
|
+
* A store that keeps none (dev) is read anew on every request.
|
|
55
|
+
*/
|
|
56
|
+
export const GENERATION_PATH = 'generation.json';
|
|
57
|
+
|
|
58
|
+
export const goneKey = (pattern: string): string => `${GONE_PREFIX}${encodeURIComponent(pattern)}`;
|
|
59
|
+
|
|
60
|
+
export function parseGoneKey(key: string): string | null {
|
|
61
|
+
try {
|
|
62
|
+
const pattern = decodeURIComponent(key);
|
|
63
|
+
return pattern.startsWith('/') ? pattern : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** the project's content config: the languages, the namespace */
|
|
70
|
+
export const CONFIG_PATH = 'config.json';
|
|
71
|
+
|
|
72
|
+
/** a listing entry: everything route matching needs — no document read required */
|
|
73
|
+
export interface IPublishedRoute {
|
|
74
|
+
readonly pattern: string;
|
|
75
|
+
readonly name: string;
|
|
76
|
+
readonly rev: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a route would take addresses that belong to a FROZEN page's route. A frozen page that
|
|
81
|
+
* answers for a whole subtree (`/workbench/{...rest}` — the workbench shell) owns everything
|
|
82
|
+
* under its base: a literal beats a rest in the route table, so a page published at `/workbench/pages`
|
|
83
|
+
* would stand in front of the shell's own address. A frozen route without a tail owns itself
|
|
84
|
+
* alone. The candidate's query requirements do not matter: the path is what collides.
|
|
85
|
+
*/
|
|
86
|
+
export function underFrozenRoute(candidate: string, frozen: string): boolean {
|
|
87
|
+
const path = candidate.split('?')[0];
|
|
88
|
+
const tail = /\/\{\.\.\.[^}]*\}$/.exec(frozen);
|
|
89
|
+
if (!tail) return path === frozen;
|
|
90
|
+
const base = frozen.slice(0, tail.index) || '/';
|
|
91
|
+
return path === base || path === frozen || path.startsWith(base === '/' ? '/' : `${base}/`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const publishedKey = ({ pattern, name, rev }: IPublishedRoute) => `${encodeURIComponent(pattern)}~${name}~${rev}`;
|
|
95
|
+
|
|
96
|
+
export function parsePublishedKey(key: string): IPublishedRoute | null {
|
|
97
|
+
const parts = key.split('~');
|
|
98
|
+
if (parts.length !== 3) return null;
|
|
99
|
+
const rev = Number(parts[2]);
|
|
100
|
+
if (!Number.isInteger(rev) || rev <= 0 || !ENTITY_NAME_RE.test(parts[1])) return null;
|
|
101
|
+
try {
|
|
102
|
+
return { pattern: decodeURIComponent(parts[0]), name: parts[1], rev };
|
|
103
|
+
} catch {
|
|
104
|
+
return null; // broken encoding — the entry is not a route
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The PUBLISHED-revision document in the public content zone. The key carries the revision and
|
|
110
|
+
* published/archived revisions are immutable — so the response is cacheable forever.
|
|
111
|
+
*/
|
|
112
|
+
export const publishedDocPath = (name: string, rev: number) => `pages/${name}/${rev}.json`;
|
|
113
|
+
|
|
114
|
+
/** listing order matters ("first page" fallback) — do not depend on store traversal order */
|
|
115
|
+
export const sortRoutes = (routes: readonly IPublishedRoute[]): IPublishedRoute[] => [...routes].sort((a, b) => (a.pattern === b.pattern ? a.name.localeCompare(b.name) : a.pattern.localeCompare(b.pattern)));
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ContentKind, IContentMeta, RevisionStatus } from './model';
|
|
2
|
+
import { META_FILE, publishedKey } from './paths';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Revision rules at the TRUST BOUNDARY.
|
|
6
|
+
*
|
|
7
|
+
* Whoever owns the store must enforce them: action code comes from a distribution and carries no
|
|
8
|
+
* trust. These checks used to live inside the dev provider (node, .mjs) — the rule had no home, a
|
|
9
|
+
* prod provider would have to rewrite it. Now they are pure functions of this package, and store
|
|
10
|
+
* implementations (dev worker over files, node provider, a future object store) are thin adapters
|
|
11
|
+
* around them.
|
|
12
|
+
*
|
|
13
|
+
* Checking HERE does not replace the check in `ContentRepository` (isolate side): that one gives
|
|
14
|
+
* the author sensible errors, this one gives the guarantee. Two checks on opposite sides of the
|
|
15
|
+
* boundary are not duplication.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ENTITY = '[a-z][A-Za-z0-9]*';
|
|
19
|
+
const DRAFT = '[a-z0-9][a-z0-9-]{5,63}'; // mirrors DRAFT_ID_RE (kept inline: composed regexes)
|
|
20
|
+
const LIST_RE = /^(pages|prefabs)\/$/;
|
|
21
|
+
const META_RE = new RegExp(`^(pages|prefabs)/(${ENTITY})/(page|prefab)\\.json$`);
|
|
22
|
+
const VERSION_RE = new RegExp(`^(pages|prefabs)/(${ENTITY})/versions/([1-9][0-9]*)\\.json$`);
|
|
23
|
+
const DRAFT_RE = new RegExp(`^(pages|prefabs)/(${ENTITY})/drafts/(${DRAFT})\\.json$`);
|
|
24
|
+
const DRAFT_LIST_RE = new RegExp(`^(pages|prefabs)/(${ENTITY})/drafts/$`);
|
|
25
|
+
const ENTITY_RE = new RegExp(`^(pages|prefabs)/(${ENTITY})$`);
|
|
26
|
+
|
|
27
|
+
const REV_STATUSES = new Set<string>(['published', 'archived']);
|
|
28
|
+
|
|
29
|
+
/** published/archived are immutable — nothing may overwrite or delete them */
|
|
30
|
+
export const IMMUTABLE: ReadonlySet<string> = new Set(['published', 'archived']);
|
|
31
|
+
|
|
32
|
+
export type TContentTarget = { readonly kind: 'list'; readonly collection: ContentKind }
|
|
33
|
+
| { readonly kind: 'meta'; readonly collection: ContentKind; readonly name: string; readonly rel: string }
|
|
34
|
+
| { readonly kind: 'version'; readonly collection: ContentKind; readonly name: string; readonly rev: number; readonly rel: string }
|
|
35
|
+
| { readonly kind: 'draft'; readonly collection: ContentKind; readonly name: string; readonly draftId: string; readonly rel: string }
|
|
36
|
+
| { readonly kind: 'draftList'; readonly collection: ContentKind; readonly name: string }
|
|
37
|
+
| { readonly kind: 'entity'; readonly collection: ContentKind; readonly name: string; readonly rel: string };
|
|
38
|
+
|
|
39
|
+
/** parse a content-channel path; `null` — the path is outside the channel and must not be served */
|
|
40
|
+
export function parseContentPath(rel: string): TContentTarget | null {
|
|
41
|
+
if (rel.includes('..')) return null;
|
|
42
|
+
const list = LIST_RE.exec(rel);
|
|
43
|
+
if (list) return { kind: 'list', collection: list[1] as ContentKind };
|
|
44
|
+
const meta = META_RE.exec(rel);
|
|
45
|
+
if (meta) {
|
|
46
|
+
const collection = meta[1] as ContentKind;
|
|
47
|
+
// the meta file name must match the collection: pages/<x>/prefab.json is not a channel path
|
|
48
|
+
if (META_FILE[collection] !== `${meta[3]}.json`) return null;
|
|
49
|
+
return { kind: 'meta', collection, name: meta[2], rel };
|
|
50
|
+
}
|
|
51
|
+
const version = VERSION_RE.exec(rel);
|
|
52
|
+
if (version) {
|
|
53
|
+
return { kind: 'version', collection: version[1] as ContentKind, name: version[2], rev: Number(version[3]), rel };
|
|
54
|
+
}
|
|
55
|
+
const draft = DRAFT_RE.exec(rel);
|
|
56
|
+
if (draft) {
|
|
57
|
+
return { kind: 'draft', collection: draft[1] as ContentKind, name: draft[2], draftId: draft[3], rel };
|
|
58
|
+
}
|
|
59
|
+
const draftList = DRAFT_LIST_RE.exec(rel);
|
|
60
|
+
if (draftList) return { kind: 'draftList', collection: draftList[1] as ContentKind, name: draftList[2] };
|
|
61
|
+
const entity = ENTITY_RE.exec(rel);
|
|
62
|
+
if (entity) return { kind: 'entity', collection: entity[1] as ContentKind, name: entity[2], rel };
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Why the proposed draft write is illegal (or `null` when it is legal) — the OPTIMISTIC
|
|
68
|
+
* CONCURRENCY gate. Every draft carries an integer `seq`; an accepted write must be the direct
|
|
69
|
+
* successor of what is stored. A writer that fell behind (someone else saved meanwhile) breaks
|
|
70
|
+
* the chain and is refused — the store answers 412, the editor answers fork-or-rollback.
|
|
71
|
+
*/
|
|
72
|
+
export function draftRejection(oldDoc: unknown, next: unknown): string | null {
|
|
73
|
+
if (!next || typeof next !== 'object' || Array.isArray(next)) return 'malformed draft';
|
|
74
|
+
const seq = (next as { seq?: unknown }).seq;
|
|
75
|
+
if (!Number.isInteger(seq) || (seq as number) < 1) return 'draft must carry an integer seq >= 1';
|
|
76
|
+
if (!oldDoc || typeof oldDoc !== 'object') {
|
|
77
|
+
return seq === 1 ? null : 'first write of a draft must carry seq 1';
|
|
78
|
+
}
|
|
79
|
+
const stored = (oldDoc as { seq?: unknown }).seq;
|
|
80
|
+
if (!Number.isInteger(stored)) return null; // stored draft has no chain (hand-edited) — repairable
|
|
81
|
+
return seq === (stored as number) + 1
|
|
82
|
+
? null
|
|
83
|
+
: `draft seq ${seq as number} does not follow stored seq ${stored as number} (concurrent edit?)`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Why the proposed meta is illegal (or `null` when it is legal). Immutable revisions must survive
|
|
88
|
+
* any rewrite: they cannot be removed, turned back into drafts, or re-published; `live` must point
|
|
89
|
+
* at the single published revision.
|
|
90
|
+
*/
|
|
91
|
+
export function metaRejection(oldMeta: IContentMeta | null, next: unknown): string | null {
|
|
92
|
+
if (!next || typeof next !== 'object' || Array.isArray(next)) return 'malformed meta';
|
|
93
|
+
const meta = next as { live?: unknown; versions?: unknown };
|
|
94
|
+
if (meta.live !== null && !Number.isInteger(meta.live)) return 'malformed meta: live must be an int or null';
|
|
95
|
+
if (!meta.versions || typeof meta.versions !== 'object' || Array.isArray(meta.versions)) {
|
|
96
|
+
return 'malformed meta: versions map required';
|
|
97
|
+
}
|
|
98
|
+
const entries = Object.entries(meta.versions as Record<string, unknown>);
|
|
99
|
+
if (entries.some(([rev, s]) => !/^[1-9][0-9]*$/.test(rev) || typeof s !== 'string' || !REV_STATUSES.has(s))) {
|
|
100
|
+
return 'malformed meta: bad revision entry';
|
|
101
|
+
}
|
|
102
|
+
const published = entries.filter(([, s]) => s === 'published');
|
|
103
|
+
if (published.length > 1) return 'more than one published revision';
|
|
104
|
+
const expectedLive = published.length === 1 ? Number(published[0][0]) : null;
|
|
105
|
+
if (meta.live !== expectedLive) return 'live must point at the published revision';
|
|
106
|
+
for (const [rev, status] of Object.entries(oldMeta?.versions ?? {})) {
|
|
107
|
+
if (!IMMUTABLE.has(status)) continue;
|
|
108
|
+
const nextStatus = (meta.versions as Record<string, string>)[rev];
|
|
109
|
+
if (!nextStatus) return `immutable revision ${rev} cannot be removed`;
|
|
110
|
+
if (status === 'archived' && nextStatus !== 'archived') return `archived revision ${rev} cannot be re-published`;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** revision status per the meta (for immutability checks and serving published documents) */
|
|
116
|
+
export function statusOf(meta: IContentMeta | null, rev: number): RevisionStatus | null {
|
|
117
|
+
return meta?.versions?.[String(rev)] ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Route table from published pages: the key carries the pattern, name and revision.
|
|
122
|
+
* Input is the already-read meta and live-revision document; reading is the store adapter's job.
|
|
123
|
+
*/
|
|
124
|
+
export function publishedKeysFrom(
|
|
125
|
+
entities: readonly { name: string; meta: IContentMeta | null; doc: { pattern?: unknown } | null }[],
|
|
126
|
+
): string[] {
|
|
127
|
+
const keys: string[] = [];
|
|
128
|
+
for (const { name, meta, doc } of entities) {
|
|
129
|
+
if (!meta || typeof meta.live !== 'number' || !doc || typeof doc.pattern !== 'string') continue;
|
|
130
|
+
keys.push(publishedKey({ pattern: doc.pattern, name, rev: meta.live }));
|
|
131
|
+
}
|
|
132
|
+
return keys.sort();
|
|
133
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// WHICH QUERY PARAMETERS A PAGE DOES NOT SEE. A live page is kept at the edge under its address,
|
|
2
|
+
// query included — so every marketing tag a link carries (`?utm_source=tg`, `?fbclid=…`) would
|
|
3
|
+
// render the same page anew and keep one more copy of it. The parameters named here are taken off
|
|
4
|
+
// the address before anything else happens: the edge key AND the render go without them (the
|
|
5
|
+
// render must, or a copy kept for one visitor's tags would be served to another's). Scripts in the
|
|
6
|
+
// browser still read them from the address bar — only the server never sees them.
|
|
7
|
+
//
|
|
8
|
+
// The list is the project's content config (`config.json`: `cache.ignoreQuery`); without one the
|
|
9
|
+
// defaults below apply, an empty list keeps every parameter. An entry is a parameter name or a
|
|
10
|
+
// prefix ending in `*` (`utm_*`).
|
|
11
|
+
|
|
12
|
+
/** the tags of the common ad and mail networks: never meant for the page, always on its links */
|
|
13
|
+
export const DEFAULT_IGNORED_QUERY: readonly string[] = [
|
|
14
|
+
'utm_*',
|
|
15
|
+
'gclid', 'gbraid', 'wbraid', 'dclid',
|
|
16
|
+
'fbclid', 'msclkid', 'ttclid', 'twclid', 'igshid',
|
|
17
|
+
'yclid', 'ysclid', '_openstat',
|
|
18
|
+
'mc_cid', 'mc_eid',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const ENTRY_RE = /^[A-Za-z0-9_.~-]+\*?$/;
|
|
22
|
+
|
|
23
|
+
/** the content config's ignore list, validated: absent or malformed → the defaults; listed → exactly that */
|
|
24
|
+
export function ignoredQueryOf(config: unknown): readonly string[] {
|
|
25
|
+
const cache = (config as { cache?: { ignoreQuery?: unknown } } | null)?.cache;
|
|
26
|
+
if (!Array.isArray(cache?.ignoreQuery)) return DEFAULT_IGNORED_QUERY;
|
|
27
|
+
return cache.ignoreQuery.filter((entry): entry is string => typeof entry === 'string' && ENTRY_RE.test(entry));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** a parameter the list names: by its whole name, or by a prefix entry (`utm_*`) */
|
|
31
|
+
export function isIgnoredQuery(name: string, ignored: readonly string[]): boolean {
|
|
32
|
+
return ignored.some((entry) => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
|
|
33
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// The content state machine over a content transport, on TWO axes:
|
|
2
|
+
// drafts/<id> ──publish──▶ versions/<n> published (= live, exactly one) ──next publish──▶ archived
|
|
3
|
+
// Versions are immutable publish history; drafts are a flat set of working copies under random
|
|
4
|
+
// ids, listed by prefix and NEVER recorded in the meta (a meta entry per draft would make the
|
|
5
|
+
// meta a shared write hotspot between concurrent editors). Every mutation here checks the meta
|
|
6
|
+
// or the stored draft first, and the dev provider independently enforces the same rules on its
|
|
7
|
+
// side of the trust boundary.
|
|
8
|
+
import { DiInject, DiService, SingleDiToken } from '@mosetta/di';
|
|
9
|
+
import {
|
|
10
|
+
ContentKind,
|
|
11
|
+
IContentEntity,
|
|
12
|
+
IContentMeta,
|
|
13
|
+
IDraftSummary,
|
|
14
|
+
ILoadedContent,
|
|
15
|
+
ILoadedDraft,
|
|
16
|
+
maxRevision,
|
|
17
|
+
PrefabType,
|
|
18
|
+
publishedFormOf,
|
|
19
|
+
RevisionStatus,
|
|
20
|
+
} from './model';
|
|
21
|
+
import { draftPath, draftsPrefix, entityPath, GENERATION_PATH, goneKey, listPrefix, metaPath, parsePublishedKey, PUBLISHED_PREFIX, publishedKey, underFrozenRoute, versionPath } from './paths';
|
|
22
|
+
import { EMPTY_ROOTS, parseRoots, ROOTS_PATH, rootsEntryOf, withRootsEntry } from './roots';
|
|
23
|
+
import type { IRootsEntry, IBlueprintManifest } from './roots';
|
|
24
|
+
import { CONTENT_TRANSPORT_TOKEN, ContentTransportError } from './transport';
|
|
25
|
+
import { draftRejection } from './policy';
|
|
26
|
+
import { draftTitleOf, revisionTitleOf } from './names';
|
|
27
|
+
// import type: the name sits in a DECORATED signature — emitDecoratorMetadata under
|
|
28
|
+
// isolatedModules refuses a value-import there (TS1272); di uses explicit tokens, no metadata
|
|
29
|
+
import type { IContentTransport } from './transport';
|
|
30
|
+
|
|
31
|
+
export type ContentErrorCode = 'NOT_FOUND' | 'ALREADY_EXISTS' | 'NO_REVISION' | 'NO_DRAFT' | 'CONFLICT' | 'MEDIA' | 'LOCKED' | 'TOO_LARGE';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The largest document that may be PUBLISHED, as JSON. A site renders its pages in one isolate of
|
|
35
|
+
* about 128 MB: a document of 6 MB (30 000 nodes) took 5–6 s to render, one of 12 MB ran the
|
|
36
|
+
* isolate out of memory every other request. A draft may grow past it; publishing it is refused.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_PUBLISHED_BYTES = 4 * 1024 * 1024;
|
|
39
|
+
|
|
40
|
+
export class ContentError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
readonly code: ContentErrorCode,
|
|
43
|
+
message: string,
|
|
44
|
+
) {
|
|
45
|
+
super(message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const CONTENT_REPOSITORY_TOKEN = new SingleDiToken<ContentRepository>('CONTENT_REPOSITORY_TOKEN');
|
|
50
|
+
|
|
51
|
+
@DiService(CONTENT_REPOSITORY_TOKEN)
|
|
52
|
+
export class ContentRepository {
|
|
53
|
+
constructor(
|
|
54
|
+
@DiInject(CONTENT_TRANSPORT_TOKEN)
|
|
55
|
+
private readonly transport: IContentTransport,
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
/** every entity with its meta and drafts — the entities read side by side, not one after another */
|
|
59
|
+
async list(kind: ContentKind): Promise<IContentEntity[]> {
|
|
60
|
+
const names = await this.transport.list(listPrefix(kind));
|
|
61
|
+
const entities = await Promise.all(names.map(async (name): Promise<IContentEntity | null> => {
|
|
62
|
+
const [meta, drafts] = await Promise.all([
|
|
63
|
+
this.transport.read(metaPath(kind, name)) as Promise<IContentMeta | null>,
|
|
64
|
+
this.listDrafts(kind, name),
|
|
65
|
+
]);
|
|
66
|
+
return meta ? { name, meta, drafts } : null;
|
|
67
|
+
}));
|
|
68
|
+
return entities.filter((e): e is IContentEntity => e !== null);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** the entity's drafts: id + working fields (and a page's route), read side by side */
|
|
72
|
+
async listDrafts(kind: ContentKind, name: string): Promise<IDraftSummary[]> {
|
|
73
|
+
const ids = await this.transport.list(draftsPrefix(kind, name));
|
|
74
|
+
const drafts = await Promise.all(ids.map(async (id): Promise<IDraftSummary | null> => {
|
|
75
|
+
const doc = (await this.transport.read(draftPath(kind, name, id))) as (Partial<IDraftSummary> & { pattern?: unknown }) | null;
|
|
76
|
+
if (!doc) return null;
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
seq: Number.isInteger(doc.seq) ? doc.seq! : 1,
|
|
80
|
+
createdAt: typeof doc.createdAt === 'string' ? doc.createdAt : '',
|
|
81
|
+
...(Number.isInteger(doc.baseRev) ? { baseRev: doc.baseRev } : {}),
|
|
82
|
+
...(typeof doc.title === 'string' ? { title: doc.title } : {}),
|
|
83
|
+
...(typeof doc.pattern === 'string' ? { pattern: doc.pattern } : {}),
|
|
84
|
+
};
|
|
85
|
+
}));
|
|
86
|
+
return drafts.filter((d): d is IDraftSummary => d !== null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** the routes of the live pages, by page name — read off the publish markers, no document opened */
|
|
90
|
+
async livePatterns(): Promise<Map<string, string>> {
|
|
91
|
+
const out = new Map<string, string>();
|
|
92
|
+
for (const key of await this.transport.keys(PUBLISHED_PREFIX)) {
|
|
93
|
+
const route = parsePublishedKey(key.slice(PUBLISHED_PREFIX.length));
|
|
94
|
+
if (route) out.set(route.name, route.pattern);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** rev omitted → live; only published/archived versions live on this axis */
|
|
100
|
+
async load<TDoc>(kind: ContentKind, name: string, rev?: number): Promise<ILoadedContent<TDoc>> {
|
|
101
|
+
const meta = await this.requireMeta(kind, name);
|
|
102
|
+
const revToLoad = rev ?? meta.live;
|
|
103
|
+
if (revToLoad == null || !meta.versions[String(revToLoad)]) {
|
|
104
|
+
throw new ContentError('NO_REVISION', `${kind}/${name} has no published revision ${revToLoad ?? '(none)'}`);
|
|
105
|
+
}
|
|
106
|
+
const doc = await this.transport.read(versionPath(kind, name, revToLoad));
|
|
107
|
+
if (doc == null) {
|
|
108
|
+
throw new ContentError('NO_REVISION', `${kind}/${name} revision ${revToLoad} file is missing`);
|
|
109
|
+
}
|
|
110
|
+
return { name, meta, rev: revToLoad, doc: doc as TDoc };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async loadDraft<TDoc>(kind: ContentKind, name: string, id: string): Promise<ILoadedDraft<TDoc>> {
|
|
114
|
+
await this.requireMeta(kind, name);
|
|
115
|
+
const doc = await this.transport.read(draftPath(kind, name, id));
|
|
116
|
+
if (doc == null) throw new ContentError('NO_DRAFT', `${kind}/${name} has no draft ${id}`);
|
|
117
|
+
return { name, id, doc: doc as TDoc };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** creates the entity: an empty publish history and the given doc as its first draft */
|
|
121
|
+
async create(kind: ContentKind, name: string, doc: unknown, type?: PrefabType): Promise<string> {
|
|
122
|
+
const existing = await this.transport.read(metaPath(kind, name));
|
|
123
|
+
if (existing) throw new ContentError('ALREADY_EXISTS', `${kind}/${name} already exists`);
|
|
124
|
+
const meta: IContentMeta = { live: null, versions: {}, ...(type ? { type } : {}) };
|
|
125
|
+
// meta first: the provider's enforcement admits draft writes only for entities it knows
|
|
126
|
+
await this.transport.write(metaPath(kind, name), meta);
|
|
127
|
+
return this.putFreshDraft(kind, name, doc as object);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The save is the CONFLICT gate: the doc must carry `seq = stored.seq + 1`. Checked HERE, before
|
|
132
|
+
* the write, whatever store sits behind the transport: a plain object store (R2 over S3) writes
|
|
133
|
+
* whatever it is given, and only the dev store refused a broken chain itself (412). Without the
|
|
134
|
+
* check a save into a draft discarded or published meanwhile silently brought it back, and two
|
|
135
|
+
* tabs overwrote each other. A draft that is gone answers CONFLICT too — the editor offers to
|
|
136
|
+
* fork the working copy. (Read-then-write: the window between them is the store's to close,
|
|
137
|
+
* with a conditional write, once a transport offers one.)
|
|
138
|
+
*/
|
|
139
|
+
async saveDraft(kind: ContentKind, name: string, id: string, doc: unknown): Promise<void> {
|
|
140
|
+
await this.requireUnlocked(kind, name);
|
|
141
|
+
const stored = await this.transport.read(draftPath(kind, name, id));
|
|
142
|
+
const rejection = stored == null
|
|
143
|
+
? `${kind}/${name} draft ${id} is gone (discarded or published)`
|
|
144
|
+
: draftRejection(stored, doc);
|
|
145
|
+
if (rejection) throw new ContentError('CONFLICT', rejection);
|
|
146
|
+
try {
|
|
147
|
+
await this.transport.write(draftPath(kind, name, id), doc);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e instanceof ContentTransportError && e.status === 412) {
|
|
150
|
+
throw new ContentError('CONFLICT', `${kind}/${name} draft ${id}: ${e.message}`);
|
|
151
|
+
}
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** copies a PUBLISHED version (default: live) into a fresh draft */
|
|
157
|
+
async createDraft(kind: ContentKind, name: string, fromRev?: number): Promise<{ id: string; doc: unknown }> {
|
|
158
|
+
const meta = await this.requireUnlocked(kind, name);
|
|
159
|
+
const sourceRev = fromRev ?? meta.live;
|
|
160
|
+
if (sourceRev == null || !meta.versions[String(sourceRev)]) {
|
|
161
|
+
throw new ContentError('NO_REVISION', `${kind}/${name} has no published revision ${sourceRev ?? '(none)'}`);
|
|
162
|
+
}
|
|
163
|
+
const source = await this.transport.read(versionPath(kind, name, sourceRev));
|
|
164
|
+
if (source == null) {
|
|
165
|
+
throw new ContentError('NO_REVISION', `${kind}/${name} revision ${sourceRev} file is missing`);
|
|
166
|
+
}
|
|
167
|
+
return this.putDraft(kind, name, { ...(source), baseRev: sourceRev });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Writes the GIVEN doc as a new draft under a fresh identity — the conflict modal's "fork"
|
|
172
|
+
* button: the local working copy (undo stacks and lineage included) survives under its own id.
|
|
173
|
+
*/
|
|
174
|
+
async forkDraft(kind: ContentKind, name: string, doc: unknown): Promise<{ id: string; doc: unknown }> {
|
|
175
|
+
await this.requireUnlocked(kind, name);
|
|
176
|
+
return this.putDraft(kind, name, doc as object);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Promotes a draft into the NEXT published version; the previous live becomes archived, the
|
|
181
|
+
* draft is deleted — it has become a version. Working fields (seq, undo stacks, …) are stripped:
|
|
182
|
+
* a published version is pure content.
|
|
183
|
+
*/
|
|
184
|
+
/** the draft becomes the next revision; `title` — the name the publication goes by, if any */
|
|
185
|
+
async publish(kind: ContentKind, name: string, draftId: string, title?: string): Promise<number> {
|
|
186
|
+
const meta = await this.requireUnlocked(kind, name);
|
|
187
|
+
const { doc } = await this.loadDraft(kind, name, draftId);
|
|
188
|
+
if (kind === 'pages') await this.refuseFrozenRoute(name, (doc as { pattern?: unknown }).pattern);
|
|
189
|
+
const rev = (maxRevision(meta) ?? 0) + 1;
|
|
190
|
+
const published = publishedFormOf(doc as object);
|
|
191
|
+
const bytes = new TextEncoder().encode(JSON.stringify(published)).length;
|
|
192
|
+
if (bytes > MAX_PUBLISHED_BYTES) {
|
|
193
|
+
throw new ContentError('TOO_LARGE', `${kind}/${name}: the page is ${Math.ceil(bytes / 1024)} KiB — a published page may be at most ${MAX_PUBLISHED_BYTES / 1024} KiB; split it into several pages`);
|
|
194
|
+
}
|
|
195
|
+
// the version file first: the rev is unknown to the meta, so the store admits the write;
|
|
196
|
+
// the meta write below is what makes it immutable
|
|
197
|
+
await this.transport.write(versionPath(kind, name, rev), published);
|
|
198
|
+
const versions: Record<string, RevisionStatus> = { ...meta.versions, [String(rev)]: 'published' };
|
|
199
|
+
if (meta.live != null && meta.live !== rev) versions[String(meta.live)] = 'archived';
|
|
200
|
+
const named = revisionTitleOf(title);
|
|
201
|
+
const names = named ? { ...meta.names, [String(rev)]: named } : meta.names;
|
|
202
|
+
await this.transport.write(metaPath(kind, name), { ...meta, live: rev, versions, ...(names ? { names } : {}) });
|
|
203
|
+
await this.transport.delete(draftPath(kind, name, draftId));
|
|
204
|
+
if (kind === 'pages') {
|
|
205
|
+
const { pattern } = doc as { pattern?: unknown };
|
|
206
|
+
await this.syncMarkers(name, { pattern, rev });
|
|
207
|
+
// the route lives again: it is no longer gone
|
|
208
|
+
if (typeof pattern === 'string') await this.transport.delete(goneKey(pattern));
|
|
209
|
+
}
|
|
210
|
+
await this.syncRoots(kind, name, rootsEntryOf(kind, publishedFormOf(doc as object)));
|
|
211
|
+
await this.markGeneration();
|
|
212
|
+
return rev;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The project's roots (ADR-0052) follow the live site: the entity's entry is set (published) or
|
|
217
|
+
* removed (deleted). A store that keeps no such file — dev derives it — refuses, and that is
|
|
218
|
+
* fine: the file is a hint the site isolate starts from, never what a render depends on.
|
|
219
|
+
*/
|
|
220
|
+
private async syncRoots(kind: ContentKind, name: string, entry: IRootsEntry | null): Promise<void> {
|
|
221
|
+
try {
|
|
222
|
+
let stored: unknown;
|
|
223
|
+
try {
|
|
224
|
+
stored = await this.transport.read(ROOTS_PATH);
|
|
225
|
+
} catch {
|
|
226
|
+
return; // the file cannot be read now: left as it is rather than overwritten blind
|
|
227
|
+
}
|
|
228
|
+
// a store without the file yet: the whole live site first — a file naming one entity would
|
|
229
|
+
// tell every site isolate that nothing else is published
|
|
230
|
+
const current = stored == null ? await this.deriveRoots() : parseRoots(stored);
|
|
231
|
+
await this.transport.write(ROOTS_PATH, withRootsEntry(current, kind, name, entry));
|
|
232
|
+
} catch {
|
|
233
|
+
// the store derives the file itself (dev), or refuses it — the hint is not kept here
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** the roots of the live site as the store holds it: every entity's live revision */
|
|
238
|
+
private async deriveRoots(): Promise<IBlueprintManifest> {
|
|
239
|
+
let file = EMPTY_ROOTS;
|
|
240
|
+
for (const kind of ['pages', 'prefabs'] as const) {
|
|
241
|
+
for (const entity of await this.transport.list(listPrefix(kind))) {
|
|
242
|
+
const meta = await this.transport.read(metaPath(kind, entity)) as IContentMeta | null;
|
|
243
|
+
if (meta?.live == null) continue;
|
|
244
|
+
const doc = await this.transport.read(versionPath(kind, entity, meta.live));
|
|
245
|
+
if (doc != null) file = withRootsEntry(file, kind, entity, rootsEntryOf(kind, doc));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return file;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The live site changed: a new generation marker (GENERATION_PATH), so readers drop what they
|
|
253
|
+
* kept. A store that keeps none (dev, which reads everything anew anyway) refuses it — no matter.
|
|
254
|
+
*/
|
|
255
|
+
private async markGeneration(): Promise<boolean> {
|
|
256
|
+
try {
|
|
257
|
+
await this.transport.write(GENERATION_PATH, { at: new Date().toISOString(), id: crypto.randomUUID() });
|
|
258
|
+
return true;
|
|
259
|
+
} catch {
|
|
260
|
+
return false; // no marker kept — readers read anew on every request
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A new generation marker without a publish: whatever readers kept of the live site — a page's
|
|
266
|
+
* render, the route table, the site's isolate — is read anew. The remedy for a change made to
|
|
267
|
+
* the store by hand. False — the store keeps no marker (dev): nothing is kept there anyway.
|
|
268
|
+
*/
|
|
269
|
+
refreshLiveSite(): Promise<boolean> {
|
|
270
|
+
return this.markGeneration();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The route table is a listing of publish MARKERS (`pages/published/<pattern>~<name>~<rev>`,
|
|
275
|
+
* see paths.ts): the live revision of a page owns exactly one. The new marker lands before the
|
|
276
|
+
* stale ones go, so a concurrent request never sees the page routeless. A store that derives
|
|
277
|
+
* the listing itself (dev) treats these writes as no-ops.
|
|
278
|
+
*/
|
|
279
|
+
private async syncMarkers(name: string, live: { pattern: unknown; rev: number } | null): Promise<void> {
|
|
280
|
+
const wanted = live && typeof live.pattern === 'string' ? `${PUBLISHED_PREFIX}${publishedKey({ pattern: live.pattern, name, rev: live.rev })}` : null;
|
|
281
|
+
if (wanted) await this.transport.write(wanted, {});
|
|
282
|
+
const stale = (await this.transport.keys(PUBLISHED_PREFIX))
|
|
283
|
+
.filter((key) => key !== wanted && parsePublishedKey(key.slice(PUBLISHED_PREFIX.length))?.name === name);
|
|
284
|
+
for (const key of stale) await this.transport.delete(key);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async discardDraft(kind: ContentKind, name: string, id: string): Promise<void> {
|
|
288
|
+
await this.requireMeta(kind, name);
|
|
289
|
+
await this.transport.delete(draftPath(kind, name, id));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** removes the entity with its whole history (the provider deletes the entity folder) */
|
|
293
|
+
async delete(kind: ContentKind, name: string): Promise<void> {
|
|
294
|
+
const meta = await this.requireUnlocked(kind, name);
|
|
295
|
+
// a LIVE page leaves its route GONE — unless another live page answers at it
|
|
296
|
+
let gone: string | null = null;
|
|
297
|
+
if (kind === 'pages' && meta.live != null) {
|
|
298
|
+
const live = (await this.transport.read(versionPath(kind, name, meta.live))) as { pattern?: unknown } | null;
|
|
299
|
+
if (typeof live?.pattern === 'string') gone = live.pattern;
|
|
300
|
+
}
|
|
301
|
+
await this.transport.delete(entityPath(kind, name));
|
|
302
|
+
if (kind === 'pages') {
|
|
303
|
+
await this.syncMarkers(name, null);
|
|
304
|
+
if (gone !== null) {
|
|
305
|
+
const heldElsewhere = (await this.transport.keys(PUBLISHED_PREFIX))
|
|
306
|
+
.some((key) => parsePublishedKey(key.slice(PUBLISHED_PREFIX.length))?.pattern === gone);
|
|
307
|
+
if (!heldElsewhere) await this.transport.write(goneKey(gone), {});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
await this.syncRoots(kind, name, null);
|
|
311
|
+
await this.markGeneration();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** the meta of an entity the editors may change — a frozen one answers LOCKED */
|
|
315
|
+
private async requireUnlocked(kind: ContentKind, name: string): Promise<IContentMeta> {
|
|
316
|
+
const meta = await this.requireMeta(kind, name);
|
|
317
|
+
if (meta.locked) throw new ContentError('LOCKED', `${kind}/${name} belongs to the platform and cannot be changed`);
|
|
318
|
+
return meta;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A frozen page's route is its own: another page publishing at it — or anywhere under it, when
|
|
323
|
+
* the frozen page answers for a subtree (underFrozenRoute) — would take its place.
|
|
324
|
+
*/
|
|
325
|
+
private async refuseFrozenRoute(name: string, pattern: unknown): Promise<void> {
|
|
326
|
+
if (typeof pattern !== 'string') return;
|
|
327
|
+
const holders = (await this.transport.keys(PUBLISHED_PREFIX))
|
|
328
|
+
.map((key) => parsePublishedKey(key.slice(PUBLISHED_PREFIX.length)))
|
|
329
|
+
.filter((route) => route !== null && route.name !== name && underFrozenRoute(pattern, route.pattern));
|
|
330
|
+
for (const route of holders) {
|
|
331
|
+
const meta = (await this.transport.read(metaPath('pages', route!.name))) as IContentMeta | null;
|
|
332
|
+
if (meta?.locked) throw new ContentError('LOCKED', `${pattern} belongs to the platform's page ${route!.name}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async requireMeta(kind: ContentKind, name: string): Promise<IContentMeta> {
|
|
337
|
+
const meta = (await this.transport.read(metaPath(kind, name))) as IContentMeta | null;
|
|
338
|
+
if (!meta) throw new ContentError('NOT_FOUND', `${kind}/${name} does not exist`);
|
|
339
|
+
return meta;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** a fresh draft from a doc that carries no lineage of its own */
|
|
343
|
+
private putFreshDraft(kind: ContentKind, name: string, doc: object): Promise<string> {
|
|
344
|
+
return this.putDraft(kind, name, doc).then(({ id }) => id);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Stamps a fresh identity and chain start (random id — creation needs no coordination), and a
|
|
349
|
+
* NAME of two words drawn from the id: a draft is known by words, never by its id (a copy of a
|
|
350
|
+
* named draft gets words of its own, so two drafts never go by the same ones by accident)
|
|
351
|
+
*/
|
|
352
|
+
private async putDraft(kind: ContentKind, name: string, doc: object): Promise<{ id: string; doc: unknown }> {
|
|
353
|
+
const id = crypto.randomUUID();
|
|
354
|
+
const stamped = { ...doc, seq: 1, createdAt: new Date().toISOString(), title: draftTitleOf(id) };
|
|
355
|
+
await this.transport.write(draftPath(kind, name, id), stamped);
|
|
356
|
+
return { id, doc: stamped };
|
|
357
|
+
}
|
|
358
|
+
}
|