@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/roots.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The project's ROOTS (ADR-0052): what every published page and prefab needs of the code, kept
|
|
2
|
+
// beside the generation marker as `roots.json`. A page's entry names its root module (the ssr
|
|
3
|
+
// engine follows it) and the modules its nodes reference where the server renders them; a
|
|
4
|
+
// prefab's entry names the modules of its blocks. The site isolate is composed with the closure
|
|
5
|
+
// of their union — the code of the published site, and nothing else — so a block the site uses
|
|
6
|
+
// is in place before the first visit. Written by every publish and deletion; a store that keeps
|
|
7
|
+
// no such file (dev) derives it from the live revisions when asked.
|
|
8
|
+
//
|
|
9
|
+
// A HINT, not a contract: a module missing from it (a library's prefab, a stale file) is learned
|
|
10
|
+
// by recomposition at the first visit — a render never depends on the file being right.
|
|
11
|
+
import type { ContentKind } from './model';
|
|
12
|
+
|
|
13
|
+
export const ROOTS_PATH = 'roots.json';
|
|
14
|
+
|
|
15
|
+
export interface IRootsEntry {
|
|
16
|
+
/** a page's root module (a prefab has none of its own on a page) */
|
|
17
|
+
readonly root?: string;
|
|
18
|
+
/** the component modules (and fallbacks) its server-rendered nodes reference */
|
|
19
|
+
readonly refs: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IBlueprintManifest {
|
|
23
|
+
readonly v: 1;
|
|
24
|
+
readonly pages: Readonly<Record<string, IRootsEntry>>;
|
|
25
|
+
readonly prefabs: Readonly<Record<string, IRootsEntry>>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const EMPTY_ROOTS: IBlueprintManifest = { v: 1, pages: {}, prefabs: {} };
|
|
29
|
+
|
|
30
|
+
interface INodeLike {
|
|
31
|
+
readonly reference?: unknown;
|
|
32
|
+
readonly fallback?: unknown;
|
|
33
|
+
readonly ssr?: unknown;
|
|
34
|
+
readonly lazy?: unknown;
|
|
35
|
+
readonly children?: readonly INodeLike[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** a module a render links: a component (prefab references are expanded, never linked) */
|
|
39
|
+
const isLinkable = (ref: unknown): ref is string => typeof ref === 'string' && /_t_component_/.test(ref);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The modules a subtree needs where the server renders it — the rule the server's closure
|
|
43
|
+
* follows: a branch marked `ssr: false` or a lazy one is the browser's.
|
|
44
|
+
*/
|
|
45
|
+
function serverRefsOf(node: INodeLike, refs: Set<string>): void {
|
|
46
|
+
if (node.ssr === false || node.lazy === true) return;
|
|
47
|
+
if (isLinkable(node.reference)) refs.add(node.reference);
|
|
48
|
+
if (isLinkable(node.fallback)) refs.add(node.fallback);
|
|
49
|
+
for (const child of node.children ?? []) serverRefsOf(child, refs);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The entry of one published document: a page's root and, when the page renders on the server
|
|
54
|
+
* (`ssr: true` on its root), the modules of its tree; a prefab's blocks (it renders within a page,
|
|
55
|
+
* so whatever page it lands in decides whether the server renders — its blocks are named either way).
|
|
56
|
+
*/
|
|
57
|
+
export function rootsEntryOf(kind: ContentKind, doc: unknown): IRootsEntry {
|
|
58
|
+
const refs = new Set<string>();
|
|
59
|
+
if (kind === 'prefabs') {
|
|
60
|
+
const root = (doc as { root?: INodeLike } | null)?.root;
|
|
61
|
+
for (const child of root?.children ?? []) serverRefsOf(child, refs);
|
|
62
|
+
if (root && isLinkable(root.fallback)) refs.add(root.fallback);
|
|
63
|
+
return { refs: [...refs].sort() };
|
|
64
|
+
}
|
|
65
|
+
const page = (doc ?? {}) as INodeLike;
|
|
66
|
+
const root = isLinkable(page.reference) ? page.reference : undefined;
|
|
67
|
+
if (page.ssr === true) serverRefsOf(page, refs);
|
|
68
|
+
else if (isLinkable(page.fallback)) refs.add(page.fallback);
|
|
69
|
+
return { ...(root ? { root } : {}), refs: [...refs].sort() };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** the file with one entity's entry set (or removed, `entry` null) */
|
|
73
|
+
export function withRootsEntry(file: IBlueprintManifest, kind: ContentKind, name: string, entry: IRootsEntry | null): IBlueprintManifest {
|
|
74
|
+
const own = { ...file[kind] };
|
|
75
|
+
if (entry) own[name] = entry;
|
|
76
|
+
else delete own[name];
|
|
77
|
+
return { ...file, [kind]: own };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** a file as read — tolerant: anything malformed is no entry, never a failure */
|
|
81
|
+
export function parseRoots(raw: unknown): IBlueprintManifest {
|
|
82
|
+
if (!raw || typeof raw !== 'object') return EMPTY_ROOTS;
|
|
83
|
+
const section = (value: unknown): Record<string, IRootsEntry> => {
|
|
84
|
+
const out: Record<string, IRootsEntry> = {};
|
|
85
|
+
if (!value || typeof value !== 'object') return out;
|
|
86
|
+
for (const [name, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
87
|
+
const e = entry as { root?: unknown; refs?: unknown } | null;
|
|
88
|
+
if (!e || !Array.isArray(e.refs)) continue;
|
|
89
|
+
out[name] = { ...(isLinkable(e.root) ? { root: e.root } : {}), refs: e.refs.filter(isLinkable) };
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
};
|
|
93
|
+
const file = raw as { pages?: unknown; prefabs?: unknown };
|
|
94
|
+
return { v: 1, pages: section(file.pages), prefabs: section(file.prefabs) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** every module the published site names: the roots and the references, once each */
|
|
98
|
+
export function referencesOfRoots(file: IBlueprintManifest): string[] {
|
|
99
|
+
const all = new Set<string>();
|
|
100
|
+
for (const entry of [...Object.values(file.pages), ...Object.values(file.prefabs)]) {
|
|
101
|
+
if (entry.root) all.add(entry.root);
|
|
102
|
+
for (const ref of entry.refs) all.add(ref);
|
|
103
|
+
}
|
|
104
|
+
return [...all].sort();
|
|
105
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Zod schema FACTORIES. Zod is injected (the isolate glue carries
|
|
2
|
+
// zod, monoliths stay lean) — this package has zero runtime deps. An action embeds these into its input
|
|
3
|
+
// schema via `createSchema({ builder })`, so deep structural validation happens at the RPC boundary.
|
|
4
|
+
import type { z as Z } from 'zod';
|
|
5
|
+
import {
|
|
6
|
+
CANONICAL_REFERENCE_RE,
|
|
7
|
+
DRAFT_ID_RE,
|
|
8
|
+
ENTITY_NAME_RE,
|
|
9
|
+
IBlueprintNodeDoc,
|
|
10
|
+
PAGE_HISTORY_LIMIT,
|
|
11
|
+
} from './model';
|
|
12
|
+
|
|
13
|
+
export function createContentSchemas(z: typeof Z) {
|
|
14
|
+
const canonicalReference = z.string().regex(
|
|
15
|
+
CANONICAL_REFERENCE_RE,
|
|
16
|
+
'expected a canonical module reference (_ns_<ns>_t_<type>_n_<name>_v_<version>)',
|
|
17
|
+
);
|
|
18
|
+
const entityName = z.string().regex(ENTITY_NAME_RE, 'expected a lowerCamel alphanumeric entity name');
|
|
19
|
+
const revision = z.number().int().positive();
|
|
20
|
+
const draftId = z.string().regex(DRAFT_ID_RE, 'expected a draft id (lowercase alphanumeric/hyphen)');
|
|
21
|
+
const prefabType = z.enum(['page', 'unit']);
|
|
22
|
+
// what an editing action is about: a page, or a prefab (edited in a page's shape)
|
|
23
|
+
const contentKind = z.enum(['pages', 'prefabs']);
|
|
24
|
+
const revisionStatus = z.enum(['published', 'archived']);
|
|
25
|
+
const settings = z.record(z.string(), z.unknown());
|
|
26
|
+
|
|
27
|
+
// the working fields a DRAFT carries on top of its content (publish strips them); declared on
|
|
28
|
+
// the doc schemas for the same reason history is — zod strips unknown keys on save otherwise
|
|
29
|
+
const draftFields = {
|
|
30
|
+
seq: z.number().int().positive().optional(),
|
|
31
|
+
createdAt: z.string().optional(),
|
|
32
|
+
baseRev: z.number().int().positive().optional(),
|
|
33
|
+
title: z.string().max(200).optional(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const treeNode: Z.ZodType<IBlueprintNodeDoc> = z.lazy(() => z.object({
|
|
37
|
+
id: z.string().min(1),
|
|
38
|
+
reference: canonicalReference,
|
|
39
|
+
children: z.array(treeNode),
|
|
40
|
+
slot: z.string().min(1).optional(),
|
|
41
|
+
settings: settings.optional(),
|
|
42
|
+
// effective-ssr flag (SSR epic stage 4) — zod strips unknown keys, so the field MUST be
|
|
43
|
+
// declared or every save silently loses it
|
|
44
|
+
ssr: z.boolean().optional(),
|
|
45
|
+
// a provider that links new blocks' fields of its type at once (the editor's, see the model)
|
|
46
|
+
autoBind: z.boolean().optional(),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// what a page says about itself (head tags, list entries): declared so a save keeps it
|
|
50
|
+
const metaWords = {
|
|
51
|
+
title: z.string().max(300).optional(),
|
|
52
|
+
description: z.string().max(1000).optional(),
|
|
53
|
+
};
|
|
54
|
+
const pageMeta = z.object({
|
|
55
|
+
...metaWords,
|
|
56
|
+
image: z.string().max(2000).optional(),
|
|
57
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD').optional(),
|
|
58
|
+
noindex: z.boolean().optional(),
|
|
59
|
+
category: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/, 'category: lowercase letters, digits, - and _').optional(),
|
|
60
|
+
i18n: z.record(z.string().regex(/^[a-z]{2}(-[a-z]{2})?$/, 'a language code'), z.object(metaWords)).optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// a prefab's open field: which block's which setting an instance sets, and what its form says
|
|
64
|
+
const prefabField = z.object({
|
|
65
|
+
node: z.string().min(1).max(200),
|
|
66
|
+
setting: z.string().regex(/^[A-Za-z][A-Za-z0-9]*$/, 'a setting name'),
|
|
67
|
+
label: z.string().min(1).max(100),
|
|
68
|
+
});
|
|
69
|
+
const prefabFields = z.array(prefabField).max(50);
|
|
70
|
+
|
|
71
|
+
const pageBody = z.object({
|
|
72
|
+
id: z.string().min(1),
|
|
73
|
+
reference: canonicalReference,
|
|
74
|
+
pattern: z.string().regex(/^\//, 'route pattern must start with /'),
|
|
75
|
+
meta: pageMeta.optional(),
|
|
76
|
+
fallback: canonicalReference.optional(),
|
|
77
|
+
devOnly: z.boolean().optional(),
|
|
78
|
+
settings: settings.optional(),
|
|
79
|
+
children: z.array(treeNode),
|
|
80
|
+
ssr: z.boolean().optional(),
|
|
81
|
+
// a prefab opened in the editor travels in a page's shape, its open fields along
|
|
82
|
+
fields: prefabFields.optional(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const pageAction = z.object({
|
|
86
|
+
type: z.enum(['insert', 'remove', 'settings', 'ssr', 'paste', 'cut', 'move']),
|
|
87
|
+
nodeId: z.string().min(1),
|
|
88
|
+
reference: canonicalReference.optional(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// history, redo and the draft fields are DECLARED here for the same reason ssr is: zod strips
|
|
92
|
+
// unknown keys, and every save would silently lose them otherwise. A snapshot is a page WITHOUT
|
|
93
|
+
// either stack — entries never nest. The page snapshot inside an entry may carry draft fields
|
|
94
|
+
// (they ride along in the working doc), hence `pageBody.extend(draftFields)` there.
|
|
95
|
+
const pageSnapshot = pageBody.extend(draftFields);
|
|
96
|
+
const pageDoc = pageSnapshot.extend({
|
|
97
|
+
history: z.array(z.object({ action: pageAction, before: pageSnapshot })).max(PAGE_HISTORY_LIMIT).optional(),
|
|
98
|
+
redo: z.array(z.object({ action: pageAction, after: pageSnapshot })).max(PAGE_HISTORY_LIMIT).optional(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const prefabDoc = z.object({
|
|
102
|
+
root: treeNode,
|
|
103
|
+
fields: prefabFields.optional(),
|
|
104
|
+
attributes: z.record(z.string(), z.unknown()).optional(),
|
|
105
|
+
devOnly: z.boolean().optional(),
|
|
106
|
+
...draftFields,
|
|
107
|
+
history: pageDoc.shape.history,
|
|
108
|
+
redo: pageDoc.shape.redo,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const meta = z.object({
|
|
112
|
+
live: z.number().int().positive().nullable(),
|
|
113
|
+
versions: z.record(z.string().regex(/^[1-9][0-9]*$/), revisionStatus),
|
|
114
|
+
names: z.record(z.string().regex(/^[1-9][0-9]*$/), z.string().max(80)).optional(),
|
|
115
|
+
type: prefabType.optional(),
|
|
116
|
+
locked: z.boolean().optional(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { canonicalReference, entityName, revision, draftId, prefabType, contentKind, revisionStatus, treeNode, pageDoc, prefabDoc, meta };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type ContentSchemas = ReturnType<typeof createContentSchemas>;
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Content transport (generalizes the earlier write-only content store).
|
|
2
|
+
// NOT an ambient framework capability — the framework/glue registers nothing writable. A TRUSTED module
|
|
3
|
+
// (the editor) opts IN by adding a transport implementation to its `services`; an ordinary module has no
|
|
4
|
+
// transport (injecting the token throws → no write access blessed by default). Prod: an S3 transport
|
|
5
|
+
// gated by a store-access secret from a secret store (deferred — just the seam).
|
|
6
|
+
import { DiInject, DiService, SingleDiToken } from '@mosetta/di';
|
|
7
|
+
import { PROVIDER_ORIGIN_TOKEN, PROVIDERS_TOKEN } from '@mosetta/server-action/server';
|
|
8
|
+
import { StoreConnectors, parseStoreConnectors } from '@mosetta/stores';
|
|
9
|
+
import type { IStoreConnector, ConnectorHandle, StoreOp } from '@mosetta/stores';
|
|
10
|
+
import { metaPath } from './paths';
|
|
11
|
+
import type { ContentKind } from './model';
|
|
12
|
+
|
|
13
|
+
export interface IContentTransport {
|
|
14
|
+
/** read a JSON document; null when it does not exist */
|
|
15
|
+
read(path: string): Promise<unknown>;
|
|
16
|
+
write(path: string, json: unknown): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* list names under a trailing-slash prefix: entity names for a kind prefix (`pages/`),
|
|
19
|
+
* draft ids for a drafts prefix (`pages/<name>/drafts/`)
|
|
20
|
+
*/
|
|
21
|
+
list(prefix: string): Promise<string[]>;
|
|
22
|
+
/** delete a draft file or a whole entity folder */
|
|
23
|
+
delete(path: string): Promise<void>;
|
|
24
|
+
/** RAW object keys under a prefix (no domain reconstruction) — for the publish-marker folder */
|
|
25
|
+
keys(prefix: string): Promise<string[]>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const CONTENT_TRANSPORT_TOKEN = new SingleDiToken<IContentTransport>('CONTENT_TRANSPORT_TOKEN');
|
|
29
|
+
|
|
30
|
+
// Thrown on non-ok transport responses; `status` lets the repository map the provider's state-machine
|
|
31
|
+
// enforcement (409 = immutable revision) to a domain error.
|
|
32
|
+
export class ContentTransportError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
readonly status: number,
|
|
35
|
+
message: string,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// DEV transport over the PROVIDER MODEL: providers arrive from the isolate ENV AS IS (including
|
|
42
|
+
// the write entry — until a system of selective secret provisioning per module exists); without
|
|
43
|
+
// them (old compositions) — fall back to the self-description descriptor. Selection is by the
|
|
44
|
+
// REQUIRED operation; in prod only the connector changes.
|
|
45
|
+
@DiService(CONTENT_TRANSPORT_TOKEN)
|
|
46
|
+
export class DevContentTransport implements IContentTransport {
|
|
47
|
+
private readonly origin: string;
|
|
48
|
+
|
|
49
|
+
private readonly envProviders: readonly IStoreConnector[];
|
|
50
|
+
|
|
51
|
+
private readonly fetchImpl: typeof fetch;
|
|
52
|
+
|
|
53
|
+
private manager: Promise<StoreConnectors> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
@DiInject(PROVIDER_ORIGIN_TOKEN)
|
|
57
|
+
providerOrigin: string,
|
|
58
|
+
@DiInject(PROVIDERS_TOKEN)
|
|
59
|
+
providers: readonly IStoreConnector[],
|
|
60
|
+
) {
|
|
61
|
+
this.origin = providerOrigin.replace(/\/$/, '');
|
|
62
|
+
this.envProviders = providers;
|
|
63
|
+
// bind to globalThis — the global fetch must NOT be called as a method of `this` (workerd is strict).
|
|
64
|
+
this.fetchImpl = fetch.bind(globalThis);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async read(path: string): Promise<unknown> {
|
|
68
|
+
const res = await (await this.handle('get')).get(path);
|
|
69
|
+
if (res.status === 404) return null;
|
|
70
|
+
if (!res.ok) throw new ContentTransportError(res.status, `content read ${path} failed: ${res.status} ${await res.text()}`);
|
|
71
|
+
return res.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async write(path: string, json: unknown): Promise<void> {
|
|
75
|
+
const res = await (await this.handle('put')).put(path, JSON.stringify(json));
|
|
76
|
+
if (!res.ok) throw new ContentTransportError(res.status, `content write ${path} failed: ${res.status} ${await res.text()}`);
|
|
77
|
+
await res.arrayBuffer(); // body is unused, but the stream must be closed
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Names from an S3-style KEY listing (an honest `list`); the domain shape is reconstructed
|
|
81
|
+
// here: an ENTITY is a folder containing the meta file, a DRAFT is a json file under `drafts/`.
|
|
82
|
+
async list(prefix: string): Promise<string[]> {
|
|
83
|
+
const objects = await (await this.handle('list')).list(prefix);
|
|
84
|
+
if (prefix.endsWith('/drafts/')) {
|
|
85
|
+
return objects
|
|
86
|
+
.map((o) => o.key.slice(prefix.length))
|
|
87
|
+
.filter((rest) => /^[^/]+\.json$/.test(rest))
|
|
88
|
+
.map((rest) => rest.slice(0, -'.json'.length))
|
|
89
|
+
.sort();
|
|
90
|
+
}
|
|
91
|
+
const kind = prefix.replace(/\/$/, '') as ContentKind;
|
|
92
|
+
const keys = new Set(objects.map((o) => o.key));
|
|
93
|
+
const names = new Set<string>();
|
|
94
|
+
for (const o of objects) {
|
|
95
|
+
const seg = o.key.slice(prefix.length).split('/')[0];
|
|
96
|
+
if (seg && keys.has(metaPath(kind, seg))) names.add(seg);
|
|
97
|
+
}
|
|
98
|
+
return [...names].sort();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async keys(prefix: string): Promise<string[]> {
|
|
102
|
+
return (await (await this.handle('list')).list(prefix)).map((o) => o.key).sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A draft file by its path, or a whole ENTITY FOLDER. Two kinds of store answer the folder:
|
|
107
|
+
* dev's disk removes the directory with everything in it (and refuses its files one by one —
|
|
108
|
+
* metas and versions are immutable there), while an S3 store has no folders — only keys under
|
|
109
|
+
* a prefix — and answers a missing key with 204, deleting nothing (the page stayed in the
|
|
110
|
+
* production store with every file it had). So: the folder path first, then whatever is still
|
|
111
|
+
* listed under it, object by object — nothing on a disk, every file on S3.
|
|
112
|
+
*/
|
|
113
|
+
async delete(path: string): Promise<void> {
|
|
114
|
+
await this.deleteKey(path);
|
|
115
|
+
if (path.endsWith('.json')) return;
|
|
116
|
+
const left = await (await this.handle('list')).list(`${path}/`);
|
|
117
|
+
for (const object of left) await this.deleteKey(object.key);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async deleteKey(key: string): Promise<void> {
|
|
121
|
+
const res = await (await this.handle('delete')).delete(key);
|
|
122
|
+
if (res.status === 404) return; // already gone — deletion is idempotent
|
|
123
|
+
if (!res.ok) throw new ContentTransportError(res.status, `content delete ${key} failed: ${res.status} ${await res.text()}`);
|
|
124
|
+
await res.arrayBuffer();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async handle(op: StoreOp): Promise<ConnectorHandle> {
|
|
128
|
+
this.manager ??= this.envProviders.length
|
|
129
|
+
? Promise.resolve(new StoreConnectors(this.envProviders, this.fetchImpl))
|
|
130
|
+
: (async () => {
|
|
131
|
+
// fallback for compositions without an env projection: the self-description descriptor
|
|
132
|
+
// (it carries no credentials — against a locked-down dev such a transport can only do
|
|
133
|
+
// what is open)
|
|
134
|
+
const res = await this.fetchImpl(`${this.origin}/__mosetta__/providers`);
|
|
135
|
+
if (!res.ok) throw new ContentTransportError(res.status, `provider descriptor failed: ${res.status} ${await res.text()}`);
|
|
136
|
+
return new StoreConnectors(parseStoreConnectors(await res.json()), this.fetchImpl);
|
|
137
|
+
})();
|
|
138
|
+
const handle = (await this.manager).pick('content', op);
|
|
139
|
+
if (!handle) throw new ContentTransportError(501, `no content provider capable of "${op}"`);
|
|
140
|
+
return handle;
|
|
141
|
+
}
|
|
142
|
+
}
|