@plurnk/plurnk-service 3.1.0 → 3.2.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/.env.defaults +22 -17
- package/INSTALL.md +20 -1
- package/SPEC.md +333 -88
- package/dist/build-info.json +1 -1
- package/dist/completions/_plurnk-service +1 -1
- package/dist/completions/plurnk-service.bash +1 -1
- package/dist/completions/plurnk-service.fish +1 -0
- package/dist/content/path-mimetype.d.ts.map +1 -1
- package/dist/content/path-mimetype.js +2 -8
- package/dist/content/path-mimetype.js.map +1 -1
- package/dist/core/AdmittedTurnExecutor.d.ts.map +1 -1
- package/dist/core/AdmittedTurnExecutor.js +22 -10
- package/dist/core/AdmittedTurnExecutor.js.map +1 -1
- package/dist/{schemes/_derivation-use.d.ts → core/ContentUse.d.ts} +3 -3
- package/dist/core/ContentUse.d.ts.map +1 -0
- package/dist/{schemes/_derivation-use.js → core/ContentUse.js} +8 -7
- package/dist/core/ContentUse.js.map +1 -0
- package/dist/core/DataStatementRunner.d.ts.map +1 -1
- package/dist/core/DataStatementRunner.js +8 -20
- package/dist/core/DataStatementRunner.js.map +1 -1
- package/dist/core/Dispatcher.d.ts +9 -1
- package/dist/core/Dispatcher.d.ts.map +1 -1
- package/dist/core/Dispatcher.js +144 -71
- package/dist/core/Dispatcher.js.map +1 -1
- package/dist/core/EditMutations.js +1 -1
- package/dist/core/EditMutations.js.map +1 -1
- package/dist/core/Engine.d.ts +1 -1
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +3 -3
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/FileAddress.d.ts +12 -0
- package/dist/core/FileAddress.d.ts.map +1 -0
- package/dist/core/FileAddress.js +73 -0
- package/dist/core/FileAddress.js.map +1 -0
- package/dist/core/FilePublication.d.ts +15 -0
- package/dist/core/FilePublication.d.ts.map +1 -0
- package/dist/core/FilePublication.js +143 -0
- package/dist/core/FilePublication.js.map +1 -0
- package/dist/core/FileSnapshot.d.ts +26 -0
- package/dist/core/FileSnapshot.d.ts.map +1 -0
- package/dist/core/FileSnapshot.js +179 -0
- package/dist/core/FileSnapshot.js.map +1 -0
- package/dist/core/KillHandler.d.ts.map +1 -1
- package/dist/core/KillHandler.js +2 -1
- package/dist/core/KillHandler.js.map +1 -1
- package/dist/core/MembershipMaterialization.d.ts.map +1 -1
- package/dist/core/MembershipMaterialization.js +87 -143
- package/dist/core/MembershipMaterialization.js.map +1 -1
- package/dist/core/MutationEffects.d.ts +2 -1
- package/dist/core/MutationEffects.d.ts.map +1 -1
- package/dist/core/MutationEffects.js +14 -1
- package/dist/core/MutationEffects.js.map +1 -1
- package/dist/core/PacketBuilder.sql +8 -0
- package/dist/core/PathGlob.d.ts +6 -0
- package/dist/core/PathGlob.d.ts.map +1 -0
- package/dist/core/PathGlob.js +24 -0
- package/dist/core/PathGlob.js.map +1 -0
- package/dist/core/ProposalLifecycle.d.ts +2 -1
- package/dist/core/ProposalLifecycle.d.ts.map +1 -1
- package/dist/core/ProposalLifecycle.js +45 -33
- package/dist/core/ProposalLifecycle.js.map +1 -1
- package/dist/core/ProposalLifecycle.sql +4 -0
- package/dist/core/ResourceBindings.d.ts.map +1 -1
- package/dist/core/ResourceBindings.js +4 -3
- package/dist/core/ResourceBindings.js.map +1 -1
- package/dist/core/ResourceSelector.d.ts +1 -2
- package/dist/core/ResourceSelector.d.ts.map +1 -1
- package/dist/core/ResourceSelector.js +30 -9
- package/dist/core/ResourceSelector.js.map +1 -1
- package/dist/core/ResourceTransfers.d.ts +1 -1
- package/dist/core/ResourceTransfers.d.ts.map +1 -1
- package/dist/core/ResourceTransfers.js +26 -11
- package/dist/core/ResourceTransfers.js.map +1 -1
- package/dist/core/TurnRunner.d.ts.map +1 -1
- package/dist/core/TurnRunner.js +6 -4
- package/dist/core/TurnRunner.js.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.d.ts +1 -0
- package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.js +5 -0
- package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
- package/dist/core/git-membership.d.ts +1 -0
- package/dist/core/git-membership.d.ts.map +1 -1
- package/dist/core/git-membership.js +9 -11
- package/dist/core/git-membership.js.map +1 -1
- package/dist/core/glob_match.js +3 -3
- package/dist/core/glob_match.js.map +1 -1
- package/dist/core/mutation-types.d.ts +3 -0
- package/dist/core/mutation-types.d.ts.map +1 -1
- package/dist/core/namespace.d.ts +4 -1
- package/dist/core/namespace.d.ts.map +1 -1
- package/dist/core/namespace.js +14 -3
- package/dist/core/namespace.js.map +1 -1
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +11 -9
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/plurnk-uri.d.ts +3 -2
- package/dist/core/plurnk-uri.d.ts.map +1 -1
- package/dist/core/plurnk-uri.js +23 -11
- package/dist/core/plurnk-uri.js.map +1 -1
- package/dist/core/scheme-types.d.ts +1 -0
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/sqlite-synchronous.d.ts +2 -0
- package/dist/core/sqlite-synchronous.d.ts.map +1 -0
- package/dist/core/sqlite-synchronous.js +4 -0
- package/dist/core/sqlite-synchronous.js.map +1 -0
- package/dist/evidence/EvidenceReader.d.ts.map +1 -1
- package/dist/evidence/EvidenceReader.js +8 -1
- package/dist/evidence/EvidenceReader.js.map +1 -1
- package/dist/evidence/digest.sql +6 -1
- package/dist/man/plurnk-service.1 +32 -21
- package/dist/schemes/Env.d.ts +1 -1
- package/dist/schemes/Env.d.ts.map +1 -1
- package/dist/schemes/Env.js +2 -2
- package/dist/schemes/Env.js.map +1 -1
- package/dist/schemes/File.d.ts +1 -0
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +233 -268
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +64 -7
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Worker.js +1 -1
- package/dist/schemes/Worker.js.map +1 -1
- package/dist/schemes/_entry-crud.d.ts +2 -0
- package/dist/schemes/_entry-crud.d.ts.map +1 -1
- package/dist/schemes/_entry-crud.js +8 -5
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-crud.sql +15 -15
- package/dist/schemes/_entry-find.d.ts +1 -0
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +15 -10
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-manifest.d.ts +2 -2
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +7 -5
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +9 -7
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_path-scope.d.ts.map +1 -1
- package/dist/schemes/_path-scope.js +12 -4
- package/dist/schemes/_path-scope.js.map +1 -1
- package/dist/schemes/_search-index.js +3 -3
- package/dist/schemes/_search-index.js.map +1 -1
- package/dist/schemes/exec-env.d.ts.map +1 -1
- package/dist/schemes/exec-env.js +7 -22
- package/dist/schemes/exec-env.js.map +1 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +40 -68
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/DrainSupervisor.d.ts +1 -3
- package/dist/server/DrainSupervisor.d.ts.map +1 -1
- package/dist/server/DrainSupervisor.js +30 -5
- package/dist/server/DrainSupervisor.js.map +1 -1
- package/dist/server/EnvFunctionality.d.ts.map +1 -1
- package/dist/server/EnvFunctionality.js +19 -11
- package/dist/server/EnvFunctionality.js.map +1 -1
- package/dist/server/Functionality.d.ts.map +1 -1
- package/dist/server/Functionality.js +15 -8
- package/dist/server/Functionality.js.map +1 -1
- package/dist/server/FunctionalityManager.d.ts +1 -1
- package/dist/server/FunctionalityManager.d.ts.map +1 -1
- package/dist/server/FunctionalityManager.js +5 -1
- package/dist/server/FunctionalityManager.js.map +1 -1
- package/dist/server/MembersFunctionality.d.ts.map +1 -1
- package/dist/server/MembersFunctionality.js +12 -12
- package/dist/server/MembersFunctionality.js.map +1 -1
- package/dist/server/Retention.d.ts.map +1 -1
- package/dist/server/Retention.js +5 -5
- package/dist/server/Retention.js.map +1 -1
- package/dist/server/Retention.sql +49 -23
- package/dist/server/lifecycle-recovery.sql +4 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +4 -0
- package/dist/service.js.map +1 -1
- package/docs/copy-move.md +3 -0
- package/docs/env.md +12 -6
- package/docs/members.md +16 -1
- package/migrations/023_file_revisions.sql +54 -0
- package/migrations/024_proposal_decisions.sql +34 -0
- package/migrations/025_response_retention.sql +30 -0
- package/package.json +33 -31
- package/dist/schemes/_derivation-use.d.ts.map +0 -1
- package/dist/schemes/_derivation-use.js.map +0 -1
package/dist/schemes/File.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
var _a;
|
|
2
|
-
import { lstat,
|
|
2
|
+
import { lstat, realpath, rm, stat } from "node:fs/promises";
|
|
3
3
|
import Namespace from "../core/namespace.js";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import FileAddress from "../core/FileAddress.js";
|
|
5
|
+
import { entryAddress, fileMissRecovery, missDetail, missExtensions } from "../core/plurnk-uri.js";
|
|
6
|
+
import { relative, isAbsolute, join } from "node:path";
|
|
6
7
|
import { createPatch } from "diff";
|
|
7
8
|
import { PlurnkParser } from "@plurnk/plurnk-parser";
|
|
8
|
-
import { PathSyntax } from "@plurnk/plurnk-contracts";
|
|
9
9
|
import EntryManifest from "./_entry-manifest.js";
|
|
10
10
|
import GitMembership from "../core/git-membership.js";
|
|
11
|
-
import EntryFind from "./_entry-find.js";
|
|
11
|
+
import EntryFind, { emptyFindFields } from "./_entry-find.js";
|
|
12
|
+
import { pathScope } from "./_path-scope.js";
|
|
12
13
|
import EntryCrud from "./_entry-crud.js";
|
|
13
14
|
import { CoreSchemeAdapterBase } from "../core/CoreSchemeServices.js";
|
|
14
15
|
import ErrorDetail from "../core/ErrorDetail.js";
|
|
@@ -18,34 +19,8 @@ import { FileByteSource, InvalidOperationResultError, } from "@plurnk/plurnk-sch
|
|
|
18
19
|
import { LineMarkerOps, LineAnchors, EditCollision, EDIT_NOOP_DETAIL, MimetypeBinary, editReceipt, projectEditReceipt, reviewerReplacementReceipt, withEditReceiptParseIssues, } from "../content/index.js";
|
|
19
20
|
import DbProjectionCaps from "../core/caps/DbProjectionCaps.js";
|
|
20
21
|
import FileMaterialization from "../core/file-materialization.js";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// guesses a root: headless workspaces fail file operations at 400.
|
|
24
|
-
const loadWorkspaceRoot = async (db, workspaceId) => {
|
|
25
|
-
const row = await db.envelope_get_workspace.get({ id: workspaceId });
|
|
26
|
-
return row?.project_root ?? null;
|
|
27
|
-
};
|
|
28
|
-
// Resolve the nearest existing ancestor, then append the still-absent tail. This
|
|
29
|
-
// gives a proposed create the same physical containment check as an existing path:
|
|
30
|
-
// an in-root symlinked parent cannot smuggle a root-scoped write outside the namespace.
|
|
31
|
-
const prospectiveRealpath = async (requested) => {
|
|
32
|
-
const tail = [basename(requested)];
|
|
33
|
-
let cursor = dirname(requested);
|
|
34
|
-
while (true) {
|
|
35
|
-
try {
|
|
36
|
-
return join(await realpath(cursor), ...tail);
|
|
37
|
-
}
|
|
38
|
-
catch (cause) {
|
|
39
|
-
if (cause.code !== "ENOENT")
|
|
40
|
-
throw cause;
|
|
41
|
-
}
|
|
42
|
-
const parent = dirname(cursor);
|
|
43
|
-
if (parent === cursor)
|
|
44
|
-
throw new Error(`No existing ancestor contains '${requested}'.`);
|
|
45
|
-
tail.unshift(basename(cursor));
|
|
46
|
-
cursor = parent;
|
|
47
|
-
}
|
|
48
|
-
};
|
|
22
|
+
import FileSnapshot from "../core/FileSnapshot.js";
|
|
23
|
+
import FilePublication from "../core/FilePublication.js";
|
|
49
24
|
// Detect mimetype from a file's path through the configured registry. The
|
|
50
25
|
// `MimetypeBinary.normalizeAutoTextMimetype` wrapper ensures text/plain returned by the
|
|
51
26
|
// service is normalized to text/markdown — plurnk-service never auto-
|
|
@@ -62,10 +37,9 @@ const detectFileMimetype = async (canonical, ctx) => {
|
|
|
62
37
|
class File extends CoreSchemeAdapterBase {
|
|
63
38
|
static manifest = {
|
|
64
39
|
name: "file",
|
|
65
|
-
storedScheme: "file", // {§entry-identity-no-null} — durable identity
|
|
66
|
-
// body
|
|
67
|
-
|
|
68
|
-
channels: { body: "text/markdown", readable: "text/markdown" },
|
|
40
|
+
storedScheme: "file", // {§entry-identity-no-null} — durable identity, independent of displayed spelling
|
|
41
|
+
// The body is typed per resource; readable is its derived projection ({§readable-channel}).
|
|
42
|
+
channels: { readable: "text/markdown" },
|
|
69
43
|
defaultChannel: "body",
|
|
70
44
|
category: "data",
|
|
71
45
|
writableBy: ["model", "client", "_plurnk"],
|
|
@@ -80,37 +54,21 @@ class File extends CoreSchemeAdapterBase {
|
|
|
80
54
|
"A scoped EDIT replaces the addressed lines; put the replacement text in its body.",
|
|
81
55
|
].join("\n\n"),
|
|
82
56
|
};
|
|
83
|
-
// {§fs-namei} Resolve FIND's address before selecting member candidates.
|
|
84
|
-
static async #canonTarget(statement, root) {
|
|
85
|
-
const t = statement.target;
|
|
86
|
-
if (t === null)
|
|
87
|
-
return statement;
|
|
88
|
-
if (t.kind === "url") {
|
|
89
|
-
const key = await _a.#canonSpelling(t.pathname, root);
|
|
90
|
-
if (key === null)
|
|
91
|
-
return null;
|
|
92
|
-
return key === t.pathname ? statement : { ...statement, target: { ...t, pathname: key } };
|
|
93
|
-
}
|
|
94
|
-
if (t.kind === "local") {
|
|
95
|
-
const key = await _a.#canonSpelling(t.raw, root);
|
|
96
|
-
if (key === null)
|
|
97
|
-
return null;
|
|
98
|
-
return key === t.raw ? statement : { ...statement, target: { ...t, raw: key } };
|
|
99
|
-
}
|
|
100
|
-
return statement; // regex — not a path
|
|
101
|
-
}
|
|
102
57
|
// {§file-find-directory}: a directory's canonical collection address does not depend on
|
|
103
58
|
// whether its authored spelling included the slash. Membership still owns its contents.
|
|
104
|
-
static async #canonSpelling(raw, root) {
|
|
59
|
+
static async #canonSpelling(raw, root, pattern = false) {
|
|
105
60
|
const key = Namespace.canonicalizeSpelling(raw, root);
|
|
106
61
|
if (root === null || key === null || key.length === 0
|
|
107
|
-
|| key.endsWith("/") || key.startsWith("../") ||
|
|
62
|
+
|| key.endsWith("/") || key.startsWith("../") || pattern)
|
|
108
63
|
return key;
|
|
109
|
-
return (await _a.#occupant(root,
|
|
64
|
+
return (await _a.#occupant(root, key))?.isDirectory() === true ? `${key}/` : key;
|
|
110
65
|
}
|
|
111
66
|
async resolveEntryAddress(target, ctx, access = "read") {
|
|
112
67
|
const core = this.coreContext(ctx);
|
|
113
|
-
const
|
|
68
|
+
const spelling = FileAddress.pathname(target);
|
|
69
|
+
if (typeof spelling !== "string")
|
|
70
|
+
return spelling;
|
|
71
|
+
const pathname = await _a.#canonSpelling(spelling, await FileAddress.projectRoot(core), FileAddress.isPattern(target));
|
|
114
72
|
if (pathname === null)
|
|
115
73
|
return null;
|
|
116
74
|
if (access === "write") {
|
|
@@ -135,9 +93,28 @@ class File extends CoreSchemeAdapterBase {
|
|
|
135
93
|
async find(statement, ctx) {
|
|
136
94
|
const core = this.coreContext(ctx);
|
|
137
95
|
// {§fs-namei} Exact paths, folders and globs share the filesystem base.
|
|
138
|
-
const root = await
|
|
139
|
-
const
|
|
140
|
-
|
|
96
|
+
const root = await FileAddress.projectRoot(core);
|
|
97
|
+
const spelling = statement.target === null ? "" : FileAddress.selector(statement.target, root);
|
|
98
|
+
if (typeof spelling !== "string")
|
|
99
|
+
return { ...emptyFindFields(), ...spelling };
|
|
100
|
+
const pattern = FileAddress.isPattern(statement.target);
|
|
101
|
+
const key = await _a.#canonSpelling(spelling, root, pattern);
|
|
102
|
+
let scope = key === null ? undefined : !pattern && key !== "" && !key.endsWith("/")
|
|
103
|
+
? { kind: "exact", pathname: key, candidatePrefix: key }
|
|
104
|
+
: pathScope(key, true);
|
|
105
|
+
if (scope?.kind === "glob" && statement.target?.kind === "url") {
|
|
106
|
+
const authored = pathScope(statement.target.pathname, true);
|
|
107
|
+
// Structural catalogs use a literal prefix, even when its URL needs quoting.
|
|
108
|
+
if (authored.kind === "glob" && (authored.shallowPrefix !== null || authored.recursivePrefix !== null)) {
|
|
109
|
+
const literal = Namespace.canonicalizeSpelling(FileAddress.pathname(statement.target), root);
|
|
110
|
+
const prefix = literal.slice(0, authored.shallowPrefix !== null ? -1 : -2);
|
|
111
|
+
scope = { ...scope, candidatePrefix: prefix,
|
|
112
|
+
shallowPrefix: authored.shallowPrefix === null ? null : prefix,
|
|
113
|
+
recursivePrefix: authored.recursivePrefix === null ? null : prefix };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return EntryFind.findWorkspaceEntries(statement, core, _a.manifest, {
|
|
117
|
+
...(scope === undefined ? {} : { scope }),
|
|
141
118
|
...(root === null ? {} : { pathBase: root }),
|
|
142
119
|
bytes: (pathname) => this.byteSource({ authority: "", pathname }, core),
|
|
143
120
|
});
|
|
@@ -146,7 +123,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
146
123
|
async readEntry(pathname, ctx) {
|
|
147
124
|
const core = this.coreContext(ctx);
|
|
148
125
|
// {§scheme-address} {§fs-namei} All read paths resolve the same member key.
|
|
149
|
-
const root = await
|
|
126
|
+
const root = await FileAddress.projectRoot(core);
|
|
150
127
|
const key = root === null ? pathname : Namespace.canonicalize(pathname, root);
|
|
151
128
|
const canonical = key ?? pathname;
|
|
152
129
|
const result = "entries" in ctx
|
|
@@ -161,7 +138,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
161
138
|
byteSource({ pathname }, ctx) {
|
|
162
139
|
const core = this.coreContext(ctx);
|
|
163
140
|
const locate = async () => {
|
|
164
|
-
const root = await
|
|
141
|
+
const root = await FileAddress.projectRoot(core);
|
|
165
142
|
if (root === null)
|
|
166
143
|
return null;
|
|
167
144
|
const key = Namespace.canonicalize(pathname, root);
|
|
@@ -174,7 +151,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
174
151
|
// and an occupied path says so. Beyond it the disk stays dark: nothing is looked at, and the
|
|
175
152
|
// sentence is true whether or not a file is there — it neither claims absence nor hints at presence.
|
|
176
153
|
async missRefusal(pathname, core, fields) {
|
|
177
|
-
const root = await
|
|
154
|
+
const root = await FileAddress.projectRoot(core);
|
|
178
155
|
if (root === null)
|
|
179
156
|
return null;
|
|
180
157
|
const key = Namespace.canonicalize(pathname, root);
|
|
@@ -182,7 +159,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
182
159
|
return null;
|
|
183
160
|
const occupant = key.startsWith("../") ? null : await _a.#occupant(root, key);
|
|
184
161
|
if (occupant?.isDirectory() === true) {
|
|
185
|
-
const { code, detail, extensions } = _a.#directoryFacts(key, "READ");
|
|
162
|
+
const { code, detail, extensions } = _a.#directoryFacts(key, "READ", root);
|
|
186
163
|
return Results.failure("scheme:file", code, 404, detail, fields, extensions);
|
|
187
164
|
}
|
|
188
165
|
const occupied = occupant !== null;
|
|
@@ -192,7 +169,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
192
169
|
return Results.failure("scheme:file", occupied ? "entry-not-member" : "entry-not-found", 404, occupied
|
|
193
170
|
? `'${key}' exists on disk but is not a member of this workspace.`
|
|
194
171
|
: missDetail("file", key), fields, {
|
|
195
|
-
target: key,
|
|
172
|
+
target: entryAddress("file", "", key, root),
|
|
196
173
|
recovery: occupied
|
|
197
174
|
? ignored
|
|
198
175
|
? "The repository ignores it: a client or operator members definition can include it, a model definition cannot."
|
|
@@ -214,14 +191,15 @@ class File extends CoreSchemeAdapterBase {
|
|
|
214
191
|
}
|
|
215
192
|
// {§file-directory-target} — a directory inside the root is named as one, never as a missing or non-member
|
|
216
193
|
// file; a READ or KILL of it carries the listing that shows its files ({§diagnostic-observation}).
|
|
217
|
-
static #directoryFacts(key, op) {
|
|
218
|
-
const
|
|
194
|
+
static #directoryFacts(key, op, root) {
|
|
195
|
+
const target = entryAddress("file", "", key, root);
|
|
196
|
+
const listing = `\`FIND (${target}/)\` lists its files.`;
|
|
219
197
|
const { detail, recovery } = {
|
|
220
198
|
READ: { detail: `'${key}' is a directory, not a file; READ reads one file.`, recovery: listing },
|
|
221
199
|
EDIT: { detail: `'${key}' is a directory, not a file; EDIT writes one file.`, recovery: undefined },
|
|
222
200
|
KILL: { detail: `'${key}' is a directory, not a file; KILL removes one file.`, recovery: listing },
|
|
223
201
|
}[op];
|
|
224
|
-
return { code: "path-is-directory", detail, extensions: { target
|
|
202
|
+
return { code: "path-is-directory", detail, extensions: { target, ...(recovery === undefined ? {} : { recovery }), retryable: false } };
|
|
225
203
|
}
|
|
226
204
|
// {§membership} disk-write gate, shared by edit() and writeEntry() (the COPY/MOVE
|
|
227
205
|
// dest). Resolves the canonical path; enforces containment (traversal → 403),
|
|
@@ -231,7 +209,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
231
209
|
// and a copy into file:/// are the same disk write under the same review.
|
|
232
210
|
// {§membership-edit-membership-gate} — membership/containment/read-only/binary gate before any disk write
|
|
233
211
|
async #resolveWriteTarget(pathname, ctx, bytesMode = false) {
|
|
234
|
-
const root = await
|
|
212
|
+
const root = await FileAddress.projectRoot(ctx);
|
|
235
213
|
if (root === null) {
|
|
236
214
|
return {
|
|
237
215
|
ok: false,
|
|
@@ -290,7 +268,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
290
268
|
if (occupancyCause.code !== "ENOENT")
|
|
291
269
|
throw occupancyCause;
|
|
292
270
|
}
|
|
293
|
-
canonical = await
|
|
271
|
+
canonical = await FileSnapshot.prospectivePath(requested);
|
|
294
272
|
fileExists = false;
|
|
295
273
|
}
|
|
296
274
|
if (!isMount) {
|
|
@@ -315,13 +293,14 @@ class File extends CoreSchemeAdapterBase {
|
|
|
315
293
|
const rel = key; // the bare canonical member key ({§fs-canonical-name}) — storage ≡ wire
|
|
316
294
|
let original = "";
|
|
317
295
|
let baseSig = null; // the snapshot signature the proposal is computed against; null = create (assumed-absent)
|
|
296
|
+
let sourceMimetype = null;
|
|
318
297
|
let creationAdmission = null;
|
|
319
298
|
if (fileExists) {
|
|
320
|
-
const member = await ctx.db.
|
|
299
|
+
const member = await ctx.db.crud_read_file_snapshot.get({ workspace_id: ctx.workspaceId, pathname: rel });
|
|
321
300
|
// {§fs-errno} — the occupancy fact (POSIX O_EXCL precedent): something invisible
|
|
322
301
|
// occupies the path; existence leaks, content stays dark. The model picks another name.
|
|
323
302
|
if (member === undefined && !isMount && (await lstat(requested)).isDirectory()) {
|
|
324
|
-
return { ok: false, status: 403, ..._a.#directoryFacts(rel, "EDIT") };
|
|
303
|
+
return { ok: false, status: 403, ..._a.#directoryFacts(rel, "EDIT", root) };
|
|
325
304
|
}
|
|
326
305
|
if (member === undefined) {
|
|
327
306
|
return {
|
|
@@ -343,10 +322,15 @@ class File extends CoreSchemeAdapterBase {
|
|
|
343
322
|
if (currentMaterialization.disposition === "input-limit") {
|
|
344
323
|
return { ok: false, ...FileMaterialization.rejection(rel, currentMaterialization) };
|
|
345
324
|
}
|
|
346
|
-
// {§membership-edit-write-
|
|
347
|
-
const
|
|
348
|
-
|
|
325
|
+
// {§membership-edit-write-revision}: retain the materialized baseline through approval.
|
|
326
|
+
const revision = FileSnapshot.decode(member.synced_sig);
|
|
327
|
+
if (revision === null || revision.hash === null || member.content === null || member.source_mimetype === null) {
|
|
328
|
+
const conflict = EditCollision.result(rel);
|
|
329
|
+
return { ok: false, code: "edit-collision", status: conflict.status, detail: conflict.problem.detail };
|
|
330
|
+
}
|
|
331
|
+
original = member.content;
|
|
349
332
|
baseSig = member.synced_sig;
|
|
333
|
+
sourceMimetype = member.source_mimetype;
|
|
350
334
|
}
|
|
351
335
|
else {
|
|
352
336
|
const admission = await GitMembership.planCreation(ctx.db, ctx.workspaceId, rel, ctx.signal);
|
|
@@ -354,7 +338,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
354
338
|
return admission;
|
|
355
339
|
creationAdmission = admission;
|
|
356
340
|
}
|
|
357
|
-
const mimetype = await detectFileMimetype(canonical, ctx);
|
|
341
|
+
const mimetype = sourceMimetype ?? await detectFileMimetype(canonical, ctx);
|
|
358
342
|
// {§binary-parity} — a text EDIT still cannot author bytes into a binary file (a text body is
|
|
359
343
|
// not bytes). A whole-resource or byte-range TRANSFER passes bytesMode: it writes the source's
|
|
360
344
|
// bytes verbatim, so the binary mimetype is the point, not an error.
|
|
@@ -372,7 +356,7 @@ class File extends CoreSchemeAdapterBase {
|
|
|
372
356
|
}
|
|
373
357
|
return { ok: true, canonical, rel, fileExists, original, mimetype, baseSig, creationAdmission };
|
|
374
358
|
}
|
|
375
|
-
// {§membership-edit-write-
|
|
359
|
+
// {§membership-edit-write-revision}, {§proposal-202-pauses}: return status=202
|
|
376
360
|
// with a udiff body for client review and attrs carrying the full patched
|
|
377
361
|
// content. Engine pauses dispatch and calls applyResolution() after accept.
|
|
378
362
|
async editBatch(statements, ctx) {
|
|
@@ -392,11 +376,16 @@ class File extends CoreSchemeAdapterBase {
|
|
|
392
376
|
retryable: false,
|
|
393
377
|
});
|
|
394
378
|
}
|
|
395
|
-
const pathname =
|
|
396
|
-
|
|
379
|
+
const pathname = FileAddress.pathname(statement.target);
|
|
380
|
+
if (typeof pathname !== "string")
|
|
381
|
+
return pathname;
|
|
382
|
+
const target = await this.#resolveWriteTarget(Namespace.expandHome(pathname), core);
|
|
397
383
|
if (!target.ok)
|
|
398
384
|
return failure(target.code, target.status, target.detail, target.extensions);
|
|
399
385
|
const { canonical, rel, fileExists, original, mimetype, baseSig } = target;
|
|
386
|
+
const selectedRevision = SchemeCtxImpl.fileRevisionOf(ctx);
|
|
387
|
+
if (selectedRevision !== undefined && selectedRevision !== baseSig)
|
|
388
|
+
return EditCollision.result(rel);
|
|
400
389
|
for (const candidate of statements.slice(1)) {
|
|
401
390
|
if (candidate.target === null) {
|
|
402
391
|
return failure("edit-batch-mismatch", 400, "The EDIT batch spans multiple resources.", {
|
|
@@ -404,8 +393,10 @@ class File extends CoreSchemeAdapterBase {
|
|
|
404
393
|
retryable: false,
|
|
405
394
|
});
|
|
406
395
|
}
|
|
407
|
-
const candidatePathname =
|
|
408
|
-
|
|
396
|
+
const candidatePathname = FileAddress.pathname(candidate.target);
|
|
397
|
+
if (typeof candidatePathname !== "string")
|
|
398
|
+
return candidatePathname;
|
|
399
|
+
const candidateTarget = await this.#resolveWriteTarget(Namespace.expandHome(candidatePathname), core);
|
|
409
400
|
if (!candidateTarget.ok || candidateTarget.rel !== rel) {
|
|
410
401
|
return failure("edit-batch-mismatch", 400, "The EDIT batch spans multiple resources.", {
|
|
411
402
|
recovery: "Submit a separate EDIT batch for each resource.",
|
|
@@ -458,14 +449,14 @@ class File extends CoreSchemeAdapterBase {
|
|
|
458
449
|
const receiptEdits = fileExists
|
|
459
450
|
? (appliedEdits ?? statements.map((candidate) => ({ marker: candidate.lineMarker, body: candidate.body ?? "" })))
|
|
460
451
|
: [{ marker: { marks: [1, -1] }, body: patched }];
|
|
461
|
-
const batchReceipt = editReceipt(original, patched, receiptEdits, undefined, EntryManifest.toPath("file", "", rel));
|
|
452
|
+
const batchReceipt = editReceipt(original, patched, receiptEdits, undefined, EntryManifest.toPath("file", "", rel, await FileAddress.projectRoot(core)));
|
|
462
453
|
return {
|
|
463
454
|
status: 202,
|
|
464
455
|
body: patch,
|
|
465
456
|
editReceipt: batchReceipt,
|
|
466
457
|
...(scopeNormalizations === undefined ? {} : { scopeNormalizations }),
|
|
467
458
|
...(merges === undefined ? {} : { merges }),
|
|
468
|
-
attrs: { path: rel, canonical, patch, patched, mimetype, editReceipt: batchReceipt, baseSig, existed: fileExists },
|
|
459
|
+
attrs: { path: rel, canonical, original, patch, patched, mimetype, editReceipt: batchReceipt, baseSig, existed: fileExists },
|
|
469
460
|
};
|
|
470
461
|
}
|
|
471
462
|
async edit(statement, ctx) {
|
|
@@ -491,15 +482,22 @@ class File extends CoreSchemeAdapterBase {
|
|
|
491
482
|
if (!target.ok) {
|
|
492
483
|
return Results.failure("scheme:file", target.code, target.status, target.detail, { created: false, entryId: null }, target.extensions);
|
|
493
484
|
}
|
|
494
|
-
const { canonical, rel, fileExists, original,
|
|
485
|
+
const { canonical, rel, fileExists, original, baseSig } = target;
|
|
486
|
+
// {§copy-destination-mimetype}: retain the admitted per-call type on creation.
|
|
487
|
+
const mimetype = fileExists ? target.mimetype : bodyChannel.mimetype;
|
|
488
|
+
const selectedRevision = SchemeCtxImpl.fileRevisionOf(ctx);
|
|
489
|
+
if (selectedRevision !== undefined && selectedRevision !== baseSig)
|
|
490
|
+
return {
|
|
491
|
+
...EditCollision.result(rel), created: false, entryId: null,
|
|
492
|
+
};
|
|
495
493
|
if (bytes !== undefined) {
|
|
496
494
|
// {§binary-parity} — a byte write carries no text diff; the receipt is the byte count and
|
|
497
495
|
// the bytes ride attrs.patchedBytes to applyResolution, which writes them verbatim to disk.
|
|
498
|
-
return { status: 202, created: !fileExists, entryId: null, body: `${bytes.length} bytes`, attrs: { path: rel, canonical, patchedBytes: bytes, patched: "", mimetype, baseSig, existed: fileExists } };
|
|
496
|
+
return { status: 202, created: !fileExists, entryId: null, body: `${bytes.length} bytes`, attrs: { path: rel, canonical, original, patchedBytes: bytes, patched: "", mimetype, baseSig, existed: fileExists } };
|
|
499
497
|
}
|
|
500
498
|
const patched = bodyChannel.content;
|
|
501
499
|
const patch = createPatch(rel, original, patched, "current", "proposed");
|
|
502
|
-
return { status: 202, created: !fileExists, entryId: null, body: patch, attrs: { path: rel, canonical, patch, patched, mimetype, baseSig, existed: fileExists } };
|
|
500
|
+
return { status: 202, created: !fileExists, entryId: null, body: patch, attrs: { path: rel, canonical, original, patch, patched, mimetype, baseSig, existed: fileExists } };
|
|
503
501
|
}
|
|
504
502
|
// applyResolution — called by Engine.dispatch after a proposed log
|
|
505
503
|
// entry resolves with decision=accept. Two responsibilities:
|
|
@@ -511,37 +509,9 @@ class File extends CoreSchemeAdapterBase {
|
|
|
511
509
|
async applyResolution(args, ctx) {
|
|
512
510
|
const core = this.coreContext(ctx);
|
|
513
511
|
const { attrs, body } = args;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (typeof attrs.deletePath === "string") {
|
|
518
|
-
const root = await loadWorkspaceRoot(core.db, core.workspaceId);
|
|
519
|
-
if (root === null) {
|
|
520
|
-
throw new InvalidOperationResultError("An accepted file deletion has no workspace project root.");
|
|
521
|
-
}
|
|
522
|
-
try {
|
|
523
|
-
await rm(join(root, attrs.deletePath));
|
|
524
|
-
}
|
|
525
|
-
catch (err) {
|
|
526
|
-
if (err.code !== "ENOENT") {
|
|
527
|
-
console.error(`File deletion failed for '${attrs.deletePath}':`, err);
|
|
528
|
-
return Results.failure("scheme:file", "delete-failed", 500, `The file could not be deleted: ${ErrorDetail.preview(err)}`, {
|
|
529
|
-
outcome: "delete_failed",
|
|
530
|
-
}, {
|
|
531
|
-
path: attrs.deletePath,
|
|
532
|
-
stage: "filesystem-delete",
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
if ("entries" in ctx)
|
|
537
|
-
await ctx.entries.delete(attrs.deletePath);
|
|
538
|
-
else {
|
|
539
|
-
await EntryCrud.deleteEntry({ authority: "", pathname: attrs.deletePath }, core, "file");
|
|
540
|
-
}
|
|
541
|
-
await GitMembership.removeCreationRecord(core.db, core.workspaceId, attrs.deletePath);
|
|
542
|
-
return { status: 200 };
|
|
543
|
-
}
|
|
544
|
-
let canonical = attrs.canonical;
|
|
512
|
+
if (typeof attrs.deletePath === "string")
|
|
513
|
+
return this.#applyDelete(attrs.deletePath, attrs.baseSig ?? null, core);
|
|
514
|
+
const canonical = attrs.canonical;
|
|
545
515
|
const relPath = attrs.path;
|
|
546
516
|
const patched = body ?? attrs.patched;
|
|
547
517
|
// {§binary-parity} — a byte write (COPY/MOVE of a binary) carries its bytes here; the disk
|
|
@@ -560,51 +530,12 @@ class File extends CoreSchemeAdapterBase {
|
|
|
560
530
|
if (typeof mimetype !== "string" || mimetype.length === 0) {
|
|
561
531
|
throw new InvalidOperationResultError("The accepted file proposal is missing attrs.mimetype.");
|
|
562
532
|
}
|
|
563
|
-
const existed = args.attrs.existed === true;
|
|
564
|
-
|
|
565
|
-
if (
|
|
566
|
-
// {§file-create-transaction}: approval is a fresh admission boundary. Re-resolve
|
|
567
|
-
// the physical target so a parent symlink swapped while review was pending cannot
|
|
568
|
-
// redirect an accepted root-scoped create outside the workspace.
|
|
569
|
-
const acceptedTarget = await this.#resolveWriteTarget(relPath, core, patchedBytes !== undefined);
|
|
570
|
-
if (!acceptedTarget.ok) {
|
|
571
|
-
return Results.failure("scheme:file", acceptedTarget.code, acceptedTarget.status, acceptedTarget.detail, { outcome: "create_refused" }, acceptedTarget.extensions);
|
|
572
|
-
}
|
|
573
|
-
if (acceptedTarget.fileExists) {
|
|
574
|
-
return EditCollision.result(relPath, { outcome: "edit_collision" });
|
|
575
|
-
}
|
|
576
|
-
canonical = acceptedTarget.canonical;
|
|
577
|
-
creationAdmission = acceptedTarget.creationAdmission;
|
|
578
|
-
if (creationAdmission === null) {
|
|
579
|
-
throw new InvalidOperationResultError("A revalidated file creation is missing its admission decision.");
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
// CAS — the write-side twin of #materializeMember's read-gate (synced_sig === sig). The
|
|
583
|
-
// proposal was computed against the snapshot (body + baseSig); if disk drifted out-of-band
|
|
584
|
-
// since propose, the full-blob write would clobber it. Refuse and surface the same neutral
|
|
585
|
-
// edit collision as an entry compare-and-swap —
|
|
586
|
-
// the next reconcile narrates disk truth via FsDivergence; the model re-reads + re-proposes.
|
|
587
|
-
// No clever re-diff, no clobber. {§membership-edit-write-cas}
|
|
588
|
-
const baseSig = (args.attrs.baseSig ?? null);
|
|
589
|
-
let currentSig = null;
|
|
590
|
-
try {
|
|
591
|
-
const st = await stat(canonical);
|
|
592
|
-
currentSig = `${st.mtimeMs}:${st.size}`;
|
|
593
|
-
}
|
|
594
|
-
catch (err) {
|
|
595
|
-
if (err.code !== "ENOENT")
|
|
596
|
-
throw err;
|
|
597
|
-
}
|
|
598
|
-
// Edit → CAS on the snapshot signature (a null baseSig is an un-materialized member with no
|
|
599
|
-
// recorded snapshot — no baseline to guard, so the write proceeds). Create → the proposal
|
|
600
|
-
// assumed an absent path, so any file present now is the conflict.
|
|
601
|
-
const conflict = existed ? (baseSig !== null && currentSig !== baseSig) : (currentSig !== null);
|
|
602
|
-
if (conflict) {
|
|
533
|
+
const existed = args.attrs.existed === true;
|
|
534
|
+
const baseline = FileSnapshot.decode(attrs.baseSig ?? null);
|
|
535
|
+
if (existed && (baseline === null || baseline.hash === null))
|
|
603
536
|
return EditCollision.result(relPath, { outcome: "edit_collision" });
|
|
604
|
-
}
|
|
605
537
|
let receipt = attrs.editReceipt;
|
|
606
|
-
const
|
|
607
|
-
const original = needsOriginal ? (existed ? await readFile(canonical, "utf8") : "") : null;
|
|
538
|
+
const original = attrs.original ?? (existed ? null : "");
|
|
608
539
|
if (body !== undefined && body !== attrs.patched) {
|
|
609
540
|
if (original === null) {
|
|
610
541
|
throw new InvalidOperationResultError("A reviewer-modified file proposal is missing its original content.");
|
|
@@ -613,126 +544,149 @@ class File extends CoreSchemeAdapterBase {
|
|
|
613
544
|
if (typeof attrs.patched !== "string") {
|
|
614
545
|
throw new InvalidOperationResultError("The reviewer-modified file proposal is missing its proposed content.");
|
|
615
546
|
}
|
|
616
|
-
receipt = editReceipt(original, attrs.patched, [{ marker: { marks: [1, -1] }, body: attrs.patched }], undefined, EntryManifest.toPath("file", "", relPath));
|
|
547
|
+
receipt = editReceipt(original, attrs.patched, [{ marker: { marks: [1, -1] }, body: attrs.patched }], undefined, EntryManifest.toPath("file", "", relPath, await FileAddress.projectRoot(core)));
|
|
617
548
|
}
|
|
618
|
-
receipt = reviewerReplacementReceipt(original, patched, receipt, undefined, EntryManifest.toPath("file", "", relPath));
|
|
549
|
+
receipt = reviewerReplacementReceipt(original, patched, receipt, undefined, EntryManifest.toPath("file", "", relPath, await FileAddress.projectRoot(core)));
|
|
619
550
|
}
|
|
620
551
|
if (receipt !== undefined) {
|
|
621
552
|
const parseIssues = await new DbProjectionCaps(core).parseIssueTransition(existed ? original : null, patched, mimetype);
|
|
622
553
|
receipt = withEditReceiptParseIssues(receipt, parseIssues);
|
|
623
554
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (
|
|
629
|
-
await writeFile(canonical, Buffer.from(patchedBytes), { flag: existed ? "w" : "wx" });
|
|
630
|
-
}
|
|
631
|
-
else {
|
|
632
|
-
await writeFile(canonical, patched, { encoding: "utf8", flag: existed ? "w" : "wx" });
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
catch (err) {
|
|
636
|
-
if (!existed && err.code === "EEXIST") {
|
|
555
|
+
return FileSnapshot.serialize(canonical, async () => {
|
|
556
|
+
const acceptedTarget = await this.#resolveWriteTarget(relPath, core, patchedBytes !== undefined);
|
|
557
|
+
if (!acceptedTarget.ok)
|
|
558
|
+
return Results.failure("scheme:file", acceptedTarget.code, acceptedTarget.status, acceptedTarget.detail, {}, acceptedTarget.extensions);
|
|
559
|
+
if (acceptedTarget.canonical !== canonical || acceptedTarget.fileExists !== existed)
|
|
637
560
|
return EditCollision.result(relPath, { outcome: "edit_collision" });
|
|
561
|
+
const creationAdmission = acceptedTarget.creationAdmission;
|
|
562
|
+
const root = await FileAddress.projectRoot(core);
|
|
563
|
+
if (root === null)
|
|
564
|
+
throw new InvalidOperationResultError("An accepted file write has no workspace project root.");
|
|
565
|
+
const publication = await FilePublication.write(join(root, relPath), canonical, baseline, patchedBytes ?? Buffer.from(patched));
|
|
566
|
+
if (publication.status === "conflict")
|
|
567
|
+
return EditCollision.result(relPath, { outcome: "edit_collision" });
|
|
568
|
+
if (publication.status === "failed") {
|
|
569
|
+
console.error(`File publication failed for '${relPath}':`, publication.cause);
|
|
570
|
+
return Results.failure("scheme:file", "write-failed", 500, `${publication.published ? "The file was published, but settlement failed" : "The file was not published"}: ${ErrorDetail.preview(publication.cause)}`, { outcome: "write_failed", ...(publication.published ? { effects: [{ target: relPath, action: existed ? "update" : "create" }] } : {}) }, {
|
|
571
|
+
path: relPath,
|
|
572
|
+
stage: "filesystem-publication",
|
|
573
|
+
published: publication.published,
|
|
574
|
+
});
|
|
638
575
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
// `file` identity; bare-path rendering is a projection of that row.
|
|
650
|
-
// {§binary-parity} — a binary member registers with empty text content: its bytes live on
|
|
651
|
-
// disk and READ serves them through byteSource; the next membership reconcile materializes
|
|
652
|
-
// the facts line and source projection the way a git-tracked binary member gets them.
|
|
653
|
-
const registerContent = patchedBytes !== undefined ? "" : patched;
|
|
654
|
-
const write = "entries" in ctx
|
|
655
|
-
? await ctx.entries.write(relPath, {
|
|
656
|
-
channels: { body: { content: registerContent, mimetype } },
|
|
657
|
-
})
|
|
658
|
-
: await EntryCrud.writeEntry({ authority: "", pathname: relPath }, {
|
|
576
|
+
// Register the exact mimetype resolved at proposal time, then complete the
|
|
577
|
+
// membership-owned incorporation transaction. No fallible receipt work follows.
|
|
578
|
+
try {
|
|
579
|
+
// {§entry-identity-no-null} — file members persist under the reserved
|
|
580
|
+
// `file` identity; bare-path rendering is a projection of that row.
|
|
581
|
+
// {§binary-parity} — a binary member registers with empty text content: its bytes live on
|
|
582
|
+
// disk and READ serves them through byteSource; the next membership reconcile materializes
|
|
583
|
+
// the facts line and source projection the way a git-tracked binary member gets them.
|
|
584
|
+
const registerContent = patchedBytes !== undefined ? "" : patched;
|
|
585
|
+
const write = await EntryCrud.writeEntry({ authority: "", pathname: relPath }, {
|
|
659
586
|
channels: { body: { content: registerContent, mimetype } },
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
throw new InvalidOperationResultError("A created file is missing its entry or admission decision.");
|
|
671
|
-
}
|
|
672
|
-
const root = await loadWorkspaceRoot(core.db, core.workspaceId);
|
|
673
|
-
if (root === null) {
|
|
674
|
-
throw new InvalidOperationResultError("A created file lost its workspace project root.");
|
|
587
|
+
attributes: patchedBytes === undefined
|
|
588
|
+
? FileMaterialization.attributes(FileMaterialization.classify(Buffer.byteLength(patched)))
|
|
589
|
+
: {},
|
|
590
|
+
}, core, "file", { syncedSig: JSON.stringify(publication.revision) });
|
|
591
|
+
const { entryId } = write;
|
|
592
|
+
if (!existed) {
|
|
593
|
+
if (entryId === null || creationAdmission === null || !creationAdmission.ok) {
|
|
594
|
+
throw new InvalidOperationResultError("A created file is missing its entry or admission decision.");
|
|
595
|
+
}
|
|
596
|
+
await GitMembership.incorporateCreation(core.db, core.workspaceId, entryId, relPath, creationAdmission);
|
|
675
597
|
}
|
|
676
|
-
await GitMembership.incorporateCreation(core.db, core.workspaceId, entryId, relPath, creationAdmission);
|
|
677
598
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
catch (rollbackCause) {
|
|
693
|
-
rollbackErrors.push(rollbackCause);
|
|
694
|
-
}
|
|
695
|
-
try {
|
|
696
|
-
await rm(canonical);
|
|
697
|
-
}
|
|
698
|
-
catch (rollbackCause) {
|
|
699
|
-
if (rollbackCause.code !== "ENOENT") {
|
|
599
|
+
catch (err) {
|
|
600
|
+
if (!existed) {
|
|
601
|
+
const rollbackErrors = [];
|
|
602
|
+
try {
|
|
603
|
+
const row = await core.db.crud_find_workspace_entry.get({
|
|
604
|
+
workspace_id: core.workspaceId,
|
|
605
|
+
scheme: "file",
|
|
606
|
+
authority: "",
|
|
607
|
+
pathname: relPath,
|
|
608
|
+
});
|
|
609
|
+
if (row !== undefined)
|
|
610
|
+
await core.db.crud_delete_entry.run({ entry_id: row.id });
|
|
611
|
+
}
|
|
612
|
+
catch (rollbackCause) {
|
|
700
613
|
rollbackErrors.push(rollbackCause);
|
|
701
614
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
615
|
+
try {
|
|
616
|
+
const current = await FileSnapshot.acquire(publication.revision.canonical, null);
|
|
617
|
+
if (current === null && await FileSnapshot.observe(publication.revision.canonical) !== null) {
|
|
618
|
+
throw new Error("The created path changed during rollback validation; it was preserved.");
|
|
619
|
+
}
|
|
620
|
+
if (current !== null && !FileSnapshot.matches(publication.revision, current.revision)) {
|
|
621
|
+
throw new Error("The created path changed; its replacement was preserved.");
|
|
622
|
+
}
|
|
623
|
+
if (current !== null)
|
|
624
|
+
await rm(publication.revision.canonical);
|
|
625
|
+
}
|
|
626
|
+
catch (rollbackCause) {
|
|
627
|
+
if (rollbackCause.code !== "ENOENT") {
|
|
628
|
+
rollbackErrors.push(rollbackCause);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (rollbackErrors.length > 0) {
|
|
632
|
+
const rollback = new AggregateError(rollbackErrors, `Creation rollback failed for '${relPath}'.`);
|
|
633
|
+
console.error(`File creation failed and rollback was incomplete for '${relPath}':`, err, rollback);
|
|
634
|
+
return Results.failure("scheme:file", "creation-rollback-failed", 500, `The file could not be incorporated and rollback was incomplete: ${ErrorDetail.preview(rollback)}`, { outcome: "creation_rollback_failed", effects: [{ target: relPath, action: "create" }] }, {
|
|
635
|
+
path: relPath,
|
|
636
|
+
stage: "creation-rollback",
|
|
637
|
+
cause: ErrorDetail.preview(err),
|
|
638
|
+
retryable: false,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
console.error(`File creation incorporation failed for '${relPath}':`, err);
|
|
642
|
+
return Results.failure("scheme:file", "creation-incorporation-failed", 500, `The file could not be incorporated, so its exclusive creation was rolled back: ${ErrorDetail.preview(err)}`, { outcome: "creation_rolled_back" }, {
|
|
707
643
|
path: relPath,
|
|
708
|
-
stage: "
|
|
709
|
-
cause: ErrorDetail.preview(err),
|
|
644
|
+
stage: "membership-incorporation",
|
|
710
645
|
retryable: false,
|
|
711
646
|
});
|
|
712
647
|
}
|
|
713
|
-
|
|
714
|
-
|
|
648
|
+
// An existing-file write has already changed disk. Surface the partial
|
|
649
|
+
// failure rather than pretending the addressable entry followed it.
|
|
650
|
+
console.error(`File registration failed for '${relPath}':`, err);
|
|
651
|
+
return Results.failure("scheme:file", "materialization-failed", 500, `The file was written to disk but could not be registered: ${ErrorDetail.preview(err)}`, { outcome: "materialize_failed", effects: [{ target: relPath, action: "update" }] }, {
|
|
715
652
|
path: relPath,
|
|
716
|
-
stage: "
|
|
653
|
+
stage: "entry-registration",
|
|
717
654
|
retryable: false,
|
|
718
655
|
});
|
|
719
656
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
657
|
+
if (receipt === undefined)
|
|
658
|
+
return { status: 200 };
|
|
659
|
+
return {
|
|
660
|
+
status: 200,
|
|
661
|
+
editReceipt: receipt,
|
|
662
|
+
result: { receipt: projectEditReceipt(receipt, 0) },
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
async #applyDelete(pathname, baseSig, core) {
|
|
667
|
+
const baseline = FileSnapshot.decode(baseSig);
|
|
668
|
+
if (baseline === null || baseline.hash === null)
|
|
669
|
+
return EditCollision.result(pathname);
|
|
670
|
+
const root = await FileAddress.projectRoot(core);
|
|
671
|
+
if (root === null)
|
|
672
|
+
throw new InvalidOperationResultError("An accepted file deletion has no workspace project root.");
|
|
673
|
+
const requested = join(root, pathname);
|
|
674
|
+
return FileSnapshot.serialize(baseline.canonical, async () => {
|
|
675
|
+
const current = await FileSnapshot.acquire(requested, null);
|
|
676
|
+
if (current === null || !FileSnapshot.matches(baseline, current.revision))
|
|
677
|
+
return EditCollision.result(pathname);
|
|
678
|
+
let deleted = false;
|
|
679
|
+
try {
|
|
680
|
+
await rm(requested);
|
|
681
|
+
deleted = true;
|
|
682
|
+
await EntryCrud.deleteEntry({ authority: "", pathname }, core, "file");
|
|
683
|
+
await GitMembership.removeCreationRecord(core.db, core.workspaceId, pathname);
|
|
684
|
+
return { status: 200 };
|
|
685
|
+
}
|
|
686
|
+
catch (cause) {
|
|
687
|
+
return Results.failure("scheme:file", "delete-failed", 500, `${deleted ? "The file was deleted, but projection settlement failed" : "The file could not be deleted"}: ${ErrorDetail.preview(cause)}`, { outcome: "delete_failed", ...(deleted ? { effects: [{ target: pathname, action: "delete" }] } : {}) }, { path: pathname, stage: deleted ? "entry-deletion" : "filesystem-delete", deleted });
|
|
688
|
+
}
|
|
689
|
+
});
|
|
736
690
|
}
|
|
737
691
|
// deleteEntry — the KILL / MOVE-source counterpart of writeEntry. Deleting a host file is
|
|
738
692
|
// DESTRUCTIVE, so it PROPOSES for review (202), exactly as edit() does — never an ungated unlink.
|
|
@@ -740,21 +694,32 @@ class File extends CoreSchemeAdapterBase {
|
|
|
740
694
|
// disk is never probed or touched. applyResolution unlinks + deregisters on accept.
|
|
741
695
|
async deleteEntry(pathname, ctx) {
|
|
742
696
|
const core = this.coreContext(ctx);
|
|
743
|
-
const root = await
|
|
697
|
+
const root = await FileAddress.projectRoot(core);
|
|
744
698
|
if (root === null) {
|
|
745
699
|
return Results.failure("scheme:file", "project-root-required", 400, "File deletion requires a workspace project root.", {}, { retryable: false });
|
|
746
700
|
}
|
|
747
701
|
const rel = Namespace.canonicalize(pathname, root);
|
|
748
702
|
if (rel === null)
|
|
749
|
-
return Results.failure("scheme:file", "entry-not-found", 404, missDetail("file", pathname), {}, missExtensions("file", pathname));
|
|
750
|
-
const member = await core.db.
|
|
703
|
+
return Results.failure("scheme:file", "entry-not-found", 404, missDetail("file", pathname), {}, missExtensions("file", entryAddress("file", "", pathname, root)));
|
|
704
|
+
const member = await core.db.crud_get_member_sig.get({ workspace_id: core.workspaceId, scheme: "file", authority: "", pathname: rel });
|
|
751
705
|
if (member === undefined && !rel.startsWith("../") && (await _a.#occupant(root, rel))?.isDirectory() === true) {
|
|
752
|
-
const { code, detail, extensions } = _a.#directoryFacts(rel, "KILL");
|
|
706
|
+
const { code, detail, extensions } = _a.#directoryFacts(rel, "KILL", root);
|
|
753
707
|
return Results.failure("scheme:file", code, 404, detail, {}, extensions);
|
|
754
708
|
}
|
|
755
709
|
if (member === undefined)
|
|
756
|
-
return Results.failure("scheme:file", "entry-not-found", 404, missDetail("file", rel), {}, missExtensions("file", rel));
|
|
757
|
-
|
|
710
|
+
return Results.failure("scheme:file", "entry-not-found", 404, missDetail("file", rel), {}, missExtensions("file", entryAddress("file", "", rel, root)));
|
|
711
|
+
const selectedRevision = SchemeCtxImpl.fileRevisionOf(ctx);
|
|
712
|
+
if (selectedRevision !== undefined && selectedRevision !== member.synced_sig)
|
|
713
|
+
return EditCollision.result(rel);
|
|
714
|
+
const baseline = FileSnapshot.decode(member.synced_sig);
|
|
715
|
+
if (baseline === null)
|
|
716
|
+
return EditCollision.result(rel);
|
|
717
|
+
// {§file-delete-revision}: an oversized marker has no body; deletion still captures exact bytes.
|
|
718
|
+
const acquired = baseline.hash === null ? await FileSnapshot.acquire(join(root, rel), null) : null;
|
|
719
|
+
if (baseline.hash === null && (acquired === null || !FileSnapshot.matches(baseline, acquired.revision))) {
|
|
720
|
+
return EditCollision.result(rel);
|
|
721
|
+
}
|
|
722
|
+
return { status: 202, attrs: { deletePath: rel, baseSig: acquired === null ? member.synced_sig : JSON.stringify(acquired.revision) } };
|
|
758
723
|
}
|
|
759
724
|
async deleteChannel(pathname, channel, ctx) {
|
|
760
725
|
if (channel !== _a.manifest.defaultChannel) {
|