@openlfcp/shared-objects 0.1.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.
- package/LICENSE +201 -0
- package/README.md +91 -0
- package/dist/automerge-bytes.d.ts +37 -0
- package/dist/automerge-bytes.js +95 -0
- package/dist/chunk-limits.d.ts +58 -0
- package/dist/chunk-limits.js +457 -0
- package/dist/data-profile.d.ts +159 -0
- package/dist/data-profile.js +311 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/profile-invalid.d.ts +12 -0
- package/dist/profile-invalid.js +15 -0
- package/dist/replica.d.ts +277 -0
- package/dist/replica.js +1039 -0
- package/dist/task.d.ts +159 -0
- package/dist/task.js +192 -0
- package/dist/validate.d.ts +70 -0
- package/dist/validate.js +196 -0
- package/dist/values.d.ts +52 -0
- package/dist/values.js +163 -0
- package/dist/wasm.d.ts +33 -0
- package/dist/wasm.js +58 -0
- package/package.json +59 -0
package/dist/task.d.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { LfcpError, type ObjectId, type PrincipalId } from "@openlfcp/core";
|
|
2
|
+
import { type Json, type ProfileProblem } from "./validate.js";
|
|
3
|
+
import { type PrincipalRef } from "./values.js";
|
|
4
|
+
/**
|
|
5
|
+
* The version-1 Task (SHARED-OBJECTS-PROFILE-01 §23, §30-§43) over logical
|
|
6
|
+
* state, and the §59 Task intents.
|
|
7
|
+
*
|
|
8
|
+
* A Task is its logical JSON object: the known fields typed, every other
|
|
9
|
+
* field kept as is. Mutators never rebuild the object; they copy it and
|
|
10
|
+
* change only the field the intent names, so unknown fields and extension
|
|
11
|
+
* namespaces survive any mutation (§70-§72). Each mutator returns the new
|
|
12
|
+
* state and the intent that produced it; the Automerge binding (LFCP-031)
|
|
13
|
+
* maps intents to Automerge changes. Conflict state (several concurrent
|
|
14
|
+
* values of a register, §45) only exists in the Automerge document, so
|
|
15
|
+
* task.resolve_field_conflict is LFCP-031 work.
|
|
16
|
+
*/
|
|
17
|
+
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled" | `x/${string}`;
|
|
18
|
+
export type TaskPriority = "lowest" | "low" | "normal" | "high" | "highest" | `x/${string}`;
|
|
19
|
+
export interface Task {
|
|
20
|
+
readonly id: ObjectId;
|
|
21
|
+
readonly type: "task";
|
|
22
|
+
readonly lifecycle: "active" | "deleted";
|
|
23
|
+
readonly created_by: PrincipalRef;
|
|
24
|
+
readonly created_at?: string;
|
|
25
|
+
readonly title: string;
|
|
26
|
+
readonly status: TaskStatus;
|
|
27
|
+
readonly due?: string | null;
|
|
28
|
+
readonly scheduled?: string | null;
|
|
29
|
+
readonly completion_date?: string | null;
|
|
30
|
+
readonly priority: TaskPriority;
|
|
31
|
+
/** Add-wins set: tag -> true (§39). */
|
|
32
|
+
readonly tags: {
|
|
33
|
+
readonly [tag: string]: true;
|
|
34
|
+
};
|
|
35
|
+
/** Add-wins set: Principal reference -> true (§42). */
|
|
36
|
+
readonly assignees: {
|
|
37
|
+
readonly [ref: string]: true;
|
|
38
|
+
};
|
|
39
|
+
readonly extensions: {
|
|
40
|
+
readonly [namespace: string]: Json;
|
|
41
|
+
};
|
|
42
|
+
/** Unknown Task fields are preserved (§72). */
|
|
43
|
+
readonly [unknown: string]: Json | undefined;
|
|
44
|
+
}
|
|
45
|
+
/** A §59 intent: what a mutation means, for the Automerge binding. */
|
|
46
|
+
export type TaskIntent = {
|
|
47
|
+
readonly intent: "task.create";
|
|
48
|
+
readonly task: Task;
|
|
49
|
+
} | {
|
|
50
|
+
readonly intent: "task.set_title";
|
|
51
|
+
readonly id: ObjectId;
|
|
52
|
+
readonly title: string;
|
|
53
|
+
} | {
|
|
54
|
+
readonly intent: "task.set_status";
|
|
55
|
+
readonly id: ObjectId;
|
|
56
|
+
readonly status: TaskStatus;
|
|
57
|
+
} | {
|
|
58
|
+
readonly intent: "task.complete";
|
|
59
|
+
readonly id: ObjectId;
|
|
60
|
+
readonly completionDate?: string;
|
|
61
|
+
} | {
|
|
62
|
+
readonly intent: "task.reopen";
|
|
63
|
+
readonly id: ObjectId;
|
|
64
|
+
} | {
|
|
65
|
+
readonly intent: "task.cancel";
|
|
66
|
+
readonly id: ObjectId;
|
|
67
|
+
} | {
|
|
68
|
+
readonly intent: "task.set_due" | "task.set_scheduled";
|
|
69
|
+
readonly id: ObjectId;
|
|
70
|
+
readonly date: string;
|
|
71
|
+
} | {
|
|
72
|
+
readonly intent: "task.clear_due" | "task.clear_scheduled";
|
|
73
|
+
readonly id: ObjectId;
|
|
74
|
+
} | {
|
|
75
|
+
readonly intent: "task.set_priority";
|
|
76
|
+
readonly id: ObjectId;
|
|
77
|
+
readonly priority: TaskPriority;
|
|
78
|
+
} | {
|
|
79
|
+
readonly intent: "task.add_tag" | "task.remove_tag";
|
|
80
|
+
readonly id: ObjectId;
|
|
81
|
+
readonly tag: string;
|
|
82
|
+
} | {
|
|
83
|
+
readonly intent: "task.add_assignee" | "task.remove_assignee";
|
|
84
|
+
readonly id: ObjectId;
|
|
85
|
+
readonly assignee: PrincipalRef;
|
|
86
|
+
} | {
|
|
87
|
+
readonly intent: "task.delete" | "task.restore";
|
|
88
|
+
readonly id: ObjectId;
|
|
89
|
+
};
|
|
90
|
+
/** A mutation result: the new Task and the intent. */
|
|
91
|
+
export interface TaskChange {
|
|
92
|
+
readonly task: Task;
|
|
93
|
+
readonly intent: TaskIntent;
|
|
94
|
+
}
|
|
95
|
+
/** Thrown for a value a writer must not create: PROFILE_INVALID with the §74.1 problems. */
|
|
96
|
+
export declare class ProfileError extends LfcpError {
|
|
97
|
+
readonly problems: readonly ProfileProblem[];
|
|
98
|
+
constructor(problems: readonly ProfileProblem[]);
|
|
99
|
+
}
|
|
100
|
+
export type ParsedTask = {
|
|
101
|
+
readonly valid: true;
|
|
102
|
+
readonly task: Task;
|
|
103
|
+
} | {
|
|
104
|
+
readonly valid: false;
|
|
105
|
+
readonly problems: readonly ProfileProblem[];
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Validates a Task stored under `key` (default: its own id) and returns it
|
|
109
|
+
* as a Task (a frozen copy with every field, known or not), or its
|
|
110
|
+
* problems with pointers relative to the object ("/title", ...).
|
|
111
|
+
*/
|
|
112
|
+
export declare function parseTask(object: Json | undefined, key?: string): ParsedTask;
|
|
113
|
+
/** The Task as logical JSON (it already is; this returns a deep copy). */
|
|
114
|
+
export declare const taskToJson: (task: Task) => Json;
|
|
115
|
+
export interface NewTask {
|
|
116
|
+
readonly title: string;
|
|
117
|
+
/** The creating Principal (§27). */
|
|
118
|
+
readonly createdBy: PrincipalId;
|
|
119
|
+
readonly status?: TaskStatus;
|
|
120
|
+
readonly priority?: TaskPriority;
|
|
121
|
+
readonly due?: string;
|
|
122
|
+
readonly scheduled?: string;
|
|
123
|
+
readonly tags?: readonly string[];
|
|
124
|
+
readonly assignees?: readonly (PrincipalId | PrincipalRef)[];
|
|
125
|
+
/** RFC 3339 UTC; omitted when no reliable clock is available (§53). */
|
|
126
|
+
readonly createdAt?: string;
|
|
127
|
+
/** A UUIDv7 Object ID; generated client-side when absent (§19, §60). */
|
|
128
|
+
readonly id?: ObjectId;
|
|
129
|
+
}
|
|
130
|
+
/** task.create (§53, §60): a new active Task with every required field, in one change. */
|
|
131
|
+
export declare function createTask(input: NewTask): TaskChange;
|
|
132
|
+
export declare const setTitle: (task: Task, title: string) => TaskChange;
|
|
133
|
+
/** task.set_status (§62): changes only the status. */
|
|
134
|
+
export declare const setStatus: (task: Task, status: TaskStatus) => TaskChange;
|
|
135
|
+
/** task.complete (§63): status done and, when supplied, the completion date. */
|
|
136
|
+
export declare function complete(task: Task, completionDate?: string): TaskChange;
|
|
137
|
+
/** task.reopen (§64): status todo and no completion date. */
|
|
138
|
+
export declare const reopen: (task: Task) => TaskChange;
|
|
139
|
+
/** task.cancel (§65): status cancelled; the completion date is cleared. */
|
|
140
|
+
export declare const cancel: (task: Task) => TaskChange;
|
|
141
|
+
export declare const setDue: (task: Task, value: string) => TaskChange;
|
|
142
|
+
export declare const setScheduled: (task: Task, value: string) => TaskChange;
|
|
143
|
+
/** task.clear_due (§66): deletes the property. */
|
|
144
|
+
export declare const clearDue: (task: Task) => TaskChange;
|
|
145
|
+
export declare const clearScheduled: (task: Task) => TaskChange;
|
|
146
|
+
export declare const setPriority: (task: Task, priority: TaskPriority) => TaskChange;
|
|
147
|
+
/** task.add_tag (§67): tags[tag] = true, after NFC normalization. */
|
|
148
|
+
export declare function addTag(task: Task, tag: string): TaskChange;
|
|
149
|
+
/** task.remove_tag (§67): deletes the key (never stores false, §39). */
|
|
150
|
+
export declare function removeTag(task: Task, tag: string): TaskChange;
|
|
151
|
+
/** task.add_assignee (§68). */
|
|
152
|
+
export declare function assign(task: Task, who: PrincipalId | PrincipalRef): TaskChange;
|
|
153
|
+
/** task.remove_assignee (§68): deletes the key. */
|
|
154
|
+
export declare function unassign(task: Task, who: PrincipalId | PrincipalRef): TaskChange;
|
|
155
|
+
/** task.delete (§54): lifecycle = deleted, a tombstone; the object stays. */
|
|
156
|
+
export declare const deleteTask: (task: Task) => TaskChange;
|
|
157
|
+
/** task.restore (§55): lifecycle = active. */
|
|
158
|
+
export declare const restoreTask: (task: Task) => TaskChange;
|
|
159
|
+
//# sourceMappingURL=task.d.ts.map
|
package/dist/task.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { generateObjectId, isObjectId, LfcpError, } from "@openlfcp/core";
|
|
2
|
+
import { isMap, objectProblems } from "./validate.js";
|
|
3
|
+
import { isLocalDate, isPrincipalRef, principalRef } from "./values.js";
|
|
4
|
+
/** Thrown for a value a writer must not create: PROFILE_INVALID with the §74.1 problems. */
|
|
5
|
+
export class ProfileError extends LfcpError {
|
|
6
|
+
problems;
|
|
7
|
+
constructor(problems) {
|
|
8
|
+
super("PROFILE_INVALID", problems.map((p) => `${p.diagnostic} at ${p.pointer}: ${p.message}`).join("; "));
|
|
9
|
+
this.name = "ProfileError";
|
|
10
|
+
this.problems = problems;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function deepFreeze(value) {
|
|
14
|
+
if (value !== null && typeof value === "object") {
|
|
15
|
+
for (const v of Object.values(value))
|
|
16
|
+
deepFreeze(v);
|
|
17
|
+
Object.freeze(value);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
const copy = (value) => JSON.parse(JSON.stringify(value));
|
|
22
|
+
/**
|
|
23
|
+
* Validates a Task stored under `key` (default: its own id) and returns it
|
|
24
|
+
* as a Task (a frozen copy with every field, known or not), or its
|
|
25
|
+
* problems with pointers relative to the object ("/title", ...).
|
|
26
|
+
*/
|
|
27
|
+
export function parseTask(object, key) {
|
|
28
|
+
if (!isMap(object) || object.type !== "task") {
|
|
29
|
+
const problems = isMap(object)
|
|
30
|
+
? [
|
|
31
|
+
{
|
|
32
|
+
code: "PROFILE_INVALID",
|
|
33
|
+
diagnostic: "INVALID_FIELD_TYPE",
|
|
34
|
+
pointer: "/type",
|
|
35
|
+
message: "not a Task (type must be task)",
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
: objectProblems(object, key ?? "", "");
|
|
39
|
+
return { valid: false, problems };
|
|
40
|
+
}
|
|
41
|
+
const problems = objectProblems(object, key ?? String(object.id), "");
|
|
42
|
+
if (problems.length > 0)
|
|
43
|
+
return { valid: false, problems };
|
|
44
|
+
return { valid: true, task: deepFreeze(copy(object)) };
|
|
45
|
+
}
|
|
46
|
+
/** The Task as logical JSON (it already is; this returns a deep copy). */
|
|
47
|
+
export const taskToJson = (task) => copy(task);
|
|
48
|
+
function checked(task) {
|
|
49
|
+
const problems = objectProblems(task, String(task.id), "");
|
|
50
|
+
if (problems.length > 0)
|
|
51
|
+
throw new ProfileError(problems);
|
|
52
|
+
return deepFreeze(task);
|
|
53
|
+
}
|
|
54
|
+
/** A copy of `task` with `changes` applied; `undefined` deletes a property (§36: writers delete cleared dates). */
|
|
55
|
+
function edit(task, changes) {
|
|
56
|
+
const next = copy(task);
|
|
57
|
+
for (const [field, value] of Object.entries(changes)) {
|
|
58
|
+
if (value === undefined)
|
|
59
|
+
delete next[field];
|
|
60
|
+
else
|
|
61
|
+
next[field] = value;
|
|
62
|
+
}
|
|
63
|
+
return checked(next);
|
|
64
|
+
}
|
|
65
|
+
const tagKey = (tag) => tag.normalize("NFC"); // §40, §67: SHOULD normalize to NFC
|
|
66
|
+
const refOf = (who) => typeof who === "string" ? who : principalRef(who);
|
|
67
|
+
/** task.create (§53, §60): a new active Task with every required field, in one change. */
|
|
68
|
+
export function createTask(input) {
|
|
69
|
+
const id = input.id ?? generateObjectId();
|
|
70
|
+
if (!isObjectId(id))
|
|
71
|
+
throw new LfcpError("INVALID_UUIDV7", "a Task id must be a canonical UUIDv7");
|
|
72
|
+
const object = {
|
|
73
|
+
id,
|
|
74
|
+
type: "task",
|
|
75
|
+
lifecycle: "active",
|
|
76
|
+
created_by: principalRef(input.createdBy),
|
|
77
|
+
...(input.createdAt !== undefined ? { created_at: input.createdAt } : {}),
|
|
78
|
+
title: input.title,
|
|
79
|
+
status: input.status ?? "todo",
|
|
80
|
+
...(input.due !== undefined ? { due: input.due } : {}),
|
|
81
|
+
...(input.scheduled !== undefined ? { scheduled: input.scheduled } : {}),
|
|
82
|
+
priority: input.priority ?? "normal",
|
|
83
|
+
tags: Object.fromEntries((input.tags ?? []).map((t) => [tagKey(t), true])),
|
|
84
|
+
assignees: Object.fromEntries((input.assignees ?? []).map((a) => [refOf(a), true])),
|
|
85
|
+
extensions: {},
|
|
86
|
+
};
|
|
87
|
+
const task = checked(object);
|
|
88
|
+
return { task, intent: { intent: "task.create", task } };
|
|
89
|
+
}
|
|
90
|
+
const change = (task, intent) => Object.freeze({ task, intent });
|
|
91
|
+
export const setTitle = (task, title) => change(edit(task, { title }), { intent: "task.set_title", id: task.id, title });
|
|
92
|
+
/** task.set_status (§62): changes only the status. */
|
|
93
|
+
export const setStatus = (task, status) => change(edit(task, { status }), { intent: "task.set_status", id: task.id, status });
|
|
94
|
+
/** task.complete (§63): status done and, when supplied, the completion date. */
|
|
95
|
+
export function complete(task, completionDate) {
|
|
96
|
+
const next = edit(task, completionDate === undefined
|
|
97
|
+
? { status: "done" }
|
|
98
|
+
: { status: "done", completion_date: completionDate });
|
|
99
|
+
return change(next, {
|
|
100
|
+
intent: "task.complete",
|
|
101
|
+
id: task.id,
|
|
102
|
+
...(completionDate !== undefined ? { completionDate } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** task.reopen (§64): status todo and no completion date. */
|
|
106
|
+
export const reopen = (task) => change(edit(task, { status: "todo", completion_date: undefined }), {
|
|
107
|
+
intent: "task.reopen",
|
|
108
|
+
id: task.id,
|
|
109
|
+
});
|
|
110
|
+
/** task.cancel (§65): status cancelled; the completion date is cleared. */
|
|
111
|
+
export const cancel = (task) => change(edit(task, { status: "cancelled", completion_date: undefined }), {
|
|
112
|
+
intent: "task.cancel",
|
|
113
|
+
id: task.id,
|
|
114
|
+
});
|
|
115
|
+
function date(field, task, value) {
|
|
116
|
+
// §66: "MUST validate the Local Date grammar before creating a change".
|
|
117
|
+
if (!isLocalDate(value))
|
|
118
|
+
throw new ProfileError([
|
|
119
|
+
{
|
|
120
|
+
code: "PROFILE_INVALID",
|
|
121
|
+
diagnostic: "INVALID_LOCAL_DATE",
|
|
122
|
+
pointer: `/${field}`,
|
|
123
|
+
message: `${value} is not a Gregorian YYYY-MM-DD date (§35)`,
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
return change(edit(task, { [field]: value }), {
|
|
127
|
+
intent: `task.set_${field}`,
|
|
128
|
+
id: task.id,
|
|
129
|
+
date: value,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
export const setDue = (task, value) => date("due", task, value);
|
|
133
|
+
export const setScheduled = (task, value) => date("scheduled", task, value);
|
|
134
|
+
/** task.clear_due (§66): deletes the property. */
|
|
135
|
+
export const clearDue = (task) => change(edit(task, { due: undefined }), { intent: "task.clear_due", id: task.id });
|
|
136
|
+
export const clearScheduled = (task) => change(edit(task, { scheduled: undefined }), { intent: "task.clear_scheduled", id: task.id });
|
|
137
|
+
export const setPriority = (task, priority) => change(edit(task, { priority }), { intent: "task.set_priority", id: task.id, priority });
|
|
138
|
+
/** task.add_tag (§67): tags[tag] = true, after NFC normalization. */
|
|
139
|
+
export function addTag(task, tag) {
|
|
140
|
+
const key = tagKey(tag);
|
|
141
|
+
return change(edit(task, { tags: { ...task.tags, [key]: true } }), {
|
|
142
|
+
intent: "task.add_tag",
|
|
143
|
+
id: task.id,
|
|
144
|
+
tag: key,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** task.remove_tag (§67): deletes the key (never stores false, §39). */
|
|
148
|
+
export function removeTag(task, tag) {
|
|
149
|
+
const key = tagKey(tag);
|
|
150
|
+
const tags = { ...task.tags };
|
|
151
|
+
delete tags[key];
|
|
152
|
+
return change(edit(task, { tags }), { intent: "task.remove_tag", id: task.id, tag: key });
|
|
153
|
+
}
|
|
154
|
+
function assigneeRef(who) {
|
|
155
|
+
const ref = refOf(who);
|
|
156
|
+
// §68: "MUST validate the Principal reference encoding before mutation".
|
|
157
|
+
if (!isPrincipalRef(ref))
|
|
158
|
+
throw new ProfileError([
|
|
159
|
+
{
|
|
160
|
+
code: "PROFILE_INVALID",
|
|
161
|
+
diagnostic: "INVALID_PRINCIPAL_REF",
|
|
162
|
+
pointer: "/assignees",
|
|
163
|
+
message: "not a Principal reference (§27)",
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
return ref;
|
|
167
|
+
}
|
|
168
|
+
/** task.add_assignee (§68). */
|
|
169
|
+
export function assign(task, who) {
|
|
170
|
+
const ref = assigneeRef(who);
|
|
171
|
+
return change(edit(task, { assignees: { ...task.assignees, [ref]: true } }), {
|
|
172
|
+
intent: "task.add_assignee",
|
|
173
|
+
id: task.id,
|
|
174
|
+
assignee: ref,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** task.remove_assignee (§68): deletes the key. */
|
|
178
|
+
export function unassign(task, who) {
|
|
179
|
+
const ref = assigneeRef(who);
|
|
180
|
+
const assignees = { ...task.assignees };
|
|
181
|
+
delete assignees[ref];
|
|
182
|
+
return change(edit(task, { assignees }), {
|
|
183
|
+
intent: "task.remove_assignee",
|
|
184
|
+
id: task.id,
|
|
185
|
+
assignee: ref,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/** task.delete (§54): lifecycle = deleted, a tombstone; the object stays. */
|
|
189
|
+
export const deleteTask = (task) => change(edit(task, { lifecycle: "deleted" }), { intent: "task.delete", id: task.id });
|
|
190
|
+
/** task.restore (§55): lifecycle = active. */
|
|
191
|
+
export const restoreTask = (task) => change(edit(task, { lifecycle: "active" }), { intent: "task.restore", id: task.id });
|
|
192
|
+
//# sourceMappingURL=task.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile validation of Shared Objects logical state (SHARED-OBJECTS-
|
|
3
|
+
* PROFILE-01 §73-§77): the JSON an Automerge document of this profile
|
|
4
|
+
* materializes to. Every failure is PROFILE_INVALID with exactly one §74.1
|
|
5
|
+
* diagnostic, at a JSON Pointer (RFC 6901); a field value that breaks
|
|
6
|
+
* several rules gets the first in §74.1 table order. One invalid object never makes
|
|
7
|
+
* the others unusable (§77); unknown fields, extension namespaces, object
|
|
8
|
+
* types and x/…/… values are accepted and preserved (§70-§72).
|
|
9
|
+
*/
|
|
10
|
+
/** §74.1 diagnostics. */
|
|
11
|
+
export type ProfileDiagnostic = "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" | "IMMUTABLE_FIELD_MUTATED"
|
|
12
|
+
/** §8, §11: a Data Unit's Automerge change is not of its signer's actor; it is not merged. */
|
|
13
|
+
| "CHANGE_ACTOR_MISMATCH"
|
|
14
|
+
/** §11, §13: a plaintext's framing or Automerge bytes are invalid (chunk type, checksum, parse, load); nothing is merged. */
|
|
15
|
+
| "INVALID_AUTOMERGE_BYTES";
|
|
16
|
+
/** One profile validation failure: PROFILE_INVALID with its §74.1 diagnostic. */
|
|
17
|
+
export interface ProfileProblem {
|
|
18
|
+
readonly code: "PROFILE_INVALID";
|
|
19
|
+
readonly diagnostic: ProfileDiagnostic;
|
|
20
|
+
/** JSON Pointer of the offending value (of its container, for a missing field). */
|
|
21
|
+
readonly pointer: string;
|
|
22
|
+
readonly message: string;
|
|
23
|
+
}
|
|
24
|
+
/** The §74.1 registry in table order: structure and value rules first, IMMUTABLE_FIELD_MUTATED last. */
|
|
25
|
+
export declare const DIAGNOSTIC_ORDER: readonly ProfileDiagnostic[];
|
|
26
|
+
/**
|
|
27
|
+
* §74.1 precedence for the fields of the object at `object` (its JSON
|
|
28
|
+
* Pointer): "When one value breaks several rules, its diagnostic is the
|
|
29
|
+
* first that applies in the order of this table", and a field with
|
|
30
|
+
* concurrent values (§45) gets the diagnostic of its first invalid value in
|
|
31
|
+
* that order. Of several problems at one field pointer, those with the
|
|
32
|
+
* first diagnostic are kept, at every pointer below the object (fields,
|
|
33
|
+
* set members, values inside extensions). Problems at the object itself
|
|
34
|
+
* (its key, a missing field) are kept as they are.
|
|
35
|
+
*/
|
|
36
|
+
export declare function firstPerField(problems: readonly ProfileProblem[], object: string): ProfileProblem[];
|
|
37
|
+
export { ProfileInvalidError } from "./profile-invalid.js";
|
|
38
|
+
export type Json = null | boolean | number | string | readonly Json[] | {
|
|
39
|
+
readonly [key: string]: Json;
|
|
40
|
+
};
|
|
41
|
+
type JsonMap = {
|
|
42
|
+
readonly [key: string]: Json;
|
|
43
|
+
};
|
|
44
|
+
export declare const isMap: (v: unknown) => v is JsonMap;
|
|
45
|
+
/** RFC 6901 escaping of one reference token. */
|
|
46
|
+
export declare const pointerToken: (key: string) => string;
|
|
47
|
+
/**
|
|
48
|
+
* Problems of one Shared Object stored under `key` in `objects`, with
|
|
49
|
+
* pointers below `at` (default "/objects/<key>"). Non-Task types are
|
|
50
|
+
* checked for the base fields only (§23, §71).
|
|
51
|
+
*/
|
|
52
|
+
export declare function objectProblems(object: Json | undefined, key: string, at?: string): ProfileProblem[];
|
|
53
|
+
/** The result of validating a root: root-level problems and problems per object (§77 isolation). */
|
|
54
|
+
export interface RootValidation {
|
|
55
|
+
readonly valid: boolean;
|
|
56
|
+
readonly problems: readonly ProfileProblem[];
|
|
57
|
+
/** Problems per objects key; an object with none is usable. */
|
|
58
|
+
readonly objects: ReadonlyMap<string, readonly ProfileProblem[]>;
|
|
59
|
+
}
|
|
60
|
+
/** Validates a whole root (§15, §74). Unknown top-level keys are preserved, not rejected (§17). */
|
|
61
|
+
export declare function validateRoot(root: Json | undefined): RootValidation;
|
|
62
|
+
/**
|
|
63
|
+
* §75: id, type and created_by never change. Problems for every object
|
|
64
|
+
* present in both states whose immutable field differs, unless the new
|
|
65
|
+
* value breaks a structure or value rule: §74.1 puts
|
|
66
|
+
* IMMUTABLE_FIELD_MUTATED last, so that value's diagnostic is the one
|
|
67
|
+
* objectProblems reports.
|
|
68
|
+
*/
|
|
69
|
+
export declare function validateTransition(before: Json | undefined, after: Json | undefined): readonly ProfileProblem[];
|
|
70
|
+
//# sourceMappingURL=validate.d.ts.map
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { isObjectId } from "@openlfcp/core";
|
|
2
|
+
import { isLocalDate, isNamespacedValue, isPrincipalRef, isReverseDomain, isUtcTimestamp, PROFILE_ID, } from "./values.js";
|
|
3
|
+
/** The §74.1 registry in table order: structure and value rules first, IMMUTABLE_FIELD_MUTATED last. */
|
|
4
|
+
export const DIAGNOSTIC_ORDER = Object.freeze([
|
|
5
|
+
"INVALID_ROOT",
|
|
6
|
+
"INVALID_OBJECT_ID",
|
|
7
|
+
"OBJECT_ID_MISMATCH",
|
|
8
|
+
"MISSING_REQUIRED_FIELD",
|
|
9
|
+
"INVALID_FIELD_TYPE",
|
|
10
|
+
"INVALID_ENUM_VALUE",
|
|
11
|
+
"INVALID_EXTENSION_NAMESPACE",
|
|
12
|
+
"INVALID_PRINCIPAL_REF",
|
|
13
|
+
"INVALID_TIMESTAMP",
|
|
14
|
+
"INVALID_LOCAL_DATE",
|
|
15
|
+
"INVALID_COLLECTION_REPRESENTATION",
|
|
16
|
+
"INVALID_TAG",
|
|
17
|
+
"IMMUTABLE_FIELD_MUTATED",
|
|
18
|
+
"CHANGE_ACTOR_MISMATCH",
|
|
19
|
+
"INVALID_AUTOMERGE_BYTES",
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* §74.1 precedence for the fields of the object at `object` (its JSON
|
|
23
|
+
* Pointer): "When one value breaks several rules, its diagnostic is the
|
|
24
|
+
* first that applies in the order of this table", and a field with
|
|
25
|
+
* concurrent values (§45) gets the diagnostic of its first invalid value in
|
|
26
|
+
* that order. Of several problems at one field pointer, those with the
|
|
27
|
+
* first diagnostic are kept, at every pointer below the object (fields,
|
|
28
|
+
* set members, values inside extensions). Problems at the object itself
|
|
29
|
+
* (its key, a missing field) are kept as they are.
|
|
30
|
+
*/
|
|
31
|
+
export function firstPerField(problems, object) {
|
|
32
|
+
const rank = (p) => DIAGNOSTIC_ORDER.indexOf(p.diagnostic);
|
|
33
|
+
// Every value below the object (a field, a set member, a value inside extensions).
|
|
34
|
+
const isField = (pointer) => pointer.startsWith(`${object}/`);
|
|
35
|
+
const best = new Map();
|
|
36
|
+
for (const p of problems)
|
|
37
|
+
if (isField(p.pointer))
|
|
38
|
+
best.set(p.pointer, Math.min(best.get(p.pointer) ?? Number.POSITIVE_INFINITY, rank(p)));
|
|
39
|
+
return problems.filter((p) => !isField(p.pointer) || rank(p) === best.get(p.pointer));
|
|
40
|
+
}
|
|
41
|
+
export { ProfileInvalidError } from "./profile-invalid.js";
|
|
42
|
+
export const isMap = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
43
|
+
/** RFC 6901 escaping of one reference token. */
|
|
44
|
+
export const pointerToken = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
45
|
+
const problem = (diagnostic, pointer, message) => Object.freeze({ code: "PROFILE_INVALID", diagnostic, pointer, message });
|
|
46
|
+
const STATUSES = new Set(["todo", "in_progress", "done", "cancelled"]);
|
|
47
|
+
const PRIORITIES = new Set(["lowest", "low", "normal", "high", "highest"]);
|
|
48
|
+
const LIFECYCLES = new Set(["active", "deleted"]);
|
|
49
|
+
const BASE_REQUIRED = ["id", "type", "lifecycle", "created_by", "extensions"];
|
|
50
|
+
const TASK_REQUIRED = ["title", "status", "priority", "tags", "assignees"];
|
|
51
|
+
const DATE_FIELDS = ["due", "scheduled", "completion_date"];
|
|
52
|
+
function extensionProblems(value, at, onType) {
|
|
53
|
+
if (!isMap(value))
|
|
54
|
+
return [problem(onType, at, "extensions must be a map (§18, §29)")];
|
|
55
|
+
return Object.keys(value)
|
|
56
|
+
.filter((ns) => !isReverseDomain(ns))
|
|
57
|
+
.map((ns) => problem("INVALID_EXTENSION_NAMESPACE", `${at}/${pointerToken(ns)}`, `"${ns}" is not a reverse-domain namespace (§18)`));
|
|
58
|
+
}
|
|
59
|
+
function setProblems(value, at, what, keyCheck) {
|
|
60
|
+
if (!isMap(value))
|
|
61
|
+
return [
|
|
62
|
+
problem("INVALID_COLLECTION_REPRESENTATION", at, `${what} must be a map of key -> true (§39, §42)`),
|
|
63
|
+
];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const [key, member] of Object.entries(value)) {
|
|
66
|
+
const keyProblem = keyCheck(key);
|
|
67
|
+
if (keyProblem !== null)
|
|
68
|
+
out.push(keyProblem);
|
|
69
|
+
if (member !== true)
|
|
70
|
+
out.push(problem("INVALID_COLLECTION_REPRESENTATION", `${at}/${pointerToken(key)}`, `a ${what} member's value must be true (§39, §42)`));
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Problems of one Shared Object stored under `key` in `objects`, with
|
|
76
|
+
* pointers below `at` (default "/objects/<key>"). Non-Task types are
|
|
77
|
+
* checked for the base fields only (§23, §71).
|
|
78
|
+
*/
|
|
79
|
+
export function objectProblems(object, key, at = `/objects/${pointerToken(key)}`) {
|
|
80
|
+
return firstPerField(allObjectProblems(object, key, at), at);
|
|
81
|
+
}
|
|
82
|
+
function allObjectProblems(object, key, at) {
|
|
83
|
+
const out = [];
|
|
84
|
+
if (!isObjectId(key))
|
|
85
|
+
out.push(problem("INVALID_OBJECT_ID", at, `the objects key "${key}" is not a canonical UUIDv7 (§19)`));
|
|
86
|
+
if (!isMap(object)) {
|
|
87
|
+
out.push(problem("INVALID_FIELD_TYPE", at, "a Shared Object must be a map (§23)"));
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
const o = object;
|
|
91
|
+
const required = o.type === "task" ? [...BASE_REQUIRED, ...TASK_REQUIRED] : BASE_REQUIRED;
|
|
92
|
+
for (const field of required) {
|
|
93
|
+
if (!(field in o))
|
|
94
|
+
out.push(problem("MISSING_REQUIRED_FIELD", at, `the required field ${field} is missing (§23, §31)`));
|
|
95
|
+
}
|
|
96
|
+
if ("id" in o) {
|
|
97
|
+
if (typeof o.id !== "string" || !isObjectId(o.id))
|
|
98
|
+
out.push(problem("INVALID_OBJECT_ID", `${at}/id`, "id is not a canonical UUIDv7 (§19)"));
|
|
99
|
+
if (o.id !== key)
|
|
100
|
+
out.push(problem("OBJECT_ID_MISMATCH", `${at}/id`, "id differs from its objects key (§20, §24)"));
|
|
101
|
+
}
|
|
102
|
+
if ("type" in o && (typeof o.type !== "string" || o.type.length === 0))
|
|
103
|
+
out.push(problem("INVALID_FIELD_TYPE", `${at}/type`, "type must be non-empty text (§25)"));
|
|
104
|
+
if ("lifecycle" in o && !(typeof o.lifecycle === "string" && LIFECYCLES.has(o.lifecycle)))
|
|
105
|
+
out.push(problem("INVALID_ENUM_VALUE", `${at}/lifecycle`, "lifecycle must be active or deleted (§26)"));
|
|
106
|
+
if ("created_by" in o && !isPrincipalRef(o.created_by))
|
|
107
|
+
out.push(problem("INVALID_PRINCIPAL_REF", `${at}/created_by`, "created_by is not a Principal reference (§27)"));
|
|
108
|
+
// created_at is a base field (§23, §28), so it is checked on every object type.
|
|
109
|
+
if ("created_at" in o && !isUtcTimestamp(o.created_at))
|
|
110
|
+
out.push(problem("INVALID_TIMESTAMP", `${at}/created_at`, "created_at is not an RFC 3339 UTC timestamp (§28)"));
|
|
111
|
+
if ("extensions" in o)
|
|
112
|
+
out.push(...extensionProblems(o.extensions, `${at}/extensions`, "INVALID_FIELD_TYPE"));
|
|
113
|
+
if (o.type !== "task")
|
|
114
|
+
return out;
|
|
115
|
+
if ("title" in o && typeof o.title !== "string")
|
|
116
|
+
out.push(problem("INVALID_FIELD_TYPE", `${at}/title`, "title must be text (§32)"));
|
|
117
|
+
if ("status" in o &&
|
|
118
|
+
!(typeof o.status === "string" && (STATUSES.has(o.status) || isNamespacedValue(o.status))))
|
|
119
|
+
out.push(problem("INVALID_ENUM_VALUE", `${at}/status`, "status is neither standard nor x/<reverse-domain>/<value> (§33)"));
|
|
120
|
+
if ("priority" in o &&
|
|
121
|
+
!(typeof o.priority === "string" &&
|
|
122
|
+
(PRIORITIES.has(o.priority) || isNamespacedValue(o.priority))))
|
|
123
|
+
out.push(problem("INVALID_ENUM_VALUE", `${at}/priority`, "priority is neither standard nor x/<reverse-domain>/<value> (§38)"));
|
|
124
|
+
for (const field of DATE_FIELDS) {
|
|
125
|
+
// §36: missing and null both mean no date.
|
|
126
|
+
if (field in o && o[field] !== null && !isLocalDate(o[field]))
|
|
127
|
+
out.push(problem("INVALID_LOCAL_DATE", `${at}/${field}`, `${field} is not a Gregorian YYYY-MM-DD date (§35)`));
|
|
128
|
+
}
|
|
129
|
+
if ("tags" in o)
|
|
130
|
+
out.push(...setProblems(o.tags, `${at}/tags`, "tag", (tag) => tag.length === 0 || tag.startsWith("#")
|
|
131
|
+
? problem("INVALID_TAG", `${at}/tags/${pointerToken(tag)}`, "a tag must be non-empty without a leading # (§40)")
|
|
132
|
+
: null));
|
|
133
|
+
if ("assignees" in o)
|
|
134
|
+
out.push(...setProblems(o.assignees, `${at}/assignees`, "assignee", (ref) => isPrincipalRef(ref)
|
|
135
|
+
? null
|
|
136
|
+
: problem("INVALID_PRINCIPAL_REF", `${at}/assignees/${pointerToken(ref)}`, "an assignee key is not a Principal reference (§42)")));
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** Validates a whole root (§15, §74). Unknown top-level keys are preserved, not rejected (§17). */
|
|
140
|
+
export function validateRoot(root) {
|
|
141
|
+
const rootProblems = [];
|
|
142
|
+
const perObject = new Map();
|
|
143
|
+
if (!isMap(root)) {
|
|
144
|
+
rootProblems.push(problem("INVALID_ROOT", "/", "the root must be a map (§15)"));
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
if (!("profile" in root) || root.profile !== PROFILE_ID)
|
|
148
|
+
rootProblems.push(problem("INVALID_ROOT", "profile" in root ? "/profile" : "/", `profile must be ${PROFILE_ID} (§15)`));
|
|
149
|
+
if (!("objects" in root))
|
|
150
|
+
rootProblems.push(problem("INVALID_ROOT", "/", "objects is missing (§15)"));
|
|
151
|
+
else if (!isMap(root.objects))
|
|
152
|
+
rootProblems.push(problem("INVALID_ROOT", "/objects", "objects must be a map (§15)"));
|
|
153
|
+
else
|
|
154
|
+
for (const [key, object] of Object.entries(root.objects))
|
|
155
|
+
perObject.set(key, objectProblems(object, key));
|
|
156
|
+
if (!("extensions" in root))
|
|
157
|
+
rootProblems.push(problem("INVALID_ROOT", "/", "extensions is missing (§15)"));
|
|
158
|
+
else
|
|
159
|
+
rootProblems.push(...extensionProblems(root.extensions, "/extensions", "INVALID_ROOT"));
|
|
160
|
+
}
|
|
161
|
+
const all = [...rootProblems, ...[...perObject.values()].flat()];
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
valid: all.length === 0,
|
|
164
|
+
problems: Object.freeze(all),
|
|
165
|
+
objects: perObject,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const IMMUTABLE = ["id", "type", "created_by"];
|
|
169
|
+
/**
|
|
170
|
+
* §75: id, type and created_by never change. Problems for every object
|
|
171
|
+
* present in both states whose immutable field differs, unless the new
|
|
172
|
+
* value breaks a structure or value rule: §74.1 puts
|
|
173
|
+
* IMMUTABLE_FIELD_MUTATED last, so that value's diagnostic is the one
|
|
174
|
+
* objectProblems reports.
|
|
175
|
+
*/
|
|
176
|
+
export function validateTransition(before, after) {
|
|
177
|
+
if (!isMap(before) || !isMap(after) || !isMap(before.objects) || !isMap(after.objects))
|
|
178
|
+
return [];
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const [key, old] of Object.entries(before.objects)) {
|
|
181
|
+
const now = after.objects[key];
|
|
182
|
+
if (!isMap(old) || !isMap(now))
|
|
183
|
+
continue;
|
|
184
|
+
const at = `/objects/${pointerToken(key)}`;
|
|
185
|
+
const broken = new Set(objectProblems(now, key, at).map((p) => p.pointer));
|
|
186
|
+
for (const field of IMMUTABLE) {
|
|
187
|
+
// A removed field is MISSING_REQUIRED_FIELD, an earlier diagnostic.
|
|
188
|
+
if (!(field in now) || broken.has(`${at}/${field}`))
|
|
189
|
+
continue;
|
|
190
|
+
if (field in old && JSON.stringify(old[field]) !== JSON.stringify(now[field]))
|
|
191
|
+
out.push(problem("IMMUTABLE_FIELD_MUTATED", `${at}/${field}`, `${field} changed (§24, §25, §27, §75)`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=validate.js.map
|
package/dist/values.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
/**
|
|
3
|
+
* Value-level rules of SHARED-OBJECTS-PROFILE-01: Principal references,
|
|
4
|
+
* Local Dates, timestamps, reverse-domain names, namespaced values, the
|
|
5
|
+
* Automerge actor ID and the profile framing of Data Unit and Snapshot
|
|
6
|
+
* plaintexts.
|
|
7
|
+
*/
|
|
8
|
+
/** §6: the profile identifier. */
|
|
9
|
+
export declare const PROFILE_ID = "org.openlfcp.shared-objects.v1";
|
|
10
|
+
/** A Principal reference (§27, §42): "p:" + unpadded base64url of the 32-byte Principal ID. */
|
|
11
|
+
export type PrincipalRef = string & {
|
|
12
|
+
readonly __principalRef: true;
|
|
13
|
+
};
|
|
14
|
+
/** §27: the reference of a Principal ID. */
|
|
15
|
+
export declare function principalRef(id: PrincipalId): PrincipalRef;
|
|
16
|
+
/** True when `text` is a canonical Principal reference of exactly 32 bytes. */
|
|
17
|
+
export declare function isPrincipalRef(text: unknown): text is PrincipalRef;
|
|
18
|
+
/** The Principal ID a reference names; throws INVALID_BASE64URL / INVALID_LENGTH otherwise. */
|
|
19
|
+
export declare function parsePrincipalRef(text: string): PrincipalId;
|
|
20
|
+
/** §35: a real Gregorian date YYYY-MM-DD (no time zone). */
|
|
21
|
+
export declare function isLocalDate(text: unknown): text is string;
|
|
22
|
+
/** §28: an RFC 3339 UTC timestamp ending in Z with a real date and time (second 60 for leap seconds). */
|
|
23
|
+
export declare function isUtcTimestamp(text: unknown): text is string;
|
|
24
|
+
/** §18 ABNF reverse-domain: at least two lowercase LDH labels without leading or trailing hyphen. */
|
|
25
|
+
export declare const isReverseDomain: (text: unknown) => text is string;
|
|
26
|
+
/** §33 ABNF namespaced-value: x/<reverse-domain>/<value>, value non-empty without "/". */
|
|
27
|
+
export declare const isNamespacedValue: (text: unknown) => text is string;
|
|
28
|
+
/**
|
|
29
|
+
* §8: the Automerge actor ID of `principal` editing `resource`:
|
|
30
|
+
* SHA-256(ASCII("OPENLFCP-SHARED-OBJECTS-ACTOR-v1") || resource_id || principal_id),
|
|
31
|
+
* from the raw 32-byte IDs.
|
|
32
|
+
*/
|
|
33
|
+
export declare function deriveActorId(resource: ResourceId, principal: PrincipalId): Uint8Array;
|
|
34
|
+
/** §11, §13: the only framing version. */
|
|
35
|
+
export declare const FRAMING_VERSION = 1;
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic CBOR of [1, bstr] (§11 shared-objects-change, §13
|
|
38
|
+
* shared-objects-snapshot): the profile plaintext of a Data Unit (one
|
|
39
|
+
* Automerge change) or a Snapshot (one Automerge full save). The bytes are
|
|
40
|
+
* framed as given; checking that they are a valid Automerge change or save
|
|
41
|
+
* is the Automerge binding (LFCP-031).
|
|
42
|
+
*/
|
|
43
|
+
export declare function frameProfilePayload(bytes: Uint8Array): Uint8Array;
|
|
44
|
+
/**
|
|
45
|
+
* The inner bytes of a framed profile plaintext. §11: a receiver MUST
|
|
46
|
+
* reject plaintext that is not valid CBOR, not a two-element array, or
|
|
47
|
+
* uses an unsupported framing version; this also requires the
|
|
48
|
+
* deterministic encoding and no trailing bytes. Throws PROFILE_INVALID /
|
|
49
|
+
* INVALID_AUTOMERGE_BYTES (§74.1).
|
|
50
|
+
*/
|
|
51
|
+
export declare function unframeProfilePayload(framed: Uint8Array): Uint8Array;
|
|
52
|
+
//# sourceMappingURL=values.d.ts.map
|