@micacms/runtime 0.5.0-rc.2 → 0.6.0-rc.1

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.
Files changed (95) hide show
  1. package/README.md +3 -1
  2. package/dist/app.js +1 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/config.d.ts +13 -13
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +9 -9
  7. package/dist/config.js.map +1 -1
  8. package/dist/context.d.ts +2 -0
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +9 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/http.d.ts +1 -3
  13. package/dist/http.d.ts.map +1 -1
  14. package/dist/http.js +2 -9
  15. package/dist/http.js.map +1 -1
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -3
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/errors.d.ts +1 -2
  21. package/dist/lib/errors.d.ts.map +1 -1
  22. package/dist/lib/errors.js +0 -2
  23. package/dist/lib/errors.js.map +1 -1
  24. package/dist/lib/id.d.ts +0 -10
  25. package/dist/lib/id.d.ts.map +1 -1
  26. package/dist/lib/id.js +0 -50
  27. package/dist/lib/id.js.map +1 -1
  28. package/dist/lib/rate-limit.d.ts +0 -2
  29. package/dist/lib/rate-limit.d.ts.map +1 -1
  30. package/dist/lib/rate-limit.js +0 -3
  31. package/dist/lib/rate-limit.js.map +1 -1
  32. package/dist/middleware/index.d.ts +1 -1
  33. package/dist/middleware/index.js +2 -2
  34. package/dist/middleware/index.js.map +1 -1
  35. package/dist/routes/admin-content.d.ts.map +1 -1
  36. package/dist/routes/admin-content.js +4 -3
  37. package/dist/routes/admin-content.js.map +1 -1
  38. package/dist/routes/admin-media.d.ts.map +1 -1
  39. package/dist/routes/admin-media.js +6 -1
  40. package/dist/routes/admin-media.js.map +1 -1
  41. package/dist/routes/admin-system.d.ts.map +1 -1
  42. package/dist/routes/admin-system.js +6 -4
  43. package/dist/routes/admin-system.js.map +1 -1
  44. package/dist/schema.d.ts +15 -3
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +63 -10
  47. package/dist/schema.js.map +1 -1
  48. package/dist/services/changes.d.ts.map +1 -1
  49. package/dist/services/changes.js +89 -13
  50. package/dist/services/changes.js.map +1 -1
  51. package/dist/services/content-store.d.ts +6 -1
  52. package/dist/services/content-store.d.ts.map +1 -1
  53. package/dist/services/content-store.js +25 -15
  54. package/dist/services/content-store.js.map +1 -1
  55. package/dist/services/staging.d.ts +2 -0
  56. package/dist/services/staging.d.ts.map +1 -1
  57. package/dist/services/staging.js +15 -4
  58. package/dist/services/staging.js.map +1 -1
  59. package/dist/workspace/tree.d.ts +6 -0
  60. package/dist/workspace/tree.d.ts.map +1 -1
  61. package/dist/workspace/tree.js +28 -0
  62. package/dist/workspace/tree.js.map +1 -1
  63. package/package.json +12 -4
  64. package/src/app.ts +127 -0
  65. package/src/config.ts +113 -0
  66. package/src/context.ts +167 -0
  67. package/src/http.ts +46 -0
  68. package/src/index.ts +141 -0
  69. package/src/lib/errors.ts +84 -0
  70. package/src/lib/file-type.ts +108 -0
  71. package/src/lib/id.ts +6 -0
  72. package/src/lib/logger.ts +87 -0
  73. package/src/lib/ports.ts +17 -0
  74. package/src/lib/rate-limit.ts +72 -0
  75. package/src/lib/schema-stats.ts +183 -0
  76. package/src/lib/version.ts +29 -0
  77. package/src/middleware/index.ts +234 -0
  78. package/src/routes/admin-content.ts +197 -0
  79. package/src/routes/admin-media.ts +96 -0
  80. package/src/routes/admin-spa.ts +82 -0
  81. package/src/routes/admin-staging.ts +126 -0
  82. package/src/routes/admin-system.ts +146 -0
  83. package/src/routes/health.ts +67 -0
  84. package/src/schema.ts +217 -0
  85. package/src/server.ts +68 -0
  86. package/src/services/changes.ts +605 -0
  87. package/src/services/content-store.ts +492 -0
  88. package/src/services/media.ts +229 -0
  89. package/src/services/staging.ts +330 -0
  90. package/src/workspace/developer.ts +46 -0
  91. package/src/workspace/files.ts +243 -0
  92. package/src/workspace/git.ts +108 -0
  93. package/src/workspace/locks.ts +103 -0
  94. package/src/workspace/policy.ts +77 -0
  95. package/src/workspace/tree.ts +314 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The content files of a working tree: the port everything reads and saves
3
+ * through, and its implementation. It extends `ContentSource`, so the Astro
4
+ * build and Mica share the same content reading code. `apply` is atomic: an
5
+ * entry is several files, and half of one is corruption.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
10
+ import { dirname, resolve } from "node:path";
11
+
12
+ import { contentPath, type ContentSource } from "@micacms/core";
13
+ import { isMissing, within } from "@micacms/core/node";
14
+
15
+ export class FilesError extends Error {
16
+ override name = "FilesError";
17
+ }
18
+
19
+ /** A file changed under an editor. The editor can recover: reload and apply again. */
20
+ export class ConflictError extends FilesError {
21
+ override name = "ConflictError";
22
+ constructor(
23
+ message: string,
24
+ /** Paths whose revision no longer matched. */
25
+ readonly paths: string[],
26
+ ) {
27
+ super(message);
28
+ }
29
+ }
30
+
31
+ /** Who a commit names. */
32
+ export interface Author {
33
+ name: string;
34
+ email: string;
35
+ }
36
+
37
+ export type FileChange =
38
+ | { type: "write"; path: string; contents: string | Uint8Array }
39
+ | { type: "delete"; path: string }
40
+ /** Deletes every file under `path`. Removing an entry removes its directory. */
41
+ | { type: "deleteTree"; path: string };
42
+
43
+ export interface FileChanges {
44
+ changes: FileChange[];
45
+ /**
46
+ * Revisions the edit was based on, per path, so editors of different entries
47
+ * never block each other. Null asserts that the path does not exist. Omitted,
48
+ * the write is unconditional.
49
+ */
50
+ expect?: Record<string, string | null>;
51
+ }
52
+
53
+ export interface ContentFiles extends ContentSource {
54
+ /** Bytes of a file, for media. Null when there is no such file. */
55
+ readBinary(path: string): Promise<Uint8Array | null>;
56
+
57
+ /**
58
+ * An opaque token for the current state of `path`, or null when it is absent.
59
+ * Compare only for equality.
60
+ */
61
+ revision(path: string): Promise<string | null>;
62
+
63
+ /**
64
+ * Applies every change, or none of them. Throws `ConflictError` when any
65
+ * `expect` no longer holds.
66
+ */
67
+ apply(input: FileChanges): Promise<void>;
68
+ }
69
+
70
+ /**
71
+ * Refuses, before any write, a change or expectation that names the content
72
+ * root or a path outside it.
73
+ */
74
+ export function checkChangePaths(input: FileChanges): void {
75
+ for (const change of input.changes) contentPath(change.path);
76
+ for (const path of Object.keys(input.expect ?? {})) contentPath(path);
77
+ }
78
+
79
+ export interface ContentFilesOptions {
80
+ root: string;
81
+ }
82
+
83
+ export function contentFiles(options: ContentFilesOptions): ContentFiles {
84
+ const base = resolve(options.root);
85
+ const full = (path: string, allowRoot = false) => within(base, path, { allowRoot });
86
+
87
+ const readBytes = async (path: string): Promise<Uint8Array | null> => {
88
+ try {
89
+ return await readFile(full(path));
90
+ } catch (error) {
91
+ if (isMissing(error)) return null;
92
+ throw error;
93
+ }
94
+ };
95
+
96
+ return {
97
+ origin: base,
98
+
99
+ async read(path) {
100
+ const bytes = await readBytes(path);
101
+ return bytes === null ? null : Buffer.from(bytes).toString("utf8");
102
+ },
103
+
104
+ readBinary: readBytes,
105
+
106
+ async list(path) {
107
+ let items;
108
+ try {
109
+ items = await readdir(full(path, true), { withFileTypes: true });
110
+ } catch (error) {
111
+ if (isMissing(error)) return [];
112
+ throw error;
113
+ }
114
+
115
+ return items
116
+ .filter((item) => item.isFile() || item.isDirectory())
117
+ .sort((a, b) => a.name.localeCompare(b.name))
118
+ .map((item) => ({ name: item.name, type: item.isFile() ? ("file" as const) : ("directory" as const) }));
119
+ },
120
+
121
+ /**
122
+ * A content hash, not an mtime: a checkout that rewrites identical bytes
123
+ * must not cause a conflict.
124
+ */
125
+ async revision(path) {
126
+ const bytes = await readBytes(path);
127
+ if (bytes === null) return null;
128
+ return createHash("sha256").update(bytes).digest("hex").slice(0, 16);
129
+ },
130
+
131
+ async apply(input: FileChanges): Promise<void> {
132
+ checkChangePaths(input);
133
+ await assertExpected(this, input);
134
+
135
+ // In-memory undo of every overwrite and removal. Content trees are small,
136
+ // so no journal is necessary.
137
+ const undo: Array<() => Promise<void>> = [];
138
+ const staging: string[] = [];
139
+
140
+ try {
141
+ for (const change of input.changes) {
142
+ if (change.type === "write") {
143
+ const target = full(change.path);
144
+ const previous = await readBytes(change.path);
145
+
146
+ undo.push(async () => {
147
+ if (previous === null) await rm(target, { force: true });
148
+ else await writeFile(target, previous);
149
+ });
150
+
151
+ await mkdir(dirname(target), { recursive: true });
152
+ // Write beside the target and rename, so a reader (the Astro dev
153
+ // server) never sees a partial file.
154
+ const staged = `${target}.mica-${process.pid}-${staging.length}`;
155
+ staging.push(staged);
156
+ await writeFile(staged, change.contents);
157
+ await rename(staged, target);
158
+ continue;
159
+ }
160
+
161
+ const target = full(change.path);
162
+
163
+ if (change.type === "delete") {
164
+ const previous = await readBytes(change.path);
165
+ if (previous === null) continue;
166
+
167
+ undo.push(async () => {
168
+ await mkdir(dirname(target), { recursive: true });
169
+ await writeFile(target, previous);
170
+ });
171
+ await unlink(target);
172
+ continue;
173
+ }
174
+
175
+ const saved = await snapshotTree(this, change.path);
176
+ undo.push(async () => {
177
+ for (const [path, bytes] of saved) {
178
+ const restored = full(path);
179
+ await mkdir(dirname(restored), { recursive: true });
180
+ await writeFile(restored, bytes);
181
+ }
182
+ });
183
+ await rm(target, { recursive: true, force: true });
184
+ }
185
+ } catch (error) {
186
+ // Reverse order, so a path written and then deleted comes back in its first state.
187
+ for (const step of undo.reverse()) {
188
+ // The original failure is what matters, not a rollback failure.
189
+ await step().catch(() => {});
190
+ }
191
+ for (const staged of staging) await rm(staged, { force: true }).catch(() => {});
192
+
193
+ throw error instanceof FilesError
194
+ ? error
195
+ : new FilesError(
196
+ `Could not apply changes to ${base}: ${(error as Error).message}`,
197
+ );
198
+ }
199
+ },
200
+ };
201
+ }
202
+
203
+ async function assertExpected(
204
+ files: ContentFiles,
205
+ input: FileChanges,
206
+ ): Promise<void> {
207
+ if (!input.expect) return;
208
+
209
+ const stale: string[] = [];
210
+ for (const [path, expected] of Object.entries(input.expect)) {
211
+ if ((await files.revision(path)) !== expected) stale.push(path);
212
+ }
213
+
214
+ if (stale.length > 0) {
215
+ throw new ConflictError(
216
+ "This content changed after you opened it. Reload to see the current version.",
217
+ stale,
218
+ );
219
+ }
220
+ }
221
+
222
+ /** Every file under `path`, so a tree deletion can be undone. */
223
+ async function snapshotTree(
224
+ files: ContentFiles,
225
+ path: string,
226
+ ): Promise<Array<[string, Uint8Array]>> {
227
+ const saved: Array<[string, Uint8Array]> = [];
228
+
229
+ const walk = async (at: string): Promise<void> => {
230
+ for (const item of await files.list(at)) {
231
+ const child = `${at}/${item.name}`;
232
+ if (item.type === "directory") {
233
+ await walk(child);
234
+ continue;
235
+ }
236
+ const bytes = await files.readBinary(child);
237
+ if (bytes !== null) saved.push([child, bytes]);
238
+ }
239
+ };
240
+
241
+ await walk(path);
242
+ return saved;
243
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Spawns the `git` binary, so the developer's SSH agent, credential helper and
3
+ * remote configuration apply. A server-owned clone passes its own environment
4
+ * instead (`GitRunnerOptions.env`).
5
+ */
6
+
7
+ import { execFile } from "node:child_process";
8
+
9
+ export class GitError extends Error {
10
+ override name = "GitError";
11
+ constructor(
12
+ message: string,
13
+ readonly stderr: string,
14
+ readonly exitCode: number | null,
15
+ /** What Git printed before it failed. A rejected push puts its porcelain lines here. */
16
+ readonly stdout = "",
17
+ ) {
18
+ super(message);
19
+ }
20
+ }
21
+
22
+ export interface GitCallOptions {
23
+ /** Written to Git's stdin, for `--pathspec-from-file=-` and similar. */
24
+ input?: string;
25
+ /** Added to the runner's environment for this call only. */
26
+ env?: NodeJS.ProcessEnv;
27
+ }
28
+
29
+ export interface GitRunner {
30
+ /** Runs `git` in the runner's directory. Trimmed stdout, or a `GitError`. */
31
+ run(args: string[], call?: GitCallOptions): Promise<string>;
32
+ /** Like `run`, but null instead of a throw — for questions Git may say no to. */
33
+ tryRun(args: string[], call?: GitCallOptions): Promise<string | null>;
34
+ }
35
+
36
+ export interface GitRunnerOptions {
37
+ /**
38
+ * The whole environment of every `git` call. Defaults to the developer's
39
+ * (`developerEnvironment`).
40
+ */
41
+ env?: NodeJS.ProcessEnv;
42
+ timeoutMs?: number;
43
+ maxBuffer?: number;
44
+ }
45
+
46
+ /**
47
+ * The developer's own environment. `GIT_TERMINAL_PROMPT=0`: a credential prompt
48
+ * must fail, not hang the request.
49
+ */
50
+ export function developerEnvironment(): NodeJS.ProcessEnv {
51
+ return { ...process.env, GIT_TERMINAL_PROMPT: "0" };
52
+ }
53
+
54
+ export function gitRunner(cwd: string, options: GitRunnerOptions = {}): GitRunner {
55
+ const env = options.env ?? developerEnvironment();
56
+ const run = (args: string[], call: GitCallOptions = {}): Promise<string> =>
57
+ new Promise((resolve, reject) => {
58
+ const child = execFile(
59
+ "git",
60
+ args,
61
+ {
62
+ cwd,
63
+ env: call.env ? { ...env, ...call.env } : env,
64
+ timeout: options.timeoutMs ?? 60_000,
65
+ maxBuffer: options.maxBuffer ?? 10 * 1024 * 1024,
66
+ },
67
+ (error, stdout, stderr) => {
68
+ if (error) {
69
+ const missing = (error as NodeJS.ErrnoException).code === "ENOENT";
70
+ reject(
71
+ new GitError(
72
+ missing
73
+ ? "Git is not installed, or not on the PATH."
74
+ : firstLine(stderr) || error.message,
75
+ stderr,
76
+ missing ? null : ((error as { code?: number }).code ?? null),
77
+ stdout,
78
+ ),
79
+ );
80
+ return;
81
+ }
82
+ resolve(stdout.replace(/\n$/, ""));
83
+ },
84
+ );
85
+ if (call.input !== undefined) child.stdin?.end(call.input);
86
+ });
87
+
88
+ return {
89
+ run,
90
+ async tryRun(args, call) {
91
+ try {
92
+ return await run(args, call);
93
+ } catch {
94
+ return null;
95
+ }
96
+ },
97
+ };
98
+ }
99
+
100
+ /** The first non-empty line, where Git puts its verdict. */
101
+ export function firstLine(text: string): string {
102
+ return (
103
+ text
104
+ .split("\n")
105
+ .map((line) => line.trim())
106
+ .find((line) => line !== "") ?? ""
107
+ );
108
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The two locks of a shared working tree: `remote` (fetch and push, one at a
3
+ * time) and `tree` (read/write; saves, discards, commits and fast-forwards
4
+ * write). A save during a commit could otherwise put half an entry into the
5
+ * commit, and a read during a fast-forward could see a half-written file.
6
+ *
7
+ * Both wait, they do not refuse. Take `remote` first, then `tree`, so two
8
+ * operations never wait on each other. No network call may run while `tree` is
9
+ * held: a slow push must not block autosave.
10
+ */
11
+
12
+ import type { ContentFiles } from "./files.js";
13
+
14
+ /** Runs one function at a time, in arrival order. */
15
+ export interface Mutex {
16
+ run<T>(fn: () => Promise<T>): Promise<T>;
17
+ }
18
+
19
+ /** Many readers or one writer, in arrival order. A waiting writer holds back later readers. */
20
+ export interface ReadWriteLock {
21
+ read<T>(fn: () => Promise<T>): Promise<T>;
22
+ write<T>(fn: () => Promise<T>): Promise<T>;
23
+ }
24
+
25
+ export interface WorkspaceLocks {
26
+ remote: Mutex;
27
+ tree: ReadWriteLock;
28
+ }
29
+
30
+ export function createWorkspaceLocks(): WorkspaceLocks {
31
+ return { remote: createMutex(), tree: createReadWriteLock() };
32
+ }
33
+
34
+ export function createMutex(): Mutex {
35
+ const lock = createReadWriteLock();
36
+ return { run: (fn) => lock.write(fn) };
37
+ }
38
+
39
+ export function createReadWriteLock(): ReadWriteLock {
40
+ let readers = 0;
41
+ let writing = false;
42
+ const queue: Array<{ kind: "read" | "write"; start: () => void }> = [];
43
+
44
+ const admit = (): void => {
45
+ while (queue.length > 0) {
46
+ const next = queue[0]!;
47
+ if (next.kind === "write") {
48
+ if (writing || readers > 0) return;
49
+ queue.shift();
50
+ writing = true;
51
+ next.start();
52
+ return;
53
+ }
54
+ if (writing) return;
55
+ queue.shift();
56
+ readers += 1;
57
+ next.start();
58
+ }
59
+ };
60
+
61
+ const acquire = (kind: "read" | "write"): Promise<void> =>
62
+ new Promise((resolve) => {
63
+ queue.push({ kind, start: resolve });
64
+ admit();
65
+ });
66
+
67
+ const release = (kind: "read" | "write"): void => {
68
+ if (kind === "write") writing = false;
69
+ else readers -= 1;
70
+ admit();
71
+ };
72
+
73
+ const hold =
74
+ (kind: "read" | "write") =>
75
+ async <T>(fn: () => Promise<T>): Promise<T> => {
76
+ await acquire(kind);
77
+ try {
78
+ return await fn();
79
+ } finally {
80
+ release(kind);
81
+ }
82
+ };
83
+
84
+ return { read: hold("read"), write: hold("write") };
85
+ }
86
+
87
+ /**
88
+ * Files behind the tree lock. The staging service does not use it: it holds the
89
+ * lock around whole operations.
90
+ */
91
+ export function lockedFiles(
92
+ files: ContentFiles,
93
+ tree: ReadWriteLock,
94
+ ): ContentFiles {
95
+ return {
96
+ origin: files.origin,
97
+ read: (path) => tree.read(() => files.read(path)),
98
+ list: (path) => tree.read(() => files.list(path)),
99
+ readBinary: (path) => tree.read(() => files.readBinary(path)),
100
+ revision: (path) => tree.read(() => files.revision(path)),
101
+ apply: (input) => tree.write(() => files.apply(input)),
102
+ };
103
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * What changes with the owner of a working tree: whether Publish may run and
3
+ * how a commit reaches the remote. The app that keeps a managed clone supplies
4
+ * its policy; `developer.ts` is the developer's.
5
+ */
6
+
7
+ import type { PublishResult } from "@micacms/core";
8
+
9
+ import type { Author } from "./files.js";
10
+
11
+ import type { GitTree } from "./tree.js";
12
+
13
+ export type PublishAvailability = { enabled: true } | { enabled: false; reason: string };
14
+
15
+ /** One piece of content that an editor changed and the website changed too. */
16
+ export interface SyncConflict {
17
+ /** Identifies the group in `resolve`. */
18
+ group: string;
19
+ label: string;
20
+ /** Content-relative paths of the group, on either side. */
21
+ paths: string[];
22
+ upstream: "changed" | "deleted" | "added";
23
+ /** What the comparison shows, when the group is one document. */
24
+ target?: { kind: "entry"; collection: string; slug: string } | { kind: "singleton"; name: string } | { kind: "order"; collection: string };
25
+ }
26
+
27
+ /** Where a clone that Mica keeps stands against its remote. Managed owners only. */
28
+ export interface SyncStatus {
29
+ lastSyncAt: string | null;
30
+ /** Upstream commits not yet in the clone. */
31
+ behind: number;
32
+ error: { code: string; message: string; since: string } | null;
33
+ /** The content model changed upstream; a restart applies it. */
34
+ restartRequired: boolean;
35
+ /** A publication committed and not yet pushed. Mica retries it. */
36
+ pendingPush: { revision: string; reason: string } | null;
37
+ /** The last publication the host refused; it is a draft again. */
38
+ refused: { reason: string; at: string } | null;
39
+ conflicts: SyncConflict[];
40
+ }
41
+
42
+ /** What the System page says about the repository, beyond what the runtime knows. */
43
+ export interface RepositoryDescription {
44
+ /** The remote URL, without credentials. */
45
+ url: string;
46
+ branch: string;
47
+ /** Bytes the clone takes on the data volume. */
48
+ cloneBytes: number | null;
49
+ }
50
+
51
+ export interface OwnerPolicy {
52
+ readonly owner: "developer" | "managed";
53
+ publishAvailability(): PublishAvailability;
54
+ /**
55
+ * Runs with the remote lock held, before the tree lock. A managed clone syncs
56
+ * here and must not take the remote lock again. Throws an `ApiError` when
57
+ * Publish must wait.
58
+ */
59
+ prepare(tree: GitTree): Promise<void>;
60
+ /** Who a commit names as its author, given the signed-in editor. Undefined leaves it to Git. */
61
+ commitAuthor(editor: Author | undefined): Author | undefined;
62
+ /**
63
+ * Sends HEAD to the remote. A failure returns `committed-pending-push`, not
64
+ * an exception: the commit is real.
65
+ */
66
+ push(tree: GitTree, revision: string): Promise<PublishResult>;
67
+
68
+ /* A clone that Mica keeps answers these; the developer's checkout does not. */
69
+
70
+ syncStatus?(): SyncStatus;
71
+ sync?(): Promise<void>;
72
+ /** Keeps the editor's version of a conflicting group, or takes the website's. */
73
+ resolve?(group: string, choice: "mine" | "website"): Promise<void>;
74
+ /** The ref that holds the website's version, for a conflict's comparison. */
75
+ upstreamRef?: string;
76
+ describe?(): Promise<RepositoryDescription>;
77
+ }