Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion packages/pages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

9 changes: 5 additions & 4 deletions packages/pages/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
HANDLES,
BLOCK_HANDLES,
indented,
NAMESPACE,
outdented,
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand All @@ -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", () => {
Expand Down
20 changes: 16 additions & 4 deletions packages/pages/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
*/

import { read } from "./reading.ts";
import type { Properties } from "./properties.ts";

export type Block = {
id: string;
Expand Down Expand Up @@ -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<string, Block[]>;
titles: Record<string, string>;
properties: Properties;
};

/**
Expand All @@ -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);
Expand Down Expand Up @@ -235,11 +244,12 @@ type Named<T extends string> = `${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<PageEvent["type"], number> = {
export const BLOCK_HANDLES: Record<PageEvent["type"], number> = {
[`${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
Expand Down Expand Up @@ -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)]),
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions packages/pages/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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(),
);
});

Expand Down
64 changes: 53 additions & 11 deletions packages/pages/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
* fold still gets wrong then, is `app/docs/block-sync.md`.
*/
import {
BLOCK_HANDLES,
blocksOn,
find,
HANDLES,
indented,
locate,
NAMESPACE,
Expand All @@ -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,
Expand All @@ -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";

/**
Expand All @@ -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<PageEvent["type"] | PropertyEvent["type"], number> = {
...BLOCK_HANDLES,
...PROPERTY_HANDLES,
};

/**
* The pages fold, for whoever runs it over a ledger: `ledger.onEvent(
* pagesFold.initial, pagesFold.reduce)`.
Expand All @@ -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,
};

/**
Expand Down Expand Up @@ -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
Expand All @@ -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) }));
}

/**
Expand Down
Loading
Loading