@openlfcp/shared-objects 0.1.4 → 0.2.0-beta.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.
- package/README.md +15 -2
- package/dist/admission/actor.d.ts +16 -0
- package/dist/admission/actor.js +35 -0
- package/dist/{canonical.d.ts → admission/canonical.d.ts} +2 -0
- package/dist/{canonical.js → admission/canonical.js} +9 -2
- package/dist/{automerge-bytes.d.ts → admission/framing.d.ts} +32 -1
- package/dist/admission/framing.js +231 -0
- package/dist/admission/index.d.ts +26 -0
- package/dist/admission/index.js +26 -0
- package/dist/{chunk-limits.d.ts → admission/limits.d.ts} +11 -1
- package/dist/{chunk-limits.js → admission/limits.js} +25 -10
- package/dist/{references.d.ts → admission/references.d.ts} +1 -1
- package/dist/{references.js → admission/references.js} +11 -0
- package/dist/admission/sequence.d.ts +81 -0
- package/dist/admission/sequence.js +133 -0
- package/dist/data-profile.d.ts +1 -1
- package/dist/data-profile.js +3 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/replica.d.ts +38 -2
- package/dist/replica.js +177 -214
- package/dist/sections/admission.d.ts +35 -0
- package/dist/sections/admission.js +328 -0
- package/dist/sections/data-profile.d.ts +195 -0
- package/dist/sections/data-profile.js +383 -0
- package/dist/sections/dispatch.d.ts +41 -0
- package/dist/sections/dispatch.js +33 -0
- package/dist/sections/document.d.ts +54 -0
- package/dist/sections/document.js +101 -0
- package/dist/sections/index.d.ts +18 -0
- package/dist/sections/index.js +18 -0
- package/dist/sections/replica.d.ts +412 -0
- package/dist/sections/replica.js +1149 -0
- package/dist/sections/schema.d.ts +77 -0
- package/dist/sections/schema.js +548 -0
- package/dist/sections/tree.d.ts +83 -0
- package/dist/sections/tree.js +290 -0
- package/dist/sections/values.d.ts +30 -0
- package/dist/sections/values.js +36 -0
- package/dist/values.d.ts +3 -18
- package/dist/values.js +7 -95
- package/package.json +12 -4
- package/dist/automerge-bytes.js +0 -111
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { type ProfileProblem } from "../validate.js";
|
|
3
|
+
/**
|
|
4
|
+
* Schema validation of a SHARED-SECTIONS-PROFILE-01 document (§3, §4,
|
|
5
|
+
* §14.2) as Automerge stores it, read through the backend so that a scalar
|
|
6
|
+
* string and collaborative Text are told apart, conflicted or not (§2: never
|
|
7
|
+
* from a plain JSON dump). Every concurrent value of a field is inspected.
|
|
8
|
+
*
|
|
9
|
+
* - Section-level problems (the root, the section map) leave nothing to
|
|
10
|
+
* project: the document is "invalid".
|
|
11
|
+
* - Otherwise each invalid node, placement and object is isolated with one
|
|
12
|
+
* diagnostic, the first that applies in the §14.2 order; the rest of the
|
|
13
|
+
* section stays usable.
|
|
14
|
+
* - A section without `ready` is "importing" (§12.1).
|
|
15
|
+
*
|
|
16
|
+
* Structural facts (placement conflicts, cycles, blocked parents, lifecycle
|
|
17
|
+
* conflicts, §14.3) and the effective tree are not decided here. Admission
|
|
18
|
+
* (§14.1) runs before a change is merged; this reads merged state only and
|
|
19
|
+
* never changes the document.
|
|
20
|
+
*/
|
|
21
|
+
/** §14.2: the diagnostics of a section value, in their order. */
|
|
22
|
+
export type SectionDiagnostic = "INVALID_ROOT" | "INVALID_OBJECT_ID" | "OBJECT_ID_MISMATCH" | "MISSING_REQUIRED_FIELD" | "INVALID_FIELD_TYPE" | "INVALID_ENUM_VALUE" | "INVALID_EXTENSION_NAMESPACE" | "INVALID_PRINCIPAL_REF" | "INVALID_TIMESTAMP" | "INVALID_LOCAL_DATE" | "INVALID_COLLECTION_REPRESENTATION" | "INVALID_TAG" | "INVALID_REFERENCE" | "IMMUTABLE_FIELD_MUTATED";
|
|
23
|
+
/** §14.2: SOP §74.1's registry with the section rows, in table order. */
|
|
24
|
+
export declare const SECTION_DIAGNOSTIC_ORDER: readonly SectionDiagnostic[];
|
|
25
|
+
/** One section validation failure: PROFILE_INVALID with its §14.2 diagnostic. */
|
|
26
|
+
export interface SectionProblem {
|
|
27
|
+
readonly code: "PROFILE_INVALID";
|
|
28
|
+
readonly diagnostic: SectionDiagnostic;
|
|
29
|
+
/** JSON Pointer of the offending value (of its container, for a missing field). */
|
|
30
|
+
readonly pointer: string;
|
|
31
|
+
readonly message: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SectionValidation {
|
|
34
|
+
/**
|
|
35
|
+
* "invalid": a section-level problem, nothing is projected (§14.2);
|
|
36
|
+
* "importing": valid, `ready` not yet written (§12.1);
|
|
37
|
+
* "ready": valid and ready.
|
|
38
|
+
*/
|
|
39
|
+
readonly state: "invalid" | "importing" | "ready";
|
|
40
|
+
/** The section-level problems: the root and the section map. */
|
|
41
|
+
readonly problems: readonly SectionProblem[];
|
|
42
|
+
/** The section ID, when it is one. */
|
|
43
|
+
readonly sectionId: string | undefined;
|
|
44
|
+
/** Each invalid node with its one diagnostic (§14.2); its descendants are blocked (§7). */
|
|
45
|
+
readonly nodes: ReadonlyMap<string, SectionProblem>;
|
|
46
|
+
/** Each invalid placement with its one diagnostic. */
|
|
47
|
+
readonly placements: ReadonlyMap<string, SectionProblem>;
|
|
48
|
+
/** Each invalid object (a Task or a preserved unknown Shared Object), per field (SOP §74.1). */
|
|
49
|
+
readonly objects: ReadonlyMap<string, readonly ProfileProblem[]>;
|
|
50
|
+
/**
|
|
51
|
+
* §14.2, SOP §21 OBJECT_ID_COLLISION: IDs under which `nodes`,
|
|
52
|
+
* `objects` or `placements` holds concurrent maps, sorted. A separate
|
|
53
|
+
* named error, not a diagnostic. Reusing one ID across categories (a
|
|
54
|
+
* node ID equal to a PlacementId, say) is a writer error (§3) that a
|
|
55
|
+
* reader does not check: isolating it would let any writer take out
|
|
56
|
+
* another's node with an ordinary change.
|
|
57
|
+
*/
|
|
58
|
+
readonly collisions: readonly string[];
|
|
59
|
+
/**
|
|
60
|
+
* §14.2: the nodes whose own ID, Task ID or selected PlacementId
|
|
61
|
+
* collides, sorted. They are not validated and not projected, and no
|
|
62
|
+
* value is chosen; their descendants are blocked (§7).
|
|
63
|
+
*/
|
|
64
|
+
readonly collided: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Validates a section document (§3, §4, §14.2). Reads only; the document is
|
|
68
|
+
* unchanged. A document's heads determine its content, so a validation is
|
|
69
|
+
* computed once per heads: committing and then reading one revision (or a
|
|
70
|
+
* clone of it) validates it once. A later revision of a document validated
|
|
71
|
+
* before reads again only the nodes, placements and objects its changes
|
|
72
|
+
* touched (A.diff); the checks across entities run in full each time.
|
|
73
|
+
*/
|
|
74
|
+
export declare function validateSection(doc: A.Doc<unknown>): SectionValidation;
|
|
75
|
+
/** Validates without the per-revision cache and the incremental path: for tests. */
|
|
76
|
+
export declare function validateSectionInFull(doc: A.Doc<unknown>): SectionValidation;
|
|
77
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { isObjectId } from "@openlfcp/core";
|
|
3
|
+
import { storedObjectProblems } from "../replica.js";
|
|
4
|
+
import { pointerToken } from "../validate.js";
|
|
5
|
+
import { isPrincipalRef, isReverseDomain, isUtcTimestamp } from "../values.js";
|
|
6
|
+
import { LIST_STYLES, NODE_KINDS, PARENT_KINDS, ROOT_MAPS, SECTIONS_PROFILE_ID, TEXT_KINDS, } from "./values.js";
|
|
7
|
+
/** §14.2: SOP §74.1's registry with the section rows, in table order. */
|
|
8
|
+
export const SECTION_DIAGNOSTIC_ORDER = Object.freeze([
|
|
9
|
+
"INVALID_ROOT",
|
|
10
|
+
"INVALID_OBJECT_ID",
|
|
11
|
+
"OBJECT_ID_MISMATCH",
|
|
12
|
+
"MISSING_REQUIRED_FIELD",
|
|
13
|
+
"INVALID_FIELD_TYPE",
|
|
14
|
+
"INVALID_ENUM_VALUE",
|
|
15
|
+
"INVALID_EXTENSION_NAMESPACE",
|
|
16
|
+
"INVALID_PRINCIPAL_REF",
|
|
17
|
+
"INVALID_TIMESTAMP",
|
|
18
|
+
"INVALID_LOCAL_DATE",
|
|
19
|
+
"INVALID_COLLECTION_REPRESENTATION",
|
|
20
|
+
"INVALID_TAG",
|
|
21
|
+
"INVALID_REFERENCE",
|
|
22
|
+
"IMMUTABLE_FIELD_MUTATED",
|
|
23
|
+
]);
|
|
24
|
+
const problem = (diagnostic, pointer, message) => Object.freeze({ code: "PROFILE_INVALID", diagnostic, pointer, message });
|
|
25
|
+
const rank = (p) => SECTION_DIAGNOSTIC_ORDER.indexOf(p.diagnostic);
|
|
26
|
+
/** The first problem in §14.2 order; ties keep their discovery order. */
|
|
27
|
+
const first = (problems) => problems.reduce((best, p) => (best === undefined || rank(p) < rank(best) ? p : best), undefined);
|
|
28
|
+
const ref = (base, key) => `${base}/${pointerToken(key)}`;
|
|
29
|
+
/**
|
|
30
|
+
* Reads a document through the backend, at its current state (no heads: a
|
|
31
|
+
* historical read is far slower). One validation asks for most fields more
|
|
32
|
+
* than once (present? then its values, then the one string); each answer is
|
|
33
|
+
* kept for the pass, which runs on one unchanging document.
|
|
34
|
+
*/
|
|
35
|
+
class Reader {
|
|
36
|
+
#backend;
|
|
37
|
+
#values = new Map();
|
|
38
|
+
constructor(doc) {
|
|
39
|
+
this.#backend = A.getBackend(doc);
|
|
40
|
+
}
|
|
41
|
+
all(obj, prop) {
|
|
42
|
+
const key = `${obj}\u0000${prop}`;
|
|
43
|
+
let values = this.#values.get(key);
|
|
44
|
+
if (values === undefined) {
|
|
45
|
+
values = this.#backend.getAll(obj, prop);
|
|
46
|
+
this.#values.set(key, values);
|
|
47
|
+
}
|
|
48
|
+
return values;
|
|
49
|
+
}
|
|
50
|
+
keys(obj) {
|
|
51
|
+
return this.#backend.keys(obj);
|
|
52
|
+
}
|
|
53
|
+
length(obj) {
|
|
54
|
+
return this.#backend.length(obj);
|
|
55
|
+
}
|
|
56
|
+
/** The value of `prop` when it has exactly one, and that one is a map. */
|
|
57
|
+
map(obj, prop) {
|
|
58
|
+
const values = this.all(obj, prop);
|
|
59
|
+
return values.length === 1 && values[0]?.[0] === "map" ? values[0][1] : undefined;
|
|
60
|
+
}
|
|
61
|
+
/** The value of `prop` when it has exactly one, and that one is a scalar string. */
|
|
62
|
+
str(obj, prop) {
|
|
63
|
+
const values = this.all(obj, prop);
|
|
64
|
+
return values.length === 1 && values[0]?.[0] === "str" ? values[0][1] : undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The checks of one map's fields, collected with their pointers. */
|
|
68
|
+
class Fields {
|
|
69
|
+
r;
|
|
70
|
+
obj;
|
|
71
|
+
at;
|
|
72
|
+
what;
|
|
73
|
+
out = [];
|
|
74
|
+
constructor(r, obj, at, what) {
|
|
75
|
+
this.r = r;
|
|
76
|
+
this.obj = obj;
|
|
77
|
+
this.at = at;
|
|
78
|
+
this.what = what;
|
|
79
|
+
}
|
|
80
|
+
has(field) {
|
|
81
|
+
return this.r.all(this.obj, field).length > 0;
|
|
82
|
+
}
|
|
83
|
+
required(fields) {
|
|
84
|
+
for (const f of fields)
|
|
85
|
+
if (!this.has(f))
|
|
86
|
+
this.out.push(problem("MISSING_REQUIRED_FIELD", this.at, `${this.what} has no required field ${f}`));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Every value of `field` is a scalar string that `check` accepts (or
|
|
90
|
+
* `onBad` with `reason`); an immutable field must have one value.
|
|
91
|
+
*/
|
|
92
|
+
scalar(field, check, onBad, reason, immutable) {
|
|
93
|
+
const values = this.r.all(this.obj, field);
|
|
94
|
+
const at = ref(this.at, field);
|
|
95
|
+
for (const v of values) {
|
|
96
|
+
if (v[0] !== "str")
|
|
97
|
+
this.out.push(problem("INVALID_FIELD_TYPE", at, v[0] === "text"
|
|
98
|
+
? `${field} is collaborative Text, not a scalar string (§3)`
|
|
99
|
+
: `${field} must be a scalar string (§3, §4)`));
|
|
100
|
+
else if (check !== undefined && !check(v[1]))
|
|
101
|
+
this.out.push(problem(onBad, at, `${field} ${reason}`));
|
|
102
|
+
}
|
|
103
|
+
if (immutable && values.length > 1)
|
|
104
|
+
this.out.push(problem("IMMUTABLE_FIELD_MUTATED", at, `the immutable ${field} has concurrent values`));
|
|
105
|
+
}
|
|
106
|
+
/** The field is one list (insert-only, §4.1) of scalar strings. */
|
|
107
|
+
list(field) {
|
|
108
|
+
const values = this.r.all(this.obj, field);
|
|
109
|
+
const at = ref(this.at, field);
|
|
110
|
+
if (values.length !== 1 || values[0]?.[0] !== "list") {
|
|
111
|
+
this.out.push(problem("INVALID_FIELD_TYPE", at, `${field} must be one Automerge list (§4)`));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const list = values[0][1];
|
|
115
|
+
for (let i = 0; i < this.r.length(list); i++)
|
|
116
|
+
if (this.r.all(list, i).some((v) => v[0] !== "str"))
|
|
117
|
+
this.out.push(problem("INVALID_FIELD_TYPE", `${at}/${i}`, `a ${field} entry must be a scalar string PlacementId (§4.3)`));
|
|
118
|
+
}
|
|
119
|
+
/** The field is one map whose keys are reverse-domain namespaces (§3, SOP §18). */
|
|
120
|
+
extensions(field = "extensions") {
|
|
121
|
+
const values = this.r.all(this.obj, field);
|
|
122
|
+
const at = ref(this.at, field);
|
|
123
|
+
if (values.length !== 1 || values[0]?.[0] !== "map") {
|
|
124
|
+
this.out.push(problem("INVALID_FIELD_TYPE", at, `${field} must be one map (§3)`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const ns of this.r.keys(values[0][1]))
|
|
128
|
+
if (!isReverseDomain(ns))
|
|
129
|
+
this.out.push(problem("INVALID_EXTENSION_NAMESPACE", ref(at, ns), `"${ns}" is not a reverse-domain namespace (§3)`));
|
|
130
|
+
}
|
|
131
|
+
createdBy() {
|
|
132
|
+
this.scalar("created_by", isPrincipalRef, "INVALID_PRINCIPAL_REF", "is not p: + base64url of 32 bytes", true);
|
|
133
|
+
}
|
|
134
|
+
createdAt() {
|
|
135
|
+
if (this.has("created_at"))
|
|
136
|
+
this.scalar("created_at", isUtcTimestamp, "INVALID_TIMESTAMP", "is not an RFC 3339 UTC timestamp", true);
|
|
137
|
+
}
|
|
138
|
+
/** `id` is a canonical UUIDv7 equal to the map key `key`. */
|
|
139
|
+
id(key) {
|
|
140
|
+
this.scalar("id", isObjectId, "INVALID_OBJECT_ID", "is not a canonical UUIDv7 (§3)", true);
|
|
141
|
+
const id = this.r.str(this.obj, "id");
|
|
142
|
+
if (id !== undefined && id !== key && isObjectId(id))
|
|
143
|
+
this.out.push(problem("OBJECT_ID_MISMATCH", ref(this.at, "id"), "id differs from its map key"));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const isNodeKind = (s) => NODE_KINDS.includes(s);
|
|
147
|
+
const LIFECYCLES = new Set(["active", "deleted"]);
|
|
148
|
+
function rootFacts(r) {
|
|
149
|
+
const root = new Fields(r, "_root", "", "the root");
|
|
150
|
+
const rootProblems = [];
|
|
151
|
+
// §3: the profile, and one map per root container.
|
|
152
|
+
const profiles = r.all("_root", "profile");
|
|
153
|
+
if (profiles.length !== 1 || profiles[0]?.[0] !== "str" || profiles[0][1] !== SECTIONS_PROFILE_ID)
|
|
154
|
+
rootProblems.push(problem("INVALID_ROOT", "/profile", `profile must be the scalar string ${SECTIONS_PROFILE_ID}`));
|
|
155
|
+
const containers = new Map();
|
|
156
|
+
for (const key of ROOT_MAPS) {
|
|
157
|
+
const obj = r.map("_root", key);
|
|
158
|
+
if (obj === undefined)
|
|
159
|
+
rootProblems.push(problem("INVALID_ROOT", `/${key}`, `${key} must be exactly one map (§3)`));
|
|
160
|
+
else
|
|
161
|
+
containers.set(key, obj);
|
|
162
|
+
}
|
|
163
|
+
const extensions = containers.get("extensions");
|
|
164
|
+
if (extensions !== undefined) {
|
|
165
|
+
root.extensions();
|
|
166
|
+
rootProblems.push(...root.out);
|
|
167
|
+
}
|
|
168
|
+
// §4.1: the section map.
|
|
169
|
+
const sectionObj = containers.get("section");
|
|
170
|
+
let sectionId;
|
|
171
|
+
let ready = false;
|
|
172
|
+
if (sectionObj !== undefined) {
|
|
173
|
+
const section = new Fields(r, sectionObj, "/section", "the section");
|
|
174
|
+
section.required(["id", "title", "created_by", "children", "extensions"]);
|
|
175
|
+
if (section.has("id"))
|
|
176
|
+
section.scalar("id", isObjectId, "INVALID_OBJECT_ID", "is not a canonical UUIDv7 (§3)", true);
|
|
177
|
+
if (section.has("title"))
|
|
178
|
+
section.scalar("title", undefined, "INVALID_FIELD_TYPE", "", false);
|
|
179
|
+
if (section.has("created_by"))
|
|
180
|
+
section.createdBy();
|
|
181
|
+
section.createdAt();
|
|
182
|
+
if (section.has("children")) {
|
|
183
|
+
const children = r.all(sectionObj, "children");
|
|
184
|
+
if (children.length !== 1 || children[0]?.[0] !== "list")
|
|
185
|
+
rootProblems.push(problem("INVALID_ROOT", "/section/children", "section.children must be one list (§3)"));
|
|
186
|
+
else
|
|
187
|
+
section.list("children");
|
|
188
|
+
}
|
|
189
|
+
if (section.has("extensions"))
|
|
190
|
+
section.extensions();
|
|
191
|
+
// §12.1: ready is the scalar boolean true, or absent.
|
|
192
|
+
const readyValues = r.all(sectionObj, "ready");
|
|
193
|
+
if (readyValues.some((v) => v[0] !== "boolean" || v[1] !== true))
|
|
194
|
+
section.out.push(problem("INVALID_FIELD_TYPE", "/section/ready", "ready must be the scalar boolean true"));
|
|
195
|
+
ready = readyValues.length > 0;
|
|
196
|
+
rootProblems.push(...section.out);
|
|
197
|
+
sectionId = r.str(sectionObj, "id");
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
problems: rootProblems,
|
|
201
|
+
sectionId,
|
|
202
|
+
ready,
|
|
203
|
+
nodesObj: containers.get("nodes"),
|
|
204
|
+
placementsObj: containers.get("placements"),
|
|
205
|
+
objectsObj: containers.get("objects"),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function nodeFacts(r, nodesObj, k) {
|
|
209
|
+
const collides = r.all(nodesObj, k).length > 1;
|
|
210
|
+
const obj = r.map(nodesObj, k);
|
|
211
|
+
const own = obj === undefined ? undefined : r.str(obj, "kind");
|
|
212
|
+
const kind = own !== undefined && isNodeKind(own) ? own : undefined;
|
|
213
|
+
const selected = obj === undefined ? [] : r.all(obj, "placement");
|
|
214
|
+
const at = ref("/nodes", k);
|
|
215
|
+
const out = [];
|
|
216
|
+
if (!isObjectId(k))
|
|
217
|
+
out.push(problem("INVALID_OBJECT_ID", at, "a NodeId key is not a canonical UUIDv7"));
|
|
218
|
+
if (obj === undefined) {
|
|
219
|
+
out.push(problem("INVALID_FIELD_TYPE", at, "a node must be a map (§4.2)"));
|
|
220
|
+
return { collides, map: obj, kind, selected, local: out };
|
|
221
|
+
}
|
|
222
|
+
const f = new Fields(r, obj, at, "a node");
|
|
223
|
+
f.required(["id", "kind", "created_by", "lifecycle", "placement", "children", "extensions"]);
|
|
224
|
+
if (kind === "task")
|
|
225
|
+
f.required(["task_id"]);
|
|
226
|
+
if (kind !== undefined && TEXT_KINDS.has(kind))
|
|
227
|
+
f.required(["text"]);
|
|
228
|
+
if (f.has("id"))
|
|
229
|
+
f.id(k);
|
|
230
|
+
if (f.has("kind"))
|
|
231
|
+
f.scalar("kind", isNodeKind, "INVALID_ENUM_VALUE", "is not task, paragraph, item or raw (§4.2)", true);
|
|
232
|
+
if (f.has("created_by"))
|
|
233
|
+
f.createdBy();
|
|
234
|
+
f.createdAt();
|
|
235
|
+
if (f.has("lifecycle"))
|
|
236
|
+
f.scalar("lifecycle", (s) => (kind === "task" ? s === "active" : LIFECYCLES.has(s)), "INVALID_ENUM_VALUE", kind === "task"
|
|
237
|
+
? "of a task node is always active; its Task holds the lifecycle (§4.2)"
|
|
238
|
+
: "is not active or deleted (§9)", false);
|
|
239
|
+
if (f.has("placement"))
|
|
240
|
+
f.scalar("placement", isObjectId, "INVALID_OBJECT_ID", "is not a canonical UUIDv7 PlacementId", false);
|
|
241
|
+
if (f.has("children"))
|
|
242
|
+
f.list("children");
|
|
243
|
+
if (f.has("extensions"))
|
|
244
|
+
f.extensions();
|
|
245
|
+
// task_id on task nodes only, equal to the node's ID.
|
|
246
|
+
if (f.has("task_id")) {
|
|
247
|
+
if (kind !== undefined && kind !== "task")
|
|
248
|
+
f.out.push(problem("INVALID_FIELD_TYPE", ref(at, "task_id"), "only a task node has task_id (§4.2)"));
|
|
249
|
+
else {
|
|
250
|
+
f.scalar("task_id", isObjectId, "INVALID_OBJECT_ID", "is not a canonical UUIDv7", true);
|
|
251
|
+
const taskId = r.str(obj, "task_id");
|
|
252
|
+
if (taskId !== undefined && isObjectId(taskId) && taskId !== k)
|
|
253
|
+
f.out.push(problem("OBJECT_ID_MISMATCH", ref(at, "task_id"), "a task node's ID differs from its task_id (§4.2)"));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// text: collaborative Text on paragraph, item and raw nodes; absent on task nodes.
|
|
257
|
+
const texts = r.all(obj, "text");
|
|
258
|
+
if (kind === "task" && texts.length > 0)
|
|
259
|
+
f.out.push(problem("INVALID_FIELD_TYPE", ref(at, "text"), "a task node has no text (§4.2)"));
|
|
260
|
+
else if (texts.some((v) => v[0] !== "text"))
|
|
261
|
+
f.out.push(problem("INVALID_FIELD_TYPE", ref(at, "text"), "text must be collaborative Text (§4.2)"));
|
|
262
|
+
// list_style on task and item nodes only.
|
|
263
|
+
if (f.has("list_style")) {
|
|
264
|
+
if (kind === "paragraph" || kind === "raw")
|
|
265
|
+
f.out.push(problem("INVALID_FIELD_TYPE", ref(at, "list_style"), "only task and item nodes have list_style (§4.2)"));
|
|
266
|
+
else
|
|
267
|
+
f.scalar("list_style", (s) => LIST_STYLES.includes(s), "INVALID_ENUM_VALUE", "is not bullet or ordered (§4.2)", false);
|
|
268
|
+
}
|
|
269
|
+
out.push(...f.out);
|
|
270
|
+
return { collides, map: obj, kind, selected, local: out };
|
|
271
|
+
}
|
|
272
|
+
function placementFacts(r, placementsObj, k) {
|
|
273
|
+
const collides = r.all(placementsObj, k).length > 1;
|
|
274
|
+
const at = ref("/placements", k);
|
|
275
|
+
const obj = r.map(placementsObj, k);
|
|
276
|
+
const out = [];
|
|
277
|
+
if (!isObjectId(k))
|
|
278
|
+
out.push(problem("INVALID_OBJECT_ID", at, "a PlacementId key is not a canonical UUIDv7"));
|
|
279
|
+
if (obj === undefined) {
|
|
280
|
+
out.push(problem("INVALID_FIELD_TYPE", at, "a placement must be a map (§4.3)"));
|
|
281
|
+
return { collides, map: obj, local: out, nodeId: undefined, parentId: undefined };
|
|
282
|
+
}
|
|
283
|
+
const f = new Fields(r, obj, at, "a placement");
|
|
284
|
+
f.required(["id", "node_id", "parent_id", "created_by"]);
|
|
285
|
+
if (f.has("id"))
|
|
286
|
+
f.id(k);
|
|
287
|
+
for (const field of ["node_id", "parent_id"])
|
|
288
|
+
if (f.has(field))
|
|
289
|
+
f.scalar(field, isObjectId, "INVALID_OBJECT_ID", "is not a canonical UUIDv7", true);
|
|
290
|
+
if (f.has("created_by"))
|
|
291
|
+
f.createdBy();
|
|
292
|
+
out.push(...f.out);
|
|
293
|
+
return {
|
|
294
|
+
collides,
|
|
295
|
+
map: obj,
|
|
296
|
+
local: out,
|
|
297
|
+
nodeId: r.str(obj, "node_id"),
|
|
298
|
+
parentId: r.str(obj, "parent_id"),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function objectFacts(doc, r, objectsObj, k) {
|
|
302
|
+
const values = r.all(objectsObj, k);
|
|
303
|
+
const collides = values.length > 1;
|
|
304
|
+
const map = r.map(objectsObj, k);
|
|
305
|
+
const type = map === undefined ? undefined : r.str(map, "type");
|
|
306
|
+
if (collides)
|
|
307
|
+
return { collides, map, type, problems: undefined };
|
|
308
|
+
// Objects: SOP Tasks and preserved unknown Shared Objects (§2, §3).
|
|
309
|
+
if (values[0]?.[0] !== "map")
|
|
310
|
+
return {
|
|
311
|
+
collides,
|
|
312
|
+
map,
|
|
313
|
+
type,
|
|
314
|
+
problems: [
|
|
315
|
+
Object.freeze({
|
|
316
|
+
code: "PROFILE_INVALID",
|
|
317
|
+
diagnostic: "INVALID_FIELD_TYPE",
|
|
318
|
+
pointer: ref("/objects", k),
|
|
319
|
+
message: "a Shared Object must be a map",
|
|
320
|
+
}),
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
const stored = doc.objects?.[k];
|
|
324
|
+
const problems = stored === undefined ? [] : storedObjectProblems(doc, stored, k);
|
|
325
|
+
return {
|
|
326
|
+
collides,
|
|
327
|
+
map,
|
|
328
|
+
type,
|
|
329
|
+
problems: problems.length > 0 ? Object.freeze(problems) : undefined,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/** Facts of every entity, or of the `dirty` ones over `base`, whose other entities are unchanged. */
|
|
333
|
+
function readFacts(doc, base) {
|
|
334
|
+
const r = new Reader(doc);
|
|
335
|
+
const root = rootFacts(r);
|
|
336
|
+
const reuse = base !== undefined &&
|
|
337
|
+
base.facts.root.nodesObj === root.nodesObj &&
|
|
338
|
+
base.facts.root.placementsObj === root.placementsObj &&
|
|
339
|
+
base.facts.root.objectsObj === root.objectsObj
|
|
340
|
+
? base
|
|
341
|
+
: undefined;
|
|
342
|
+
const nodeKeys = root.nodesObj === undefined ? [] : r.keys(root.nodesObj);
|
|
343
|
+
const placementKeys = root.placementsObj === undefined ? [] : r.keys(root.placementsObj);
|
|
344
|
+
const objectKeys = root.objectsObj === undefined ? [] : r.keys(root.objectsObj);
|
|
345
|
+
const collect = (keys, obj, old, dirty, read) => {
|
|
346
|
+
const out = new Map();
|
|
347
|
+
if (obj === undefined)
|
|
348
|
+
return out;
|
|
349
|
+
for (const k of keys) {
|
|
350
|
+
const kept = dirty?.has(k) ? undefined : old?.get(k);
|
|
351
|
+
out.set(k, kept ?? read(obj, k));
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
};
|
|
355
|
+
return {
|
|
356
|
+
root,
|
|
357
|
+
nodeKeys,
|
|
358
|
+
placementKeys,
|
|
359
|
+
objectKeys,
|
|
360
|
+
nodes: collect(nodeKeys, root.nodesObj, reuse?.facts.nodes, reuse?.dirty.nodes, (o, k) => nodeFacts(r, o, k)),
|
|
361
|
+
placements: collect(placementKeys, root.placementsObj, reuse?.facts.placements, reuse?.dirty.placements, (o, k) => placementFacts(r, o, k)),
|
|
362
|
+
objects: collect(objectKeys, root.objectsObj, reuse?.facts.objects, reuse?.dirty.objects, (o, k) => objectFacts(doc, r, o, k)),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** The validation of a document from its facts: the checks across entities, in document order. */
|
|
366
|
+
function assemble(facts) {
|
|
367
|
+
const { root, nodeKeys, placementKeys, objectKeys } = facts;
|
|
368
|
+
const { sectionId } = root;
|
|
369
|
+
const nodeSet = new Set(nodeKeys);
|
|
370
|
+
// §14.2, SOP §21: collisions. A key created concurrently twice holds two maps.
|
|
371
|
+
const collisions = new Set();
|
|
372
|
+
for (const [keys, of] of [
|
|
373
|
+
[nodeKeys, facts.nodes],
|
|
374
|
+
[placementKeys, facts.placements],
|
|
375
|
+
[objectKeys, facts.objects],
|
|
376
|
+
])
|
|
377
|
+
for (const k of keys)
|
|
378
|
+
if (of.get(k)?.collides)
|
|
379
|
+
collisions.add(k);
|
|
380
|
+
const kinds = new Map();
|
|
381
|
+
for (const k of nodeKeys) {
|
|
382
|
+
const kind = facts.nodes.get(k)?.kind;
|
|
383
|
+
if (kind !== undefined)
|
|
384
|
+
kinds.set(k, kind);
|
|
385
|
+
}
|
|
386
|
+
const objects = new Map();
|
|
387
|
+
for (const k of objectKeys) {
|
|
388
|
+
const o = facts.objects.get(k);
|
|
389
|
+
if (o === undefined || collisions.has(k))
|
|
390
|
+
continue;
|
|
391
|
+
if (o.problems !== undefined)
|
|
392
|
+
objects.set(k, o.problems);
|
|
393
|
+
}
|
|
394
|
+
// §4.3: placements.
|
|
395
|
+
const placements = new Map();
|
|
396
|
+
for (const k of placementKeys) {
|
|
397
|
+
const p = facts.placements.get(k);
|
|
398
|
+
if (p === undefined || collisions.has(k))
|
|
399
|
+
continue;
|
|
400
|
+
const at = ref("/placements", k);
|
|
401
|
+
const out = [...p.local];
|
|
402
|
+
if (out.length === 0 && p.map !== undefined) {
|
|
403
|
+
if (p.nodeId === undefined || !nodeSet.has(p.nodeId))
|
|
404
|
+
out.push(problem("INVALID_REFERENCE", ref(at, "node_id"), "node_id names no node"));
|
|
405
|
+
if (p.parentId === undefined || (p.parentId !== sectionId && !nodeSet.has(p.parentId)))
|
|
406
|
+
out.push(problem("INVALID_REFERENCE", ref(at, "parent_id"), "parent_id names no node or section"));
|
|
407
|
+
}
|
|
408
|
+
const worst = first(out);
|
|
409
|
+
if (worst !== undefined)
|
|
410
|
+
placements.set(k, worst);
|
|
411
|
+
}
|
|
412
|
+
// §14.2: a node whose own ID (a Task node's is its Task ID) or selected
|
|
413
|
+
// PlacementId collides is not validated.
|
|
414
|
+
const collided = new Set(nodeKeys.filter((k) => {
|
|
415
|
+
if (collisions.has(k))
|
|
416
|
+
return true;
|
|
417
|
+
const selected = facts.nodes.get(k)?.selected ?? [];
|
|
418
|
+
return selected.length === 1 && collisions.has(selected[0]?.[1]);
|
|
419
|
+
}));
|
|
420
|
+
// §4.2: nodes, then their references.
|
|
421
|
+
const nodes = new Map();
|
|
422
|
+
for (const k of nodeKeys) {
|
|
423
|
+
const n = facts.nodes.get(k);
|
|
424
|
+
if (n === undefined || collided.has(k))
|
|
425
|
+
continue;
|
|
426
|
+
const at = ref("/nodes", k);
|
|
427
|
+
const out = [...n.local];
|
|
428
|
+
if (n.map === undefined) {
|
|
429
|
+
nodes.set(k, first(out));
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// §14.2 references, for an otherwise valid node.
|
|
433
|
+
if (out.length === 0) {
|
|
434
|
+
if (n.kind === "task") {
|
|
435
|
+
const task = facts.objects.get(k);
|
|
436
|
+
const taskProblems = objects.get(k);
|
|
437
|
+
if (task?.map === undefined || task.type !== "task")
|
|
438
|
+
out.push(problem("INVALID_REFERENCE", ref(at, "task_id"), "task_id names no Task (§4.2)"));
|
|
439
|
+
else if (taskProblems !== undefined && taskProblems.length > 0) {
|
|
440
|
+
// §14.2: a Task whose object is invalid isolates its Task node the same way.
|
|
441
|
+
const worst = [...taskProblems].sort((a, b) => SECTION_DIAGNOSTIC_ORDER.indexOf(a.diagnostic) -
|
|
442
|
+
SECTION_DIAGNOSTIC_ORDER.indexOf(b.diagnostic))[0];
|
|
443
|
+
out.push(problem(worst.diagnostic, worst.pointer, `its Task is invalid: ${worst.message}`));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// Only an unconflicted placement selects a parent (§7); a conflict is a fact, not a problem.
|
|
447
|
+
if (n.selected.length === 1) {
|
|
448
|
+
const pid = n.selected[0]?.[1];
|
|
449
|
+
const placement = facts.placements.get(pid);
|
|
450
|
+
if (placement?.map === undefined || placements.has(pid))
|
|
451
|
+
out.push(problem("INVALID_REFERENCE", ref(at, "placement"), "placement names no valid placement"));
|
|
452
|
+
else if (placement.nodeId !== k)
|
|
453
|
+
out.push(problem("INVALID_REFERENCE", ref(at, "placement"), "placement belongs to another node"));
|
|
454
|
+
else {
|
|
455
|
+
const parent = placement.parentId;
|
|
456
|
+
// Under a collided parent the node is blocked (§7), not invalid.
|
|
457
|
+
if (parent !== sectionId && !collided.has(parent)) {
|
|
458
|
+
const parentKind = kinds.get(parent);
|
|
459
|
+
if (parentKind === undefined || !PARENT_KINDS.has(parentKind))
|
|
460
|
+
out.push(problem("INVALID_REFERENCE", ref(at, "placement"), "its parent cannot hold content (§4.2)"));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const worst = first(out);
|
|
466
|
+
if (worst !== undefined)
|
|
467
|
+
nodes.set(k, worst);
|
|
468
|
+
}
|
|
469
|
+
const state = root.problems.length > 0 ? "invalid" : root.ready ? "ready" : "importing";
|
|
470
|
+
return Object.freeze({
|
|
471
|
+
state,
|
|
472
|
+
problems: Object.freeze([...root.problems]),
|
|
473
|
+
sectionId,
|
|
474
|
+
nodes,
|
|
475
|
+
placements,
|
|
476
|
+
objects,
|
|
477
|
+
collisions: Object.freeze([...collisions].sort()),
|
|
478
|
+
collided: Object.freeze([...collided].sort()),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/** Null when a patch is above an entity (a container replaced): read everything again. */
|
|
482
|
+
function dirtyOf(patches) {
|
|
483
|
+
const dirty = {
|
|
484
|
+
nodes: new Set(),
|
|
485
|
+
placements: new Set(),
|
|
486
|
+
objects: new Set(),
|
|
487
|
+
};
|
|
488
|
+
for (const p of patches) {
|
|
489
|
+
const [container, key] = p.path;
|
|
490
|
+
if (container === "nodes" || container === "placements" || container === "objects") {
|
|
491
|
+
if (typeof key !== "string")
|
|
492
|
+
return null;
|
|
493
|
+
dirty[container].add(key);
|
|
494
|
+
}
|
|
495
|
+
// Any other path is the root or the section, read again on every validation.
|
|
496
|
+
}
|
|
497
|
+
return dirty;
|
|
498
|
+
}
|
|
499
|
+
/** Validations by document object, and the latest few by heads (a clone has the heads of its source). */
|
|
500
|
+
const byDoc = new WeakMap();
|
|
501
|
+
const byHeads = new Map();
|
|
502
|
+
/** The facts of the latest validations, to validate a later revision of one of them incrementally. */
|
|
503
|
+
const bases = [];
|
|
504
|
+
const KEPT = 8;
|
|
505
|
+
/**
|
|
506
|
+
* Validates a section document (§3, §4, §14.2). Reads only; the document is
|
|
507
|
+
* unchanged. A document's heads determine its content, so a validation is
|
|
508
|
+
* computed once per heads: committing and then reading one revision (or a
|
|
509
|
+
* clone of it) validates it once. A later revision of a document validated
|
|
510
|
+
* before reads again only the nodes, placements and objects its changes
|
|
511
|
+
* touched (A.diff); the checks across entities run in full each time.
|
|
512
|
+
*/
|
|
513
|
+
export function validateSection(doc) {
|
|
514
|
+
const known = byDoc.get(doc);
|
|
515
|
+
if (known !== undefined)
|
|
516
|
+
return known;
|
|
517
|
+
const heads = A.getHeads(doc);
|
|
518
|
+
const key = heads.slice().sort().join(",");
|
|
519
|
+
let v = byHeads.get(key);
|
|
520
|
+
if (v === undefined) {
|
|
521
|
+
const facts = factsOf(doc, heads);
|
|
522
|
+
v = assemble(facts);
|
|
523
|
+
byHeads.set(key, v);
|
|
524
|
+
if (byHeads.size > KEPT)
|
|
525
|
+
byHeads.delete(byHeads.keys().next().value);
|
|
526
|
+
bases.unshift({ heads, facts });
|
|
527
|
+
if (bases.length > KEPT)
|
|
528
|
+
bases.pop();
|
|
529
|
+
}
|
|
530
|
+
byDoc.set(doc, v);
|
|
531
|
+
return v;
|
|
532
|
+
}
|
|
533
|
+
function factsOf(doc, heads) {
|
|
534
|
+
for (const base of bases) {
|
|
535
|
+
if (!A.hasHeads(doc, base.heads))
|
|
536
|
+
continue;
|
|
537
|
+
const dirty = dirtyOf(A.diff(doc, base.heads, heads));
|
|
538
|
+
if (dirty === null)
|
|
539
|
+
break;
|
|
540
|
+
return readFacts(doc, { facts: base.facts, dirty });
|
|
541
|
+
}
|
|
542
|
+
return readFacts(doc);
|
|
543
|
+
}
|
|
544
|
+
/** Validates without the per-revision cache and the incremental path: for tests. */
|
|
545
|
+
export function validateSectionInFull(doc) {
|
|
546
|
+
return assemble(readFacts(doc));
|
|
547
|
+
}
|
|
548
|
+
//# sourceMappingURL=schema.js.map
|