diff --git a/packages/pages/README.md b/packages/pages/README.md index 6f31705..2be501d 100644 --- a/packages/pages/README.md +++ b/packages/pages/README.md @@ -16,7 +16,7 @@ To write, hand `addBlock`, `editBlock`, `deleteBlock`, `indentBlock` or `outdent Each of those writes one event, and each event has a pure builder: `blockCreated`, `blockEdited`, `blockDeleted`, `blockIndented` and `blockOutdented`. A builder returns `{ v, type, payload }` with `v` from `HANDLES`, so a writer that holds no fold can still write a valid event, but it skips the checks. -The queries (`getPage`, `getPages`, `getReferences`, `getPreviews`, `searchPages` and `locateBlock`) take the folded pages alone, since they never write. +The queries (`getPage`, `getPages`, `getReferences`, `getPreviews`, `searchPages` and `locateBlock`) take the folded pages alone, since they never write. So do the property reads under [Page properties](#page-properties). ## What it does not depend on @@ -30,3 +30,36 @@ Every event type is `NAMESPACE` followed by a name, for example `github.com/shov - **A name and a `v` never get a new meaning.** Once a log holds an event, it stays on disk forever, and every reader of that folder will see it. A payload that changes shape gets a new `v`, and a new kind of event gets a new name. - **The namespace is an identifier, not a link.** Do not change its bytes, even if the repository moves. - **`pagesFold` is the gate.** It skips a type that is not in `HANDLES` and a `v` higher than `HANDLES` gives, so an older reader passes over what it cannot read and does not fold it wrongly. Do not call `reduce` on events from a log without the gate in front of it. + +## Page properties + +A page can carry properties: values about the page itself, such as a status, its aliases or a cover. They are a tree. A key is a **path**, a non-empty array of strings, and every node holds a value, a set of items, both, or only what is under it. The events give no key a meaning: what `["app", "cover"]` is, is a rule in whatever reads it. By convention the first segment says who owns a key: `["user", ...]` for what people type, `["app", ...]` for keys the app gives a meaning to. + +| Type | Payload | Meaning | +|---|---|---| +| `page.property.set` | `{ page, path, value }` | The value at `path` is now `value`. Its items are untouched. | +| `page.property.unset` | `{ page, path }` | `path` has no value. Its items are untouched. | +| `page.property.item.added` | `{ page, path, item }` | `item` is among the items at `path`. Adding one already there moves it to the end. The value is untouched. | +| `page.property.item.removed` | `{ page, path, item }` | `item` is no longer among them. The value is untouched. | +| `page.property.removed` | `{ page, seen: [{ path, value?, items }] }` | A property removed: for each path in `seen`, the value if it is still the one seen, and each item listed. What is not listed stays. | + +Each is `NAMESPACE` followed by its name, at `v: 1`. + +- **A scalar** is a string, a finite number, a boolean or `null`, and the four stay distinct: `1`, `"1"`, `true` and `null` are four values. `null` is a value; `unset` is how a path stops having one. +- **The fold trims each path segment and puts every string in NFC,** so `" status "` and `"status"` are one key, and `Café` typed two ways is one key, one value and one item. A writer doesn't need to; the fold does it on every read. +- **A malformed event is skipped whole:** a page that isn't a non-empty title, a path that isn't a non-empty array of strings, a segment that is empty once trimmed, a value or item that isn't a scalar, or a `removed` whose `seen` is empty or has one bad entry. +- **No timestamp in any payload,** and the fold keeps none. +- **No create and no delete by prefix.** A node exists while it or something under it holds something. A removal lists what the writer saw, so what another device added in the meantime survives it. +- **The later write in fold order wins,** for the value and for each item on its own, so two devices writing different paths, or different items on one path, never overwrite each other. +- **A property event spells its page's title** the way `block.created` does, and a page with properties and no blocks is still a page. + +The reads take the folded pages, a page title and a path. Each returns a snapshot. + +- `get(pages, page, path)`: `{ path, value, items }`, both slots, so no reader has to choose. A path with nothing at it reads as no value and no items. +- `getValue` and `getItems`: one slot each. `getItems` is never undefined, and its last item is the latest added. +- `list(pages, page, scope = [])`: every path at or under `scope` that holds something, sorted by path. `[]` is the page root. +- `children(pages, page, path)`: the next segment of every path under `path`, each once, sorted. +- `path("a/b/c")` and `showPath(["a", "b", "c"])`: a path from text and back, for code and UI. The events always carry the array. + +Sorting is by path segment, by codepoint, the same on every device. The order means nothing. + diff --git a/packages/pages/blocks.test.ts b/packages/pages/blocks.test.ts index 10e8eca..d5457dc 100644 --- a/packages/pages/blocks.test.ts +++ b/packages/pages/blocks.test.ts @@ -8,7 +8,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { - HANDLES, + BLOCK_HANDLES, indented, NAMESPACE, outdented, @@ -21,7 +21,7 @@ import type { Block, PageEvent, Pages } from "./blocks.ts"; const TODAY = "2026-09-21"; /** Nothing written yet. */ -const empty = (): Pages => ({ blocks: {}, titles: {} }); +const empty = (): Pages => ({ blocks: {}, titles: {}, properties: new Map() }); /** One event, straight into `reduce`, with no log or device around it. */ function event(type: string, v: number, payload: unknown) { @@ -96,6 +96,7 @@ test("an event is folded under its namespaced name and no other", () => { assert.deepEqual(reduce(none, event(`${NAMESPACE}block.created`, 1, payload)), { blocks: { [TODAY]: [{ id: "b1", text: "first", children: [] }] }, titles: { [TODAY]: TODAY }, + properties: new Map(), }); assert.equal(reduce(none, event("block.created", 1, payload)), none); // Only the exact prefix. A slash is what #37 first proposed and is not it. @@ -104,8 +105,8 @@ test("an event is folded under its namespaced name and no other", () => { none, ); - assert.equal(Object.keys(HANDLES).length, 5); - assert.equal(Object.keys(HANDLES).every((type) => type.startsWith(NAMESPACE)), true); + assert.equal(Object.keys(BLOCK_HANDLES).length, 5); + assert.equal(Object.keys(BLOCK_HANDLES).every((type) => type.startsWith(NAMESPACE)), true); }); test("a prediction is the fold, and folding it over a page that has it changes nothing", () => { diff --git a/packages/pages/blocks.ts b/packages/pages/blocks.ts index bf07918..b5e35ab 100644 --- a/packages/pages/blocks.ts +++ b/packages/pages/blocks.ts @@ -21,6 +21,7 @@ */ import { read } from "./reading.ts"; +import type { Properties } from "./properties.ts"; export type Block = { id: string; @@ -106,10 +107,15 @@ export function pageKey(title: string): string { * overwritten: editing the link away later leaves it as it was. A page can * have a spelling and no blocks yet — a link names it before anyone writes on * it — so every key in `blocks` is in `titles` but not the other way round. + * + * `properties` sits beside the blocks, not inside them, so nothing that reads + * blocks changes (`properties.ts`, #84). A property event spells its page in + * `titles` too, so every key in `properties` is in `titles` as well. */ export type Pages = { blocks: Record; titles: Record; + properties: Properties; }; /** @@ -134,9 +140,12 @@ export function titleOf(pages: Pages, title: string): string { /** * `titles` with a spelling for each of `names` whose key has none yet, in - * order, so the first of two spellings in one event is the one kept. + * order, so the first of two spellings in one event is the one kept. The + * same object back when every name already has one. + * + * Exported for the property fold, which spells a page the same way. */ -function spell(titles: Pages["titles"], names: string[]): Pages["titles"] { +export function spell(titles: Pages["titles"], names: string[]): Pages["titles"] { let next = titles; for (const name of names) { const key = pageKey(name); @@ -235,11 +244,12 @@ type Named = `${typeof NAMESPACE}${T}`; * new event type without a version here, or a version here for an event type * that does not exist, stops the build. * - * The gate itself is `pagesFold` in `pages.ts`. Its job is the case a + * The block events' half of `HANDLES` in `pages.ts`, which is the table the + * gate reads. The gate itself is `pagesFold` there. Its job is the case a * single-writer log could never produce: a newer build on another machine * writing something this one does not understand. Those events are skipped. */ -export const HANDLES: Record = { +export const BLOCK_HANDLES: Record = { [`${NAMESPACE}block.created`]: 1, // 2 since the page came off the payload. Nothing needed an upcast in this // direction — the field is simply unread — but an older build handed one of @@ -423,6 +433,7 @@ export function reduce( block, ]; return { + ...state, blocks: { ...state.blocks, [pageKey(page)]: next }, // The page before its links: it is where the user was writing. titles: spell(state.titles, [page, ...linksIn(text)]), @@ -568,6 +579,7 @@ export function predict(page: Page, events: PageEvent[]): Page { let state: Pages = { blocks: { [key]: page.blocks }, titles: { [key]: page.title }, + properties: new Map(), }; for (const event of events) { if (!landed(state, event)) state = reduce(state, event); diff --git a/packages/pages/pages.test.ts b/packages/pages/pages.test.ts index 65f3d70..39aa217 100644 --- a/packages/pages/pages.test.ts +++ b/packages/pages/pages.test.ts @@ -117,7 +117,7 @@ test("each builder is the event its command writes, and folds to the same pages" } }); -test("every builder writes the v its own gate reads, and there is one for every event", () => { +test("every builder writes the v its own gate reads, and there is one for every block event", () => { const built = [ blockCreated(TODAY, "x", undefined, "x"), blockEdited("x", "y"), @@ -126,9 +126,10 @@ test("every builder writes the v its own gate reads, and there is one for every blockOutdented({ id: "x", after: "y" }), ]; for (const event of built) assert.equal(event.v, HANDLES[event.type], event.type); + // Block events only: the property events' builders are #133. assert.deepEqual( built.map((event) => event.type).sort(), - Object.keys(HANDLES).sort(), + Object.keys(HANDLES).filter((type) => type.startsWith(`${NAMESPACE}block.`)).sort(), ); }); diff --git a/packages/pages/pages.ts b/packages/pages/pages.ts index 51d1f1e..4dc4bf2 100644 --- a/packages/pages/pages.ts +++ b/packages/pages/pages.ts @@ -14,9 +14,9 @@ * fold still gets wrong then, is `app/docs/block-sync.md`. */ import { + BLOCK_HANDLES, blocksOn, find, - HANDLES, indented, locate, NAMESPACE, @@ -34,12 +34,13 @@ import type { Pages, } from "./blocks.ts"; import { linking, read } from "./reading.ts"; +import { PROPERTY_HANDLES, reduceProperties } from "./properties.ts"; +import type { PropertyEvent } from "./properties.ts"; export { beside, blocksOn, find, - HANDLES, indented, lastLeaf, locate, @@ -64,6 +65,28 @@ export type { Pages, } from "./blocks.ts"; export { inCode, linking, read } from "./reading.ts"; +export { + children, + get, + getItems, + getValue, + list, + path, + showPath, +} from "./properties.ts"; +export type { + Entry, + Properties, + Property, + PropertyEvent, + PropertyItemAdded, + PropertyItemRemoved, + PropertyRemoved, + PropertySet, + PropertyUnset, + Scalar, + Seen, +} from "./properties.ts"; export { remarkPlugins, remarkWikilink } from "./wikilink.ts"; /** @@ -88,6 +111,16 @@ export function today(now: Date): string { return `${now.getFullYear()}-${month}-${day}`; } +/** + * Event type → the payload `v` this build folds, which is also the `v` it + * writes: the block events and the property events in one table, because + * the gate reads one. Public contract with `NAMESPACE`; see the README. + */ +export const HANDLES: Record = { + ...BLOCK_HANDLES, + ...PROPERTY_HANDLES, +}; + /** * The pages fold, for whoever runs it over a ledger: `ledger.onEvent( * pagesFold.initial, pagesFold.reduce)`. @@ -99,12 +132,14 @@ export function today(now: Date): string { * and the rest) take `v` from the same table. */ export const pagesFold = { + // The block fold, then the property fold. Each hands back the state it was + // given for an event that is not its own, so composing them is all it takes. reduce: (state: Pages, event: { type: string; v: number; payload: unknown }): Pages => Object.hasOwn(HANDLES, event.type) && - event.v <= HANDLES[event.type as PageEvent["type"]] - ? reduce(state, event) + event.v <= HANDLES[event.type as keyof typeof HANDLES] + ? reduceProperties(reduce(state, event), event) : state, - initial: { blocks: {}, titles: {} } as Pages, + initial: { blocks: {}, titles: {}, properties: new Map() } as Pages, }; /** @@ -137,7 +172,8 @@ export function getPage(pages: Pages, title: string): Page { * * A day that folds to nothing is left out. A date whose only block was * deleted is not a page the user wrote on any more, and a heading with - * nothing under it reads like something went missing. + * nothing under it reads like something went missing. A day that has + * properties and no blocks is still a day (#84). * * Today is not added here. Which day that is depends on the clock, and this * is a reading of the log — a caller that shows a journal puts today at the @@ -147,11 +183,17 @@ export function getPage(pages: Pages, title: string): Page { * dates themselves. That is the reason for the format. */ export function getPages(pages: Pages): Page[] { - const { blocks, titles } = pages; - return Object.entries(blocks) - .filter(([key, on]) => DATE_PATTERN.test(key) && on.length > 0) - .sort(([a], [b]) => b.localeCompare(a)) - .map(([key, on]) => ({ title: titles[key], blocks: on })); + const { blocks, titles, properties } = pages; + const on = (key: string): Block[] => + Object.hasOwn(blocks, key) ? blocks[key] : []; + const days = new Set([ + ...Object.keys(blocks).filter((key) => on(key).length > 0), + ...properties.keys(), + ]); + return [...days] + .filter((key) => DATE_PATTERN.test(key)) + .sort((a, b) => b.localeCompare(a)) + .map((key) => ({ title: titles[key], blocks: on(key) })); } /** diff --git a/packages/pages/properties.test.ts b/packages/pages/properties.test.ts new file mode 100644 index 0000000..e5b6454 --- /dev/null +++ b/packages/pages/properties.test.ts @@ -0,0 +1,332 @@ +/** + * Run with: npm test + * + * Page properties folded through the gate, with no log around them: each + * event is `{ type, v, payload }`, which is all the fold reads. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + children, + get, + getItems, + getPage, + getPages, + getValue, + HANDLES, + list, + NAMESPACE, + pagesFold, + path, + showPath, +} from "./pages.ts"; +import type { Pages } from "./pages.ts"; + +const PAGE = "Mira"; +const DAY = "2026-10-29"; + +type Event = { type: string; v: number; payload: unknown }; + +/** A property event of `name`, as written. */ +const ev = (name: string, payload: unknown, v = 1): Event => ({ + type: `${NAMESPACE}page.property.${name}`, + v, + payload, +}); + +const set = (path: unknown, value: unknown, page: unknown = PAGE) => + ev("set", { page, path, value }); +const unset = (path: unknown, page: unknown = PAGE) => ev("unset", { page, path }); +const added = (path: unknown, item: unknown, page: unknown = PAGE) => + ev("item.added", { page, path, item }); +const removedItem = (path: unknown, item: unknown, page: unknown = PAGE) => + ev("item.removed", { page, path, item }); +const removed = (seen: unknown, page: unknown = PAGE) => ev("removed", { page, seen }); + +/** `events` folded in order, from nothing. */ +function fold(...events: Event[]): Pages { + return events.reduce((state, event) => pagesFold.reduce(state, event), pagesFold.initial); +} + +/** What `path` holds on `page`, as plain data to compare. */ +function at(state: Pages, path: string[], page = PAGE) { + const { value, items } = get(state, page, path); + return { value, items: [...items] }; +} + +const STATUS = ["user", "status"]; + +test("the table has a v for each of the five property events", () => { + for (const name of ["set", "unset", "item.added", "item.removed", "removed"]) { + assert.equal(HANDLES[`${NAMESPACE}page.property.${name}` as keyof typeof HANDLES], 1, name); + } +}); + +test("set and unset touch the value, and leave the items alone", () => { + const state = fold(added(STATUS, "a"), set(STATUS, "draft")); + assert.deepEqual(at(state, STATUS), { value: "draft", items: ["a"] }); + assert.deepEqual(at(fold(added(STATUS, "a"), set(STATUS, "draft"), unset(STATUS)), STATUS), { + value: undefined, + items: ["a"], + }); +}); + +test("item.added and item.removed touch the items, and leave the value alone", () => { + const state = fold(set(STATUS, "draft"), added(STATUS, "a"), added(STATUS, "b")); + assert.deepEqual(at(state, STATUS), { value: "draft", items: ["a", "b"] }); + assert.deepEqual( + at(pagesFold.reduce(state, removedItem(STATUS, "a")), STATUS), + { value: "draft", items: ["b"] }, + ); +}); + +test("the later write in fold order wins, for the value and for each item", () => { + assert.equal(getValue(fold(set(STATUS, "a"), set(STATUS, "b")), PAGE, STATUS), "b"); + assert.equal(getValue(fold(set(STATUS, "b"), set(STATUS, "a")), PAGE, STATUS), "a"); + assert.equal(getValue(fold(set(STATUS, "a"), unset(STATUS)), PAGE, STATUS), undefined); + assert.equal(getValue(fold(unset(STATUS), set(STATUS, "a")), PAGE, STATUS), "a"); + + assert.deepEqual([...getItems(fold(added(STATUS, "x"), removedItem(STATUS, "x")), PAGE, STATUS)], []); + assert.deepEqual([...getItems(fold(removedItem(STATUS, "x"), added(STATUS, "x")), PAGE, STATUS)], ["x"]); +}); + +test("two writers adding different items to one path keep both, in either order", () => { + const one = getItems(fold(added(STATUS, "a"), added(STATUS, "b")), PAGE, STATUS); + const other = getItems(fold(added(STATUS, "b"), added(STATUS, "a")), PAGE, STATUS); + assert.deepEqual([...one].sort(), ["a", "b"]); + assert.deepEqual([...other].sort(), ["a", "b"]); +}); + +test("adding an item that is already there moves it to the end", () => { + const state = fold(added(STATUS, "a"), added(STATUS, "b"), added(STATUS, "a")); + assert.deepEqual([...getItems(state, PAGE, STATUS)], ["b", "a"]); +}); + +test("a path emptied by its last unset or item.removed is gone, and so is a page left with nothing", () => { + const note = ["user", "status", "note"]; + const state = fold(set(note, "x"), added(STATUS, "a")); + assert.deepEqual(children(state, PAGE, ["user"]), ["status"]); + + const emptied = fold(set(note, "x"), added(STATUS, "a"), unset(note), removedItem(STATUS, "a")); + assert.deepEqual(list(emptied, PAGE), []); + assert.deepEqual(children(emptied, PAGE, []), []); + assert.equal(emptied.properties.size, 0); + + // A parent that holds nothing itself goes with the last thing under it. + const parentless = fold(set(note, "x"), unset(note)); + assert.deepEqual(children(parentless, PAGE, ["user"]), []); +}); + +test("a page with properties and no blocks is still a page, under its first spelling", () => { + const state = fold(set(["user", "mood"], "good", DAY)); + assert.deepEqual(getPages(state).map((page) => page.title), [DAY]); + assert.deepEqual(getPages(state)[0].blocks, []); + + const spelled = fold(set(STATUS, "a", "MIRA"), set(STATUS, "b", "mira")); + assert.equal(getPage(spelled, "mira").title, "MIRA"); + const blockLater = pagesFold.reduce(spelled, { + type: `${NAMESPACE}block.created`, + v: 1, + payload: { page: "Mira", id: "b1", text: "hello" }, + }); + assert.equal(getPage(blockLater, "MIRA").title, "MIRA"); + assert.equal(getPage(blockLater, "mira").blocks.length, 1); + // The property is still there after the block event. + assert.equal(getValue(blockLater, "Mira", STATUS), "b"); +}); + +test("a malformed payload is skipped whole and not applied in part", () => { + const before = fold(set(STATUS, "kept")); + for (const event of [ + set([], "x"), + set("user/status", "x"), + set(["user", 3], "x"), + set(["user", ""], "x"), + set(["user", " "], "x"), + set(STATUS, undefined), + set(STATUS, Number.NaN), + set(STATUS, Number.POSITIVE_INFINITY), + set(STATUS, { nested: true }), + set(STATUS, ["a"]), + set(STATUS, "x", 7), + set(STATUS, "x", " "), + added(STATUS, undefined), + ev("set", null), + ev("set", "not an object"), + ev("set", { path: STATUS, value: "x" }), + removed([]), + removed("nope"), + removed([{ path: STATUS, items: ["a"] }, { path: [], items: [] }]), + removed([{ path: STATUS }]), + removed([{ path: STATUS, items: [Number.NaN] }]), + removed([{ path: STATUS, value: Number.NaN, items: [] }]), + ev("renamed", { page: PAGE, path: STATUS }), + ]) { + assert.equal(pagesFold.reduce(before, event), before, JSON.stringify(event.payload)); + } +}); + +test("the gate skips a property event with a v this build does not read", () => { + const before = pagesFold.initial; + assert.equal(pagesFold.reduce(before, ev("set", { page: PAGE, path: STATUS, value: "x" }, 2)), before); +}); + +test("__proto__ as a title, a segment or an item is an ordinary key", () => { + const proto = "__proto__"; + const state = fold( + set(["user", proto], "x", proto), + added(["user", proto], proto, proto), + set(["toString"], "y"), + ); + assert.deepEqual(at(state, ["user", proto], proto), { value: "x", items: [proto] }); + assert.equal(getValue(state, PAGE, ["toString"]), "y"); + assert.deepEqual(children(state, proto, ["user"]), [proto]); + assert.deepEqual(getPage(state, proto).blocks, []); +}); + +test("NFC and NFD spellings of one string are one segment, one value and one item", () => { + const nfc = "Café"; + const nfd = "Café"; + const state = fold(set(["user", nfd], nfd), added(["user", nfc], nfd), added(["user", nfd], nfc)); + assert.deepEqual(at(state, ["user", nfc]), { value: nfc, items: [nfc] }); + assert.deepEqual(children(state, PAGE, ["user"]), [nfc]); +}); + +test("scalars keep their type, segments are trimmed, and empty strings are values", () => { + const items = getItems(fold(added(STATUS, 1), added(STATUS, "1"), added(STATUS, true), added(STATUS, null)), PAGE, STATUS); + assert.deepEqual([...items], [1, "1", true, null]); + + assert.deepEqual(at(fold(set(STATUS, null)), STATUS), { value: null, items: [] }); + assert.equal(list(fold(set(STATUS, null)), PAGE).length, 1); + assert.deepEqual(list(fold(set(STATUS, null), unset(STATUS)), PAGE), []); + + const trimmed = fold(set([" user ", "\tstatus\n"], "x")); + assert.equal(getValue(trimmed, PAGE, STATUS), "x"); + assert.equal(getValue(trimmed, PAGE, [" user", "status "]), "x"); + + assert.deepEqual(at(fold(set(STATUS, ""), added(STATUS, "")), STATUS), { value: "", items: [""] }); + + // -0 is 0, as JSON writes it, and 1.0 is 1. + assert.deepEqual([...getItems(fold(added(STATUS, -0), added(STATUS, 0), added(STATUS, 1.0), added(STATUS, 1)), PAGE, STATUS)], [0, 1]); +}); + +test("removed clears what it lists, at a path and under it", () => { + const note = ["user", "status", "note"]; + const state = fold(set(STATUS, "draft"), added(STATUS, "a"), set(note, "x")); + const gone = pagesFold.reduce( + state, + removed([ + { path: STATUS, value: "draft", items: ["a"] }, + { path: note, value: "x", items: [] }, + ]), + ); + assert.deepEqual(list(gone, PAGE), []); + assert.deepEqual(children(gone, PAGE, ["user"]), []); +}); + +test("removed leaves what the writer did not see, in either fold order", () => { + const note = ["user", "status", "note"]; + const base = [set(STATUS, "draft"), added(STATUS, "a")]; + const removal = removed([{ path: STATUS, value: "draft", items: ["a"] }]); + const concurrent = [set(STATUS, "done"), added(STATUS, "b"), set(note, "y")]; + + for (const state of [fold(...base, ...concurrent, removal), fold(...base, removal, ...concurrent)]) { + assert.deepEqual(at(state, STATUS), { value: "done", items: ["b"] }); + assert.equal(getValue(state, PAGE, note), "y"); + } +}); + +test("removed takes a seen value that is still there, null included, and leaves the value for an absent one", () => { + assert.equal( + getValue(fold(set(STATUS, null), removed([{ path: STATUS, value: null, items: [] }])), PAGE, STATUS), + undefined, + ); + // A seen `null` is not a seen "nothing": a value of "x" stays. + assert.equal( + getValue(fold(set(STATUS, "x"), removed([{ path: STATUS, value: null, items: [] }])), PAGE, STATUS), + "x", + ); + // No `value`: the writer saw no value here, so whatever is there stays. + assert.deepEqual( + at(fold(set(STATUS, "x"), added(STATUS, "a"), removed([{ path: STATUS, items: ["a"] }])), STATUS), + { value: "x", items: [] }, + ); + // The seen value compares the way the fold does: NFD finds NFC, -0 finds 0. + assert.equal( + getValue(fold(set(STATUS, "Café"), removed([{ path: STATUS, value: "Café", items: [] }])), PAGE, STATUS), + undefined, + ); + assert.equal(getValue(fold(set(STATUS, 0), removed([{ path: STATUS, value: -0, items: [] }])), PAGE, STATUS), undefined); +}); + +test("list and children read the tree, sorted by codepoint", () => { + const state = fold( + set(["user", "foobar"], 2), + set(["user", "foo"], 1), + set(["user", "foo", "deep", "er"], 3), + added(["user", "foo", "list"], "i"), + set(["app", "cover"], "x"), + // U+FF5E sorts after U+1F600 by UTF-16 unit, before it by codepoint. + set(["user", "\u{1F600}"], "smile"), + set(["user", "~"], "tilde"), + ); + + assert.deepEqual(children(state, PAGE, []), ["app", "user"]); + assert.deepEqual(children(state, PAGE, ["user"]), ["foo", "foobar", "~", "\u{1F600}"]); + assert.deepEqual(children(state, PAGE, ["user", "foo"]), ["deep", "list"]); + assert.deepEqual(children(state, PAGE, ["user", "foo", "deep"]), ["er"]); + assert.deepEqual(children(state, PAGE, ["user", "foo", "deep", "er"]), []); + + // A scope that holds something itself comes first, then what is under it; + // ["foo"] is not a prefix of ["foobar"]. + assert.deepEqual( + list(state, PAGE, ["user", "foo"]).map((it) => showPath(it.path)), + ["user/foo", "user/foo/deep/er", "user/foo/list"], + ); + assert.deepEqual( + list(state, PAGE).map((it) => showPath(it.path)), + ["app/cover", "user/foo", "user/foo/deep/er", "user/foo/list", "user/foobar", "user/~", "user/\u{1F600}"], + ); + // Only paths that hold something: "user/foo/deep" is in neither list. + assert.equal(get(state, PAGE, ["user", "foo", "deep"]).value, undefined); + // The root holds nothing. + assert.deepEqual(at(state, []), { value: undefined, items: [] }); + // A page with nothing, and a scope that can't be a path, read as empty. + assert.deepEqual(list(state, "Nobody"), []); + assert.deepEqual(list(state, PAGE, [" "]), []); + assert.deepEqual(children(state, PAGE, ["nothing", "here"]), []); +}); + +test("reads are snapshots, and the fold never changes the state it is handed", () => { + const state = fold(set(STATUS, "draft"), added(STATUS, "a")); + const read = get(state, PAGE, STATUS); + read.path.push("changed"); + (read.items as Set).add("b"); + assert.deepEqual(at(state, STATUS), { value: "draft", items: ["a"] }); + + const page = state.properties.get("mira"); + const snapshot = structuredClone(state.properties); + pagesFold.reduce(state, set(STATUS, "done")); + pagesFold.reduce(state, added(STATUS, "c")); + pagesFold.reduce(state, removed([{ path: STATUS, value: "draft", items: ["a"] }])); + assert.equal(state.properties.get("mira"), page); + assert.deepEqual(state.properties, snapshot); +}); + +test("an event that changes nothing, and one that is not a property event, hand back the same state", () => { + const state = fold(set(STATUS, "draft")); + assert.equal(pagesFold.reduce(state, unset(["user", "other"])), state); + assert.equal(pagesFold.reduce(state, removedItem(STATUS, "absent")), state); + assert.equal(pagesFold.reduce(state, removed([{ path: STATUS, value: "other", items: [] }])), state); + const block = pagesFold.reduce(state, { + type: `${NAMESPACE}block.edited`, + v: 2, + payload: { id: "nope", text: "x" }, + }); + assert.equal(block, state); +}); + +test("path and showPath turn text into a path and back", () => { + assert.deepEqual(path("a/b/c"), ["a", "b", "c"]); + assert.equal(showPath(["a", "b", "c"]), "a/b/c"); +}); diff --git a/packages/pages/properties.ts b/packages/pages/properties.ts new file mode 100644 index 0000000..07ff0ff --- /dev/null +++ b/packages/pages/properties.ts @@ -0,0 +1,448 @@ +/** + * Page properties: values about a page itself, such as a status, its aliases + * or a cover (#84). The events, the fold over them, and the reads. + * + * A page's properties are a tree. A key is a path, a non-empty list of + * strings, and every node on it holds a value, a set of items, both, or + * nothing but what is under it. Nobody declares which a key is before writing + * to it, and the format gives no key a meaning: "the cover is the value at + * `["app", "cover"]`" is a rule in whatever reads it, not in the events. + * + * The fold is two steps, kept apart. `parse` takes a payload from a shared + * folder, which may be anything, and either turns it into edits or refuses + * it whole. `apply` takes edits it can trust and changes the state. Every + * event is a list of edits, each one a change to one path's entry, and edits + * compose: `removed` is nothing but several of the edits the four single + * events make, applied in one go. + * + * Like `blocks.ts`, nothing here touches a log, a clock or a window. + */ + +import { NAMESPACE, pageKey, spell } from "./blocks.ts"; + +/** + * A value or an item. The four types stay distinct: `1`, `"1"`, `true` and + * `null` are four values. `null` is a value, not an absence: `unset` is how + * a path stops having one. + * + * A number is a JSON number, so finite. A string is kept in NFC by the fold. + */ +export type Scalar = string | number | boolean | null; + +/** The value at `path` is now `value`. Its items are untouched. */ +export type PropertySet = { page: string; path: string[]; value: Scalar }; + +/** `path` has no value. Its items are untouched. */ +export type PropertyUnset = { page: string; path: string[] }; + +/** + * `item` is among the items at `path`. Adding one that is already there moves + * it to the end, which is what makes the last item the latest. The value is + * untouched. + */ +export type PropertyItemAdded = { page: string; path: string[]; item: Scalar }; + +/** `item` is no longer among the items at `path`. The value is untouched. */ +export type PropertyItemRemoved = { page: string; path: string[]; item: Scalar }; + +/** + * What a writer saw at one path when it removed a property. + * + * `value` absent: there was no value here, so the value is left alone. + * Present, `null` included: the value is removed only if it is still this + * one, so a value another device set that the remover never saw stays. + */ +export type Seen = { path: string[]; value?: Scalar; items: Scalar[] }; + +/** + * A property removed: for each path in `seen`, the value if it is still the + * one seen, and each item listed. What the writer did not see stays, which + * is why there is no "delete everything under this path": that would also + * remove what another device added in the meantime. + * + * One event for the whole removal, so a crash cannot leave half of it. + */ +export type PropertyRemoved = { page: string; seen: Seen[] }; + +/** An event type as it is written: under the namespace. */ +type Named = `${typeof NAMESPACE}${T}`; + +/** + * Every property event and its payload. No timestamp in any of them: the + * event's own `at` is when it was written, and this fold keeps no time. + */ +export type PropertyEvent = + | { type: Named<"page.property.set">; payload: PropertySet } + | { type: Named<"page.property.unset">; payload: PropertyUnset } + | { type: Named<"page.property.item.added">; payload: PropertyItemAdded } + | { type: Named<"page.property.item.removed">; payload: PropertyItemRemoved } + | { type: Named<"page.property.removed">; payload: PropertyRemoved }; + +/** Event type → the payload `v` this build reads and writes. See `HANDLES`. */ +export const PROPERTY_HANDLES: Record = { + [`${NAMESPACE}page.property.set`]: 1, + [`${NAMESPACE}page.property.unset`]: 1, + [`${NAMESPACE}page.property.item.added`]: 1, + [`${NAMESPACE}page.property.item.removed`]: 1, + [`${NAMESPACE}page.property.removed`]: 1, +}; + +/** + * What one path holds. Never empty: an entry with no value and no items is + * deleted, so a node with nothing in it is no node. + * + * The value is boxed so that `null`, a value, and no value stay apart. Items + * are keyed by their JSON, so `1` and `"1"` are two keys, and kept in the + * order they were last added. + */ +export type Entry = { + /** Trimmed and in NFC. */ + path: string[]; + value?: { v: Scalar }; + items: Map; +}; + +/** + * Every page's properties: page key (`pageKey`) → JSON of the path → entry. + * Flat, keyed by path, not a stored tree: an event touches only the entries + * it names, and the tree is a reading of this map that cannot disagree with + * it. `Map` throughout, so a title, a segment or an item of `__proto__` is an + * ordinary key (#62). + */ +export type Properties = Map>; + +/** One change to one path's entry. Returns the same entry when nothing changes. */ +type Edit = { path: string[]; change: (entry: Entry) => Entry }; + +const keyOf = (value: Scalar | string[]): string => JSON.stringify(value); + +const setValue = + (v: Scalar) => + (entry: Entry): Entry => ({ ...entry, value: { v } }); + +function unsetValue(entry: Entry): Entry { + if (!entry.value) return entry; + const { value: _, ...rest } = entry; + return rest; +} + +const unsetIf = + (v: Scalar) => + (entry: Entry): Entry => + entry.value && keyOf(entry.value.v) === keyOf(v) ? unsetValue(entry) : entry; + +const addItem = + (item: Scalar) => + (entry: Entry): Entry => { + const key = keyOf(item); + const items = new Map(entry.items); + items.delete(key); + items.set(key, item); + return { ...entry, items }; + }; + +const removeItem = + (item: Scalar) => + (entry: Entry): Entry => { + const key = keyOf(item); + if (!entry.items.has(key)) return entry; + const items = new Map(entry.items); + items.delete(key); + return { ...entry, items }; + }; + +/** A field the payload holds itself, never one every object inherits. */ +function field(payload: object, name: string): unknown { + return Object.hasOwn(payload, name) + ? (payload as Record)[name] + : undefined; +} + +/** + * A scalar as the fold keeps it, boxed so that `null` is not a refusal: + * strings in NFC, `-0` as `0`. Undefined for anything that is not a scalar, + * a non-finite number among them. + * + * `undefined` is refused, never read as "no value": `JSON.stringify` leaves a + * field holding it out, so on disk it is absent, and the event has to fold + * the same in memory as it will after a reopen. + */ +function scalar(value: unknown): { v: Scalar } | undefined { + if (value === null) return { v: null }; + if (typeof value === "boolean") return { v: value }; + if (typeof value === "string") return { v: value.normalize("NFC") }; + if (typeof value === "number" && Number.isFinite(value)) { + return { v: value === 0 ? 0 : value }; + } + return undefined; +} + +/** A segment as the fold keeps it: trimmed, then NFC. Undefined if that leaves nothing. */ +function segment(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim().normalize("NFC"); + return trimmed === "" ? undefined : trimmed; +} + +/** Every segment as the fold keeps it, or undefined if any one is refused. */ +function segments(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: string[] = []; + for (const it of value) { + const kept = segment(it); + if (kept === undefined) return undefined; + out.push(kept); + } + return out; +} + +/** A path for an event: segments, and at least one. */ +function pathOf(value: unknown): string[] | undefined { + const path = segments(value); + return path && path.length > 0 ? path : undefined; +} + +/** The edits one entry of a `removed` makes, or undefined if it is malformed. */ +function seenEdits(seen: unknown): Edit[] | undefined { + if (typeof seen !== "object" || seen === null) return undefined; + const path = pathOf(field(seen, "path")); + const listed = field(seen, "items"); + if (!path || !Array.isArray(listed)) return undefined; + const edits: Edit[] = []; + const raw = field(seen, "value"); + if (raw !== undefined) { + const value = scalar(raw); + if (!value) return undefined; + edits.push({ path, change: unsetIf(value.v) }); + } + for (const it of listed) { + const item = scalar(it); + if (!item) return undefined; + edits.push({ path, change: removeItem(item.v) }); + } + return edits; +} + +/** + * A property event as edits to one page, or undefined for anything else: an + * event that is not a property event, or one whose payload is malformed. A + * malformed event is skipped whole, never applied in part, and not reported + * (#104): it came from a shared folder, and may be anything. + */ +function parse( + type: string, + payload: unknown, +): { page: string; edits: Edit[] } | undefined { + if (!type.startsWith(NAMESPACE)) return undefined; + if (typeof payload !== "object" || payload === null) return undefined; + const page = field(payload, "page"); + if (typeof page !== "string" || pageKey(page) === "") return undefined; + + const named = type.slice(NAMESPACE.length); + if (named === "page.property.removed") { + const seen = field(payload, "seen"); + if (!Array.isArray(seen) || seen.length === 0) return undefined; + const edits: Edit[] = []; + for (const it of seen) { + const more = seenEdits(it); + if (!more) return undefined; + edits.push(...more); + } + return { page, edits }; + } + + const path = pathOf(field(payload, "path")); + if (!path) return undefined; + switch (named) { + case "page.property.set": { + const value = scalar(field(payload, "value")); + return value && { page, edits: [{ path, change: setValue(value.v) }] }; + } + case "page.property.unset": + return { page, edits: [{ path, change: unsetValue }] }; + case "page.property.item.added": { + const item = scalar(field(payload, "item")); + return item && { page, edits: [{ path, change: addItem(item.v) }] }; + } + case "page.property.item.removed": { + const item = scalar(field(payload, "item")); + return item && { page, edits: [{ path, change: removeItem(item.v) }] }; + } + default: + return undefined; + } +} + +/** + * `edits` applied in order to one page's entries. An entry left with nothing + * is deleted, then a page left with no entries. The same map back when no + * edit changed anything. + */ +function apply(properties: Properties, page: string, edits: Edit[]): Properties { + const key = pageKey(page); + const entries = new Map(properties.get(key)); + let changed = false; + for (const { path, change } of edits) { + const at = keyOf(path); + const current = entries.get(at) ?? { path, items: new Map() }; + const next = change(current); + if (next === current) continue; + changed = true; + if (next.value === undefined && next.items.size === 0) entries.delete(at); + else entries.set(at, next); + } + if (!changed) return properties; + const out = new Map(properties); + if (entries.size === 0) out.delete(key); + else out.set(key, entries); + return out; +} + +/** + * The property fold: applies one property event, and hands back `state` + * itself for every other event, so it composes with the block fold. + * + * Applied in the order the ledger folds, which is all "the later write wins" + * needs: each value and each item merges on its own, so two devices writing + * different paths, or different items on one path, never overwrite each + * other, and there are no tombstones. + * + * A property event spells its page's title the way `block.created` does: the + * first spelling the fold sees, from a block or a property, is the one kept. + * + * Pure, and it never changes what it is handed: the ledger keeps earlier + * states as checkpoints. Reads only what it declares, so any state with + * `titles` and `properties` folds through it. + */ +export function reduceProperties< + S extends { titles: Record; properties: Properties }, +>(state: S, event: { type: string; payload: unknown }): S { + const parsed = parse(event.type, event.payload); + if (!parsed) return state; + const properties = apply(state.properties, parsed.page, parsed.edits); + const titles = spell(state.titles, [parsed.page]); + return properties === state.properties && titles === state.titles + ? state + : { ...state, properties, titles }; +} + +/** What a read hands back about one path. A snapshot: changing it changes nothing. */ +export type Property = { + path: string[]; + value: Scalar | undefined; + items: ReadonlySet; +}; + +/** What the reads look at. `Pages` is one. */ +type Holds = { readonly properties: Properties }; + +const nothing = (path: string[]): Property => ({ + path, + value: undefined, + items: new Set(), +}); + +function snapshot(entry: Entry): Property { + return { + path: [...entry.path], + value: entry.value?.v, + items: new Set(entry.items.values()), + }; +} + +/** Whether `path` is `scope` or under it, segment by segment. */ +function within(path: string[], scope: string[]): boolean { + return scope.length <= path.length && scope.every((it, i) => path[i] === it); +} + +/** Two strings by codepoint, not by UTF-16 unit, so the order is the same everywhere. */ +function byCodepoint(a: string, b: string): number { + const x = Array.from(a, (c) => c.codePointAt(0) ?? 0); + const y = Array.from(b, (c) => c.codePointAt(0) ?? 0); + for (let i = 0; i < x.length && i < y.length; i++) { + if (x[i] !== y[i]) return x[i] - y[i]; + } + return x.length - y.length; +} + +/** Two paths segment by segment; a path sorts before what is under it. */ +function byPath(a: string[], b: string[]): number { + for (let i = 0; i < a.length && i < b.length; i++) { + const order = byCodepoint(a[i], b[i]); + if (order !== 0) return order; + } + return a.length - b.length; +} + +/** + * The entries of a page under `scope`, as the fold keeps them. A scope is + * read the way the fold reads a path, so `[" status "]` finds `["status"]`; + * one with a segment that is empty once trimmed finds nothing. + */ +function under(holds: Holds, page: string, scope: string[]): Entry[] { + const kept = segments(scope); + const entries = holds.properties.get(pageKey(page)); + if (!kept || !entries) return []; + return [...entries.values()].filter((entry) => within(entry.path, kept)); +} + +/** + * What `path` holds on `page`: its value and its items, both, so no reader + * has to choose. A path with nothing at it reads as no value and no items, + * the same as one never written. `[]`, the page root, holds nothing. + */ +export function get(holds: Holds, page: string, path: string[]): Property { + const kept = segments(path); + if (!kept) return nothing([...path]); + const entry = holds.properties.get(pageKey(page))?.get(keyOf(kept)); + return entry ? snapshot(entry) : nothing(kept); +} + +/** The value at `path` on `page`, or undefined for none. `null` is a value. */ +export function getValue(holds: Holds, page: string, path: string[]): Scalar | undefined { + return get(holds, page, path).value; +} + +/** The items at `path` on `page`, latest last. Empty, never undefined. */ +export function getItems(holds: Holds, page: string, path: string[]): ReadonlySet { + return get(holds, page, path).items; +} + +/** + * Every path on `page` at or under `scope` that holds a value or an item, + * sorted by path. `list(pages, page)` is every property the page has. + */ +export function list(holds: Holds, page: string, scope: string[] = []): Property[] { + return under(holds, page, scope) + .sort((a, b) => byPath(a.path, b.path)) + .map(snapshot); +} + +/** + * The next segment of every path on `page` strictly under `path`, each once, + * sorted. Calling it again on each name walks the whole tree; `[]` means + * nothing further down. + */ +export function children(holds: Holds, page: string, path: string[]): string[] { + const depth = path.length; + const names = new Set( + under(holds, page, path) + .filter((entry) => entry.path.length > depth) + .map((entry) => entry.path[depth]), + ); + return [...names].sort(byCodepoint); +} + +/** + * A path from text, `"a/b/c"` → `["a", "b", "c"]`, for code and UI that like + * typing strings. Never what the ledger holds: a segment may contain a `/`, + * and the events carry the array. + */ +export function path(text: string): string[] { + return text.split("/"); +} + +/** A path as text, `["a", "b", "c"]` → `"a/b/c"`, for display. */ +export function showPath(parts: string[]): string { + return parts.join("/"); +}