/** * Rendering another app's objects from its published manifest — the pure part. * * Split from `foreign.ts` so it can be exercised against a live relay without a * Convex deployment: everything here takes a `query` function rather than * reaching for one. `foreign.ts` supplies the authenticated bridge; a test * supplies its own. * * **Nothing in this file knows what Linear-lite is.** No kind number, no field * name, no status vocabulary is hardcoded — every one comes off the NIP-89 * manifest at runtime. If that stops being true the demo stops proving * anything: it becomes an integration written against one app, which is the * thing the whole exercise argues against. */ import { type AddressPointer } from '@estiva-app/protocol'; import { type Profile, type SignedEvent } from '@estiva-app/protocol'; import { BLOCK_DOCUMENT_FORMAT } from '@estiva-app/protocol'; import { type FolderRef } from './folders.js'; /** Query the relay. Returns matching events; shape mirrors the HTTP bridge. */ export type QueryFn = (filters: Record[]) => Promise; /** An unsigned Nostr event, ready for `sign.ts`. */ export interface UnsignedActionEvent { pubkey: string; created_at: number; kind: number; tags: string[][]; content: string; } /** * The people named on another app's objects (FEE-1). * * A manifest says a slot holds a pubkey; it never says whose. Peek showed the * first eight characters of the key, which is not a person — it is the thing a * person is behind. The name and face come from kind:0, the same place the * message list already gets them, so the same person reads the same way in both. * * Keyed by pubkey. A key that resolves to nothing is simply absent, and the * renderer decides what an unknown person looks like. */ export type People = Record; /** * Looking people up, as a seam rather than a direct call. * * The browser puts a session cache in front of this: a topic with ten reference * widgets resolves ten times, and it is the same handful of people every time. * Tests and Storybook pass a fixture instead and never touch a relay. */ export type PeopleFn = (pubkeys: string[]) => Promise; /** The default: one kind:0 query, straight to the relay. */ /** * How long a *name* may be believed. * * Names change rarely, so this is generous. See {@link PROFILE_MISS_TTL_MS} for * the half that matters. */ export declare const PROFILE_HIT_TTL_MS: number; /** * How long an *absent* profile may be believed, and it is deliberately short. * * A miss is somebody who has not finished setting up their identity — which is * to say, precisely the person whose name is about to arrive. Cached as long as * a hit, they render as `nostr:<8 chars>` until a full reload, and a consumer * that re-reads on a timer looks like its refresh is broken. That is PEE-3, * found in Peek once its panels started polling. */ export declare const PROFILE_MISS_TTL_MS = 60000; export interface PeopleCacheOptions { hitTtlMs?: number; missTtlMs?: number; /** Injectable clock. A test needs to move time, not wait ten minutes. */ now?: () => number; } /** * A profile store that outlives any one lookup. * * The store and the lookup have different lifetimes, which is why this is not * simply a wrapped {@link PeopleFn}: a consumer builds its query per call — * Peek's carries the viewer's token — while the profiles it finds are public * and worth keeping across all of them. `through` takes the lookup of the * moment and answers from one store. * * **Caller-owned**, like {@link createProjectionCache}. Peek's original lived in * a module-level `Map`, which worked but meant every test needed a reset hook * to undo the previous one. */ export interface PeopleCache { /** Wrap a lookup so it batches and expires against this store. */ through(fromRelay: PeopleFn): PeopleFn; /** The common case: {@link peopleViaRelay}, cached. */ viaRelay(query: QueryFn): PeopleFn; /** Forget everything. What a sign-out should call. */ clear(): void; } export declare function createPeopleCache(options?: PeopleCacheOptions): PeopleCache; export declare function peopleViaRelay(query: QueryFn): PeopleFn; /** * NIP-29's chat message, the one a Folder's conversation is made of — SPEC §7.8. * * Owned by no app, like a comment: the specification says how it draws and * where it opens, so no manifest's `k` tag is asked about it. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-chat-message-belongs-to-the-protocol.md */ export declare const KIND_MESSAGE = 9; /** * Every kind an app's comments might be under — what it publishes **now**, plus * any it has published before. * * `emits.kind` is a single number, and for a while that was enough. It stops * being enough the moment an app *changes* the kind it emits, because the old * events do not move: a `kind:9` message is not replaceable at all, so a comment * written under the old kind stays under it permanently. Reading only the * declared kind would show an object's newest comments and silently drop every * one written before the change — no error, nothing empty, just a thread that * begins in the middle. * * So the owning app may also declare `emits.alsoRead`, which is the kinds it * used to publish. That is the only place the knowledge actually lives; the * alternative is Peek hardcoding one app's history, which is exactly what the * note on `KIND_COMMENT` above says not to do. * * Absent `alsoRead` this returns a single kind, so a manifest written before * the field existed behaves exactly as it did. */ export declare function commentKindsOf(manifest: Manifest): number[]; /** * How many comments one conversation read asks for. * * **One constant for the panel and the badge, deliberately.** A count that * saturated at a different number than the view it labels would put "100" next * to a hundred and fifty visible messages, and a reader would be right to trust * neither. Both {@link resolveForeignObject} and {@link conversationCountsOf} * use this, so a file at the cap reports the cap in both places and the badge * means exactly "what opening this will show you". */ export declare const CONVERSATION_LIMIT = 200; /** * How many messages each file's conversation holds — one round trip for all of them. * * For the question a reader actually has in front of a list: *is this one worth * opening*. Answering it per file through {@link resolveForeignObject} would be * a resolve each, which is the cost {@link resolveFolderContents} exists to * avoid — against a relay metering reads at 300 a minute, a folder of * twenty-five would not load. * * ## One filter per file, not one filter for all of them * * The obvious shape — a single `#a` naming every address — shares one `limit` * across every file, so one busy file starves the rest and the short results * are indistinguishable from "there are no more". Per file, the relay clamps * each filter's limit independently, so each gets its own budget. The results * come back merged, which is fine: every comment names its object in its own * `a` tag, so attribution never depends on the order they arrive in. * * Chunked at `MAX_FILTERS_PER_QUERY` because a folder may hold more files than * one POST accepts, and silently counting only the first 128 is the kind of * quiet wrong answer this module keeps removing. * * ## What a zero means * * Exactly "nothing is addressed to this file", which is **not** the same as * "nobody has discussed it". A Peek topic's messages carry `h` and no `a` — * its conversation belongs to the container rather than the object — so a topic * counts zero however busy it is, until a topic becomes an addressable file * (RFC 0.4 §11.1). Ship's issues carry `a` and count correctly. A consumer * showing this must not render the zero as "no discussion". * * Files with no address are absent from the result rather than zero, so a * consumer can tell "asked and got none" from "not askable". */ export declare function conversationCountsOf(files: ForeignObject[], query: QueryFn, /** Warm from the read that produced these files; see {@link ProjectionCache}. */ cache?: ProjectionCache): Promise>; /** * One message of a file's conversation, as much of it as a list needs. * * Not the event: a list deciding whether a file is worth opening, or whether it * is unread, needs when and by whom and which thread — never the body. `root` * is the id of the thread's root message, which is the message's own id when * it *is* the root; a reader applying SPEC §11.3 needs it to find the thread's * marker. */ export interface ConversationMessage { id: string; /** Unix seconds, as the event carries and as NIP-RS stores. */ at: number; /** Author pubkey. */ by: string; root: string; } /** * What each file's conversation holds — one round trip for all of them. * * The read behind {@link conversationCountsOf}, returned rather than reduced * to a number, for the second question a list has once it knows a file is * worth opening: *is any of it new to me* (SPEC §11.1, FOL-16). A file's * read-state context is its address and its messages' own `a` tag names it, * so a list can judge unread per file from exactly this — each message's time, * author and thread against the file's marker — with no second read. Read * state itself is not this package's business, so nothing here knows a marker; * the consumer brings its own rule. * * Everything the count's doc says holds here: one filter per file, chunked at * `MAX_FILTERS_PER_QUERY`, attributed by every `a` tag, deduplicated across * filters, and a file with no address is absent rather than empty. Messages are * returned oldest first. * * Only what SPEC §6.4 calls a **comment** is here (CON-15). The `#a` read also * returns every message that merely *mentions* the file — a `kind:1111` rooted * on another file, a `kind:9` whose body names this one — and those are * *Mentioned in*, which §6.4 says MUST NOT be presented as the file's own * discussion. A badge counting them said "3" beside an issue with one comment. * See `@estiva-app/conversation`'s `isCommentOn` for the per-kind rule, which * this passes the owner's declared comment kinds (§7.3). * * ## `replies`: the threads under those comments too (CON-34) * * A dot judges replies by their thread (SPEC §11.3), and a reply never comes * back for `#a`: its lowercase tags name its parent comment, and the file is * only in its uppercase `A` (§6.4). Asked with `replies`, each file gets a * second filter by `#A`, and a reply is kept when its root is one of the * comments this read found for the file — the root decides (C11), so a reply * in a thread that only mentions the file, or under a root older than the * limit, is not here. The messages are then roots and replies, told apart by * `root === id`; a consumer that counts comments must not ask for them. */ export declare function conversationsOf(files: ForeignObject[], query: QueryFn, /** Warm from the read that produced these files; see {@link ProjectionCache}. */ cache?: ProjectionCache, options?: { replies?: boolean; }): Promise>; /** Every thread of one file's conversation, keyed by the comment it hangs from. */ export interface Threads { /** The root comments themselves, by id — the only read that reports their `kind` and tags. */ roots: Record; /** Direct replies to each root, oldest first. A root with none is absent, not `[]`. */ replies: Record; } /** * The replies under a file's comments — {@link conversationsOf}'s counterpart, * read by `#e` (PRO-20, from Peek's FOL-19 thread pane). * * **Asked by `#e`, not by `#a`, and that is the whole reason this exists.** A * reply's lowercase tags name its parent comment, not the file (SPEC §6.4), so * the `#a` read that brings the roots never brings a reply — whichever app * wrote it. `rootIds` are those roots, typically `conversationsOf`'s ids. * * **Every thread in one request.** The roots by `ids` and their replies by * `#e` go in one POST, because a `#e` filter takes a list: a whole pane's * replies cost the round trip of one thread's, which is what lets a comment * carry an honest reply count. The roots come back beside them because NIP-22's * lowercase `k` on a reply names the *parent's* kind, and no projection reports * it. * * The kinds are the owning app's, from its manifest (`commentKindsOf`), so a * NIP-22 `kind:1111` reply and Ship's `kind:9` ones both arrive. No manifest, * or a reference that does not decode, answers empty rather than guessing 1111. * * Only *direct* replies: a reply's lowercase `e` names its immediate parent, so * a reply to a reply hangs off that reply and is not here. The replies share * one `limit`, by default `CONVERSATION_LIMIT` — the same budget as one file's * comment read, and what Peek's pane asks for. * * A refused read is whatever `query` does with one — this adds no catch, so an * unreadable thread and an unanswered one stay distinguishable. */ export declare function threadsOf( /** The file's address, or any reference `referenceToPointer` reads. */ reference: string, rootIds: readonly string[], query: QueryFn, cache?: ProjectionCache, /** How many replies, across every thread, one read asks for. */ limit?: number): Promise; /** How the owning app says its records should be read. */ interface RecordsRule { changeKind: number; targetTag: string; fieldTag: string; valueTag: string; order: string[]; rule: string; /** * A folded field whose value means "do not show this object". * * Archiving is a field, not a deletion, so an archived record still comes * back from every query and renders perfectly well. Without this a consumer * cannot tell it apart from live work — which is how a topic whose Folder * held three archived projects ended up showing one of them. * * Peek honours the rule without knowing what the word means: the owner says * which field and which value, and this stays out of it. */ hiddenWhen?: { field: string; equals: string; }; } interface SlotSpec { /** * A tag on the root event, or a list of them meaning **first that resolves**. * * A list exists for the same reason `emits.alsoRead` does: an app that * renames a tag cannot rename it on the events it already published, so its * history spans both spellings permanently. Ship moving a project's title * from `title` to `name` is the case in hand — without a fallback, either * every record written before the rename renders blank or every one written * after does. * * Not to be confused with a `SlotSpec[]`, which the caller treats as "render * all of these as meta". This is one slot with several places to look. */ tag?: string | string[]; field?: string; fold?: string; map?: string; as?: string; label?: string; truncate?: number; default?: string; /** * **Child objects, found by the tag on the child that names this one.** * * The `list` slot (RFC 0.4 §13.3, PRO-2). The only slot source that does not * read the root event: every other field here answers "what does this event * say?", and this one answers "what points at it?". * * `limit` is the producer saying how many are worth fetching. **Recursion * depth is deliberately not here** — a child rendered through its own * projection may declare a `list` too, and the app at risk of the render loop * is the one drawing it, so the budget is the consumer's (`MAX_LIST_DEPTH`). * §13.4's honour-system rule cuts that way: a producer that could set the * consumer's recursion budget could hang it. */ children?: { kind: number; via: string; limit?: number; /** * What the child's `via` tag holds — added by PRO-6. * * `address` (the default, and Ship's case) means the tag carries the * parent's full `kind:pubkey:d`. `identifier` means it carries only the * parent's `d`. * * Found by declaring a projection for a Peek Topic. A message names its * channel with `h`, and `h` holds the **bare channel uuid** — which is the * topic's `d`, not its address. Nothing in NIP-29 is going to change that, * so a `list` slot that could only compare addresses could not express the * one relationship Peek has. Defaulting to `address` keeps every manifest * written before this reading exactly as it did. */ match?: 'address' | 'identifier'; /** * The field on the child whose change moves it to another parent — SPEC * §7.2, MAN-1. `project` on Ship's issues, `parent` on a bare file. * * The `via` tag is written once, by the child's author, on a root only * they may replace; a move is therefore a change event, and this names the * field it sets. The folded value wins over the tag, and an empty one is a * move to no parent. Ignored with `match: 'identifier'`, because a move's * value is an address. */ movedBy?: string; /** * The child names its own parent with its `via` tag, **of any kind** — the * bare file's rule (SPEC §6.7 Nesting), declared rather than hard-coded so * an owned kind can have it: a document sits under a topic, a project or * another document (Miky, 2026-10-14). With it, `movedBy` names the field * without limiting the parent to the declaring kind, and a consumer reads * the parent off the child instead of matching the declaring kind's prefix. */ anyParent?: boolean; }; } /** * What invoking an action does, coarsely enough for a caller to decide whether * to ask first (RFC 0.4 §13.4). * * Closed, and the closure is the point: a value only means something if every * consumer reads it the same way. * * | | | * | --- | --- | * | `safe` | changes nothing an author would mind — invoking unprompted is fine | * | `writes` | publishes a change somebody will see | * | `destructive` | removes or supersedes something | * * **Only an explicit `safe` may be read as safe.** Absent, unrecognised, or * from a manifest published before this field existed all mean *unknown*, and * unknown has to fall on the cautious side — a consumer testing * `effect !== 'destructive'` gets `true` for a manifest that never said, which * is the wrong direction to be wrong in. That is why {@link withKnownEffect} drops * a value outside this set rather than passing it through: an unrecognised * effect is not a fourth meaning, it is a consumer that does not understand * this manifest yet. */ export type ActionEffect = 'safe' | 'writes' | 'destructive'; export declare const ACTION_EFFECTS: readonly ActionEffect[]; /** An action the owning app says other apps may perform. */ export interface ManifestAction { id: string; label: string; /** * Prose aimed at a machine, distinct from `label`, which is a button caption * (RFC 0.4 §13.4). * * "Change status" tells a person which button to press and tells a caller * choosing *between* actions nothing at all. It was taken early because * adding a field costs a line and adding one after several apps have * published manifests is a migration across every one of them — a manifest is * republished by its owner alone. The same argument `emits.alsoRead` makes one * level down. * * **Something reads it now.** Peek's launcher selects an action from a * conversation by matching on this prose (INT-5), across every manifest it can * resolve. So a `description` written for a screen is not merely unused, it is * an action a caller never picks — a failure with no error in it. * {@link actionProblems} is the check to run before signing. */ description?: string; /** See {@link ActionEffect}. */ effect?: ActionEffect; /** Kind(s) this applies to, as strings. */ appliesTo: string | string[]; emits: { kind: number; field?: string; scope?: string; /** * Set by an action that creates a whole new object under this one: the tag * the child carries, and what it points at. `toAddressOf: "self"` means the * child names the object the action was invoked on. * * Peek reads these two to learn a **containment relation** — "kind X holds * kind Y" — without being told which app or which kinds are involved. It is * the only thing in the manifest that says so, which is what lets the * sidebar start from a Folder and find a project with issues in it. */ setTag?: string; toAddressOf?: string; /** * Kinds this action *used* to emit, which consumers must still read. * * Only ever additive to a read, never to a write: `kind` is what gets * published, this is what also gets fetched. See `commentKindsOf`. */ alsoRead?: number[]; /** * On a creation: the tag the new root names its Folder with — `h`, the * default, or `buzz-channel` (SPEC §7.3, MAN-1). * * `buzz-channel` is how a Ship project is written: the relay stores the * record globally, so anybody can discover the project, while its changes * and comments keep `h`. Any other value is refused when building, because * a Folder named in a tag nothing reads is no Folder. */ placement?: string; /** * That objects this action writes are named in their Folder's state, so a * creation is followed by a `kind:1852` add and a deletion by a remove from * each Folder listing it (SPEC §7.3, MAN-1; FOL-48). * * Only {@link buildActionEvents} builds one, because only its caller can * say whether a Folder has state — a read, never a guess. */ listed?: boolean; }; input?: { type: string; /** For a scalar input: the vocabulary its value must come from. */ enum?: string; /** * For a scalar change: the richest content model the owner accepts for the * value — SPEC §7.3's `format`, MAN-1. `estiva-blocks-1` is a block * document (§13.3). A format on the declaration, not a new type, because * the wire value is a string either way; a plain value is still accepted, * written untagged as marker text (§13.4). */ format?: string; /** * For `type: 'object'`: the fields a consumer draws, and which are required. * * **A property's name is the tag its value is written to** (PRO-4). That * rule was implicit — Ship's `add-issue` declares `{ title }` and a Ship * issue carries `["title", …]`, so it held by coincidence of naming and * nothing said so. It is stated here because a consumer cannot construct * the event without it, and RFC 0.4 §13.4 asserts the existing declaration * is already sufficient for one to try. * * **A property may instead target the event's `content`** — PRO-18. That * limit used to be recorded here as one designed around: *"nothing can * target an event's `content`"*. It is what made a conversation * undeclarable, because a message with its body in a tag is not a message, * and the empty `actions: []` in Peek's manifest says so in its own words. * * At most one property may do it. Two would be two writers of one field, * and the second silently winning is exactly the shape of bug this * vocabulary exists to make impossible. * * **A property targeting `content` may declare a `format`** — MAN-1, the * same field a scalar input declares. Meaningless on a property written to * a tag, which is a scalar, and ignored there. */ properties?: Record; required?: string[]; }; } /** * An action resolved into something a renderer can draw without re-reading the * manifest. * * The interpretation happens here rather than in the component on purpose: the * widget should not have to know that `input.enum` names a vocabulary, or that * `as: "pubkey"` means "a person". Those are manifest semantics, and keeping * them on this side is what lets the React component stay a dumb renderer that * would work for any app. */ /** One field of an object-creating action's form. */ export interface ActionFormField { /** Also the tag its value is written to — see {@link ManifestAction.input}. */ name: string; type: string; required: boolean; /** * Written to the event's `content` rather than to a tag — PRO-18. * * Absent for every field that is a tag, which is nearly all of them. A * consumer may use it to draw prose instead of a single line; it does not * have to, and one that ignores it still publishes the right event. */ target?: 'content'; /** * That the field is prose the owner accepts as a block document — MAN-1. * * Set only for a content field declaring a format this version writes, so a * consumer that sees it may draw a block editor and pass * `contentFormat: 'estiva-blocks-1'` to {@link buildActionEvent}. A consumer * that draws a text box instead still publishes a correct event (§7.3). */ format?: ProseFormat; /** When the property names a vocabulary, its entries, already looked up. */ options?: { value: string; label: string; colour?: string; }[]; } /** * The content formats a declaration may name and this version writes — only * SPEC §13.3's block document. An unknown format is not surfaced, so no * consumer draws an editor for a model nothing here can tag correctly. */ export type ProseFormat = typeof BLOCK_DOCUMENT_FORMAT; export interface ResolvedAction { id: string; label: string; /** See {@link ManifestAction.description}. Carried through, never rendered here. */ description?: string; /** * See {@link ActionEffect}. Absent when the manifest did not say, or said * something this version does not recognise — both meaning *unknown*. */ effect?: ActionEffect; /** * The control a consumer draws. `confirm` is a deletion (FOL-33): one button * that asks first and takes no value, because the action emits NIP-09's * `kind:5` and there is nothing to type. Added in 0.25.0 — a consumer that * switches on this and falls through to a text input must not draw one for * it. */ control: 'select' | 'pubkey' | 'text' | 'form' | 'confirm'; /** For `select`: the declared vocabulary, already looked up. */ options?: { value: string; label: string; colour?: string; }[]; /** * For `form`: the fields to draw, in declaration order, with any vocabulary * already looked up — the same service `options` performs for a `select`. */ fields?: ActionFormField[]; /** * For `form`: that the new object hangs under the one the action was invoked * on (`emits.toAddressOf: 'self'`). * * Surfaced because it is the other half of "needs a form **and a parent**", * which is what made these unrenderable: a consumer drawing only the * properties would publish an orphan. */ createsUnder?: string; /** * For a `text` change: that the value is prose the owner accepts as a block * document (MAN-1). See {@link ActionFormField.format} — the same rule for a * field of a form. */ format?: ProseFormat; /** * That building this action needs to know the Folder's state: whether it has * any, for a creation, or which Folders name the object, for a deletion * (`emits.listed`, MAN-1). Pass it to {@link buildActionEvents}. */ listed?: true; /** The value this field holds right now, so a control can show it. */ current?: string; field?: string; /** * The app that declared this action, **when it is not the object's own** — * MAN-8. * * Ship declares `add-project` on a Folder, and a Folder is Peek's kind: the * object is drawn by one manifest and the action comes from another. Only a * creation is ever borrowed like this (see {@link creatorsOn}), so this is * set only on a `form`. Absent on every action the owning app declares. * * A consumer acting on it must build with the manifest named here, not the * object's — `buildActionEvents` finds the action by id in the manifest it is * handed, and the owner's has no `add-project`. {@link resolveActingManifest} * is that lookup. `appName` is the declaring app's `name`, for a consumer * that says whose action it is. */ declaredBy?: { address: string; appName?: string; }; } /** How the owning app asks one of its kinds to be drawn (RFC 0.4 §13.3). */ interface Projection { widget: string | string[]; slots: Record; /** * What one of these is called, lower case and singular — "issue", * "project". Resolved onto {@link ForeignObject.noun}; see there. */ noun?: string; } interface Manifest { name?: string; about?: string; actions?: ManifestAction[]; records?: RecordsRule; projections?: Record; vocabularies?: Record; /** * Which aspect of *every* file this app renders — RFC 0.5 §10.7's one * protocol change, and the field that tells a generic app from a specialized * one that happens to draw a card. * * A specialized app owns kinds and says nothing here. A generic app declares * the one aspect it renders for everyone else's files: `conversation` (Peek) * or `document` (Leaf, which writes the title, summary and text of any file * whose owner shares them, SPEC §7.8). It may own kinds as well, as Leaf owns * documents: the `k` tags say what it owns, this says what it renders. * See {@link ASPECTS}. * * What it is read for today: the bare file (§6.7) has no owner and so no * `web` template of its own, and the app that renders its conversation is * where a link to it should land. Nothing in this package consults * `document` yet; Leaf reads its own role. */ aspect?: string; /** * What this app calls a file nobody owns when it opens one, lower case and * singular: Peek's is "topic" (PRO-24). Read only beside `aspect`, from the * same manifest a bare file's link comes from, and resolved onto that bare * file's {@link ForeignObject.noun}. * * The opener's word and not the protocol's, Miky's call on 2026-10-02: a * company running its own conversation app calls these whatever its people * call them, and the specification never has to pick. */ fileNoun?: string; } /** * The aspects a generic app may declare — RFC 0.5 §10.2's three parts of a * file, minus `properties`, which is what a *type* adds and so what a * specialized app owns rather than something a generic one renders. */ export declare const ASPECTS: readonly ["conversation", "document"]; export type Aspect = (typeof ASPECTS)[number]; /** * NIP-29 group metadata — the relay's own record for a channel. * * Named here because {@link folderOf} keys on it, and a bare `39000` in that * one comparison would read as an app's kind rather than as the relay's. */ export declare const KIND_CHANNEL_METADATA = 39000; /** * Which Folder an object belongs to — both spellings, in one place. * * **This lived in Peek and had to move** (PRO-18). Its comment there said so: * reading only `h` made five of Ship's fifteen projects unactionable, because * a record published globally names its Folder with `buzz-channel` instead, * and the fix "does belong one layer down in the runtime, where every consumer * would get it rather than each discovering it". This is that layer. Both tags * are the relay's and NIP-29's, so knowing them is not knowing what any app * is. * * **0.13.0 had a third case as manifest vocabulary and 0.14.0 withdrew it.** A * manifest could declare `records.folder: "identifier"`, meaning *my objects * are containers, so the Folder is the object's own `d`*. The withdrawal was * right and stands: its only instance was a Peek topic, RFC 0.5 §1 retires * that shape, and a vocabulary field whose only instance is going away is one * every future producer has to read and none can use. * * **What the withdrawal took with it was a working feature, and nothing * noticed because nothing had declared the field yet** (INT-9). A channel * record names no Folder by either tag above, so every write aimed at one is * refused for having nowhere to go — which is why Peek can declare no action on * a topic, four days after PRO-18 concluded a conversation had become * declarable. Measured on production 2026-09-08: **0 of 40 `kind:39000` events * carry `h` or `buzz-channel`, and 40 of 40 carry `d`.** * * So the third case comes back **as protocol rather than as vocabulary**, which * is the distinction 0.14.0 was actually about. A `kind:39000` is NIP-29 group * metadata, relay-signed, and its `d` *is* the channel id — buzz's `NOSTR.md` * §"Group metadata" states it, `channel_info_from_event` reads it, and the * relay's `h_grammar` is the same uuid. Nobody declares that and no producer * can opt out of it, exactly like `h` and `buzz-channel` above. It asks nothing * of any manifest, so it cannot be the field that every future producer reads * and none can use. * * **It is the last of the three to fire, and it goes quiet on its own.** FOL-3 * makes a topic a file that carries an `h`; from that day the first clause * answers and this one is unreachable. A shape retired by a model change should * decay into dead code, not into a wrong answer — which is what the `??` order * buys and what the "still resolves after FOL-3" test pins. * * Narrow on purpose: **`kind:39000` and nothing else.** Not "any addressable * kind", which would hand every `30000`–`39999` record its own `d` as a Folder * and put a consumer's write in a channel that may not exist. An addressable * kind that is not the relay's channel record still answers `null` below, and a * test holds that line. * * Returns `null` when nothing says — which is a real answer. An object with no * Folder has nowhere for a write to go, and guessing at one publishes into * somebody else's channel. */ export declare function folderOf(root: SignedEvent): string | null; /** What {@link resolveManifest} answers with. */ export interface ResolvedManifest { manifest: Manifest; address: string; viaRecommendation: boolean; /** * NIP-89 `web` template, `` not yet substituted. * * The owning app's, for every kind that has an owner. For the bare file it * is the template of the app that renders its *conversation* — the one * place a link to an ownerless file can honestly land (FOL-17). Absent when * no app has declared that aspect. */ webTemplate?: string; } /** * The bare file — `kind:30840`, SPEC §6.7, decided in RFC 0.5 §10.7. * * A file **no app owns**: a `d`, a `title`, the `h` of the Folder it lives in, at * most one `a` naming the file it sits under, and a §13.3 block document (or * nothing) for content. A Peek topic is one. Typed kinds — a project, an issue * — add properties on top of this; the bare file adds none, which is exactly * why no app may claim it: NIP-89 ownership is keyed by kind, and an app that * owned the bare file would own every subject nobody has built an app for. * * So its projection lives **here, in the runtime, and not on the relay.** A * consumer resolving a `30840` gets this manifest before NIP-89 is consulted * at all — no `kind:31990` anywhere can override it. That is the test the * ticket names: remove every published manifest and a bare file still * resolves, lists its comments, and names its parent. * * The one thing the relay *is* asked is where to open it (FOL-17). An * ownerless file has no `web` template of its own, so the link goes to the * app that has declared it renders every file's conversation — Peek — read * off its manifest's `aspect` field by {@link resolveAspectApp}. With no such * manifest published the file still resolves; it just has nowhere to open. * * Its conversation is `kind:1111` anchored at its address, the shape a Ship * issue's comments already have, so `commentKindsOf` needs no special case. * Its changes are the ecosystem's `kind:1851` with the same fold rule Ship * declares, so a rename or a re-parent by somebody other than the author * folds the way an issue's `project` field does — a root tag seeds the value, * a change event overrides it. * * What a person reads on it comes from that app too (PRO-24). Its `appName` is * the opener's name, because that is where its link goes — "Open in File" told * nobody anything. Its `noun` is the opener's `fileNoun`, so Peek's topic says * "topic". `name` below is "File" only for the file nothing can open, where a * consumer has no app to print. */ export declare const KIND_BARE_FILE = 30840; /** * The tag a move carries naming its new parent — SPEC §7.2 (FOL-45). * * A move is a change to the field a list declares as `movedBy`, and its new * parent is the change's `value`, which no relay indexes. So a move also * carries `["A", ]`, after `ts`, and a list reads * `{ kinds: [changeKind], "#A": [parent] }` to find the children moved in. * * Uppercase because a lowercase `a` is the change's target to every reader — * and Folder listings match *every* `a` on a change, so a second one would * fold the move into the parent itself. Only on a change, never read without a * `kinds` filter, and only ever a hint: the child's own fold decides. */ export declare const MOVED_TO_TAG = "A"; /** * The field that archives a file, whatever its kind — FOL-46. * * **One concept for every file.** A Ship project, a Ship issue, a Peek topic, a * bare file and a Folder are all archived the same way: a change setting * `archived` to `'true'` on the file's address, and an empty value to restore * it. That is the shape Ship has written for projects since SPEC §6.5, so * nothing on the relay needs migrating. It is a change, never a deletion, so it * is reversible, attributed, and open to anyone who may write in the Folder — * not only the author. */ export declare const ARCHIVED_FIELD = "archived"; /** * The field, and the root tag seeding it, that holds a file's one-line summary, * whatever its kind — SPEC §6.12. * * Plain text, one or two sentences, beside the title: what a card, a row or a * picker shows under it. Changed by a `kind:1851` like the title, last write * wins, and a value that is blank after trimming means no summary. Every file * kind's `subtitle` reads it ({@link defaultSubtitle}), so no manifest has to. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-files-summary-is-its-own-field.md */ export declare const SUMMARY_FIELD = "summary"; /** * Where a `ResolvedManifest.address` would name the manifest event, this names * the section of the specification the manifest is built from. Not an address, * and deliberately not shaped like one — a consumer that tried to fetch it * should fail loudly rather than find something. */ export declare const BARE_FILE_MANIFEST_ADDRESS = "spec:6.7"; /** * What a Folder's built-in actions are declared by — SPEC §3.2, as * {@link BARE_FILE_MANIFEST_ADDRESS} is for the bare file. The * `declaredBy.address` of an action no app declared. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/folders-belong-to-the-protocol.md */ export declare const FOLDER_ACTIONS_ADDRESS = "spec:3.2"; /** * What a {@link ProjectionCache} entry holds: one app's manifest, or none — or, * for {@link creatorsOn}, every manifest declaring a creation on one kind * (MAN-8). Still only manifests, which is the whole of the cache's promise. */ type CachedManifests = ResolvedManifest | null | readonly ResolvedManifest[]; /** * A memo for the half of a resolve that does not change between refreshes. * * Resolving one reference costs four round trips, and **two of them are NIP-89 * discovery** — the author's `kind:31989` recommendation, then the `kind:31990` * manifest itself. A consumer that re-resolves on a timer pays for both every * time, and they answer the same thing until an app republishes its manifest. * Measured on Ship, where a reference widget re-resolves on the poll: 4 * requests per reference per tick, identical on the second resolve, against a * relay that meters reads at 300 a minute. * * **Owned by the caller, not this module.** A module-level cache would be * invisible global state shared by every consumer in the process, impossible to * scope to a screen and awkward to reset in a test. A caller that wants no * caching passes nothing and gets exactly the old behaviour. * * Deliberately only the manifest. The object, its changes, its comments and its * children are the parts a refresh exists to notice, and caching those is how * a live widget becomes a screenshot. */ export interface ProjectionCache { /** Forget everything. Worth calling after publishing a manifest. */ clear(): void; /** @internal */ lookup(key: string, now: number): { value: CachedManifests; } | undefined; /** @internal */ remember(key: string, value: CachedManifests, now: number): void; /** * @internal Run `read` for `key` unless a read for it is already running, in * which case join that one; remember the answer as `remember` would. Optional * so a hand-written cache still satisfies the interface — without it, a burst * of cold resolves asks once each, which is the old behaviour. */ share?(key: string, now: number, read: () => Promise): Promise; } /** * How long a manifest may be believed without asking again. * * Five minutes is a compromise with one real cost: republish a manifest and * consumers keep drawing the old projection for up to that long. That is * recoverable and self-correcting, where the alternative — asking twice per * reference per tick, for ever — is neither. */ export declare const MANIFEST_TTL_MS: number; /** * ## One read per manifest, however many resolves ask at once (PER-8) * * The memo only helps a resolve that starts after another has *finished*. A * consumer that resolves a set together — Peek's Screener does * `Promise.all(files.map(resolveObject))` every minute — starts every one of * them against the same cold entry, so each fetched the recommendation and the * manifest itself, and did it again every time the entry expired. Measured on * production: 133 of an idle tab's 385 requests in five minutes were manifest * lookups, against a meter that refused 74 of them. * * So a read in flight is shared: the second asker for a key joins the first * one's promise. A failure is shared the same way and **not** remembered — the * burst that caused it costs one request instead of one each, and the next * resolve asks afresh. Remembering it would blank every reference to that app * for the whole window over what may have been one refused request. */ export declare function createProjectionCache(ttlMs?: number): ProjectionCache; /** * A negative answer is cached too. * * "No app claims this kind" costs the same two round trips as a hit and is just * as stable. Caching only successes would leave the expensive case — a * reference nothing can draw — paying full price on every tick for ever. */ export declare function resolveManifest(pointer: AddressPointer, query: QueryFn, cache?: ProjectionCache): Promise; /** * The manifest to build an action with — the object's own, or the one named by * the action's {@link ResolvedAction.declaredBy} (MAN-8). * * `declaredBy` omitted, or naming the owner, is exactly {@link resolveManifest}. * Otherwise the answer is that manifest **only if resolving the object would * have offered the action from it** — the same sweep, the same filtering — and * null if not. So a caller cannot act on a Folder with an arbitrary manifest by * naming its address; it can only act with one this module found declaring a * creation there. The manifest returned holds only those creations. */ export declare function resolveActingManifest(pointer: AddressPointer, declaredBy: string | undefined, query: QueryFn, cache?: ProjectionCache): Promise; /** * SPEC §13.4's content format — **re-exported from `@estiva-app/protocol`.** * * These were defined here first, because the projection layer is what needed * them: a slot has to tell a consumer which model its value is in. Reading a * tag off an event turned out to be a question about the *event* rather than * about the slot, and two folds outside this layer now need it — Ship's and the * agent's, one fold in two repositories held to a single recorded state. Making * either depend on the projection layer to read a tag is the wrong direction, * and a second copy of `'estiva-blocks-1'` is what the protocol package exists * to prevent. * * So the definitions moved down and this re-exports them. **Nothing this * package published has changed** — same names, same values, same behaviour. */ export { type ContentFormat, CONTENT_FORMAT_TAG, BLOCK_DOCUMENT_FORMAT, contentFormatOf, } from '@estiva-app/protocol'; import { type ContentFormat } from '@estiva-app/protocol'; /** * The one slot in SPEC §7.2's closed set that means "structured content". * * Stays here: a *slot* is this layer's subject, and the protocol package has no * opinion about which of them carries a body. * * Named rather than inlined because the runtime keys behaviour on it twice — * whether a value carries a content format, and whether it may be truncated — * and a consumer reads `slots.body` to find it. */ export declare const BODY_SLOT = "body"; /** A file that is archived: who did it, when, and what they said about it. */ export interface Archive { /** Who wrote the archiving change. */ by: string; /** Its `created_at`, in seconds. */ at: number; /** * The resolution summary, when the person archiving gave one — the archiving * change's `content`. One event, so the summary can never land without the * archive or outlive the unarchive that replaces it. */ resolution?: string; /** * Present when the file is not archived itself but is hidden **with** an * archived ancestor: that ancestor's address — the project an issue belongs * to, the file a sub-topic sits under, the record a channel belongs to. * * Computed when reading and never written to the file (FOL-46): unarchiving * the ancestor restores exactly what was there before, and archiving a * project with two hundred issues is one event rather than two hundred. */ with?: string; } /** * Whether a file — or a Folder — is archived, itself or with an ancestor. * * Reads the {@link Archive} a resolver put on it. A resolver leaves archived * files out of a Folder's contents, so this matters where a file is reached * directly: a link, a mention, a page opened by address. */ export declare function isArchived(file: { archived?: Archive; } | null | undefined): boolean; /** * The first archived ancestor of `address`, walking `parentOf`, with its archive. * * SPEC §6.5's rule for a file archived with something above it, for an app * that folds its own files: the closest archived ancestor, with `with` its * address, so every app names the same thing to unarchive (PEEKM-2). A file * read through {@link resolveForeignObject}'s `archivedWith` is marked by the * same rule. `archiveAt` gives a file's own archive only. * * Stops at a cycle rather than looping: `parentRef` is written by people, and * two files each moved under the other is a state the relay accepts. */ export declare function archivedAncestorOf(address: string, parentOf: (address: string) => string | undefined, archiveAt: (address: string) => Archive | undefined): Archive | undefined; /** * Which layout to draw, from a declared type or an ordered chain of them. * * RFC 0.4 §13.3. A widget is a layout *hint* rather than a semantic, so an * unknown one can degrade honestly: `["message", "card"]` means *render me as a * message if you know it, else as a card*, and a chain must terminate in a type * the spec closes. * * **It lives here rather than in each consumer** because two implementations * would disagree the first time a chain had three entries, and the whole point * of the chain is that producers and consumers upgrade at different times. The * consumer supplies what it implements; the runtime does the walking. * * `fallback` is what to draw when the chain runs out — never "nothing". §13.3's * argument is that a blank object is indistinguishable from one the reader may * not be allowed to see, and reports "that app is broken" about an app doing * exactly what it was told. */ /** * The widget types RFC 0.4 §13.3 closes. A chain MUST end in one of these. * * Exported because both halves need the same list and they must not drift: a * producer checks its chain terminates here, a consumer's fallback is drawn * from here. Two copies would disagree the first time the set grew, and the * disagreement would show up as an object rendering blank in one app only. */ export declare const CLOSED_WIDGETS: readonly ["card", "row", "table", "stat"]; /** * Why a widget declaration is not publishable, or null when it is — PRO-3. * * **The producer half of the fallback chain.** `pickWidget` below makes a * consumer safe against a chain it does not fully understand; this stops the * unrenderable chain being published in the first place. Both are needed and * they fail differently: without the consumer half an unknown widget renders * blank, and without this one a *conformant* consumer renders blank through no * fault of its own, having done exactly what it was told. * * A chain that does not terminate in a closed type is the PEE-10 failure with * a longer fuse — every consumer that has not heard of `profile` walks * `["profile"]` to the end and has nothing left to draw. * * Returns a sentence rather than a boolean because this is read by a person * publishing a manifest, and "invalid widget" tells them nothing about which * one or what to do. */ export declare function widgetChainProblem(declared: unknown): string | null; /** * The shortest `description` that could plausibly distinguish one action from * another. * * A proxy, and a weak one — forty characters of nonsense passes. It is here * because the failure it does catch is the common one: a label pasted into the * description field, or a two-word caption where a sentence was wanted. Lifted * from Ship, which has enforced exactly this privately since PRO-5. */ export declare const MIN_ACTION_DESCRIPTION = 40; /** * Why an action is not worth publishing yet, or an empty list when it is. * * **The producer half of action selection**, and the same division of labour * {@link widgetChainProblem} draws for widgets: a consumer must stay safe * against a declaration it did not expect, *and* the unusable declaration * should not be signed in the first place. Both are needed and they fail * differently — a consumer alone cannot tell a bad description from a bad * match, and a producer alone cannot know what a consumer required. * * The failure is silent, which is the whole reason this exists. Peek's launcher * matches an action from a conversation on `description` (INT-5) and gates on * `effect`; an app whose description restates its label is not rejected * anywhere — its actions are simply never the one chosen, and nothing tells * anybody. There is no error to find. * * **Advisory, never fatal, and nothing in resolution calls it.** A weak * description is a worse match, not an invalid manifest — refusing to render an * app over its prose would be the objection that rules out iframes, wearing a * different hat. It is exported so a producer can run it in its own suite, the * way Ship does. * * Returns sentences rather than codes, for {@link widgetChainProblem}'s reason: * this is read by a person publishing a manifest, and "invalid description" * tells them neither which action nor what to write instead. * * A list rather than the first problem, because an action carries three * independent declarations and fixing them one round-trip at a time is a poor * trade for a producer. At most one is reported per declaration: a description * that restates its label is told that, not also that it is short. */ export declare function actionProblems(declared: unknown): string[]; export declare function pickWidget(declared: string | string[], implemented: readonly T[], fallback: T): T; /** A slot, resolved to something a renderer can put on screen. */ export interface ResolvedSlot { label?: string; value: string; /** Set when the value came from a vocabulary — the consumer picks the colour. */ colour?: string; /** True when the value is a pubkey and should be shown as a person. */ isPubkey?: boolean; /** * The content model this value is written in — SPEC §13.4. * * Set only for a slot whose source can carry a body (`{field: "content"}`, or * a `fold` a change event has actually set). **Undefined means the value is * not a body at all** — a tag, a seed, a default — not that it is marker * text. `'marker'` is what "a body with no declared format" resolves to, and * that is permanent rather than a migration state. * * A consumer rendering `slots.body` must branch on this. Parsing a block * document as marker text, or the reverse, is forbidden outright by §13. */ format?: ContentFormat; /** * The underlying field this slot reads, when it has a name. * * Carried so a renderer can tell that a slot and an action are two views of * *one* value — a status label beside a status picker is the same fact twice * (PEEK-18). Matching on the field is what makes that check work for any app: * the slot key (`status`) and the action's label ("Change status") are both * free-form, but a `ResolvedAction.field` and this always name the same thing * because the manifest wrote them both. * * Undefined for a slot with no named source — `{field: "content"}` reads the * event body, which no action can set. */ field?: string; } /** Everything the frontend needs to draw the widget. */ export interface ForeignObject { /** * A stable unique handle for this object, whatever kind of thing it is. * * **Added by PRO-7, and it exists because not every object has an address.** * A replaceable record is identified by `kind:pubkey:d`; a regular event — * Peek's `kind:9` message is the case in hand — has no `d` at all and is * identified only by its event id. Before this, `address` was required and * doubled as the React key, the error-map key and the `data-` attribute, so * a non-addressable object could not be represented at all. * * Use this for identity. Use `naddr` only where an *address* is genuinely * required, which in practice means acting on an object. */ ref: string; /** * The address, `kind:pubkey:d` — **only for an addressable object.** * * Undefined for a regular event. See `ref`. */ address?: string; /** * The address as `naddr1…`, **only for an addressable object.** * * Carried alongside `address` because acting on an object is addressed by * naddr (`act.ts`), and the sidebar builds its objects rather than being * handed a reference somebody pasted. Without it every control in a * sidebar card would have to re-encode what the server already knows. * * Its absence is meaningful rather than a gap: an action emits a change * carrying an `a` tag, and there is nothing for that tag to point at on a * non-addressable object. A consumer that has no `naddr` correctly offers no * actions. */ naddr?: string; /** The event id — set for every object, and the only handle a regular event has. */ eventId: string; /** * Child objects from a `list` slot, each resolved through its own projection. * * Empty rather than absent when the slot is declared and nothing matched, so * a renderer can tell "this holds nothing" from "this holds no list". */ children?: ForeignObject[]; /** * Whether the owning app declares a child list **and** says how to draw it. * * For a consumer deciding whether to offer an expander. `children` answers a * different question: it is absent until something has actually fetched them, * so a folder listing — which deliberately does not, see * {@link resolveFolderContents} — cannot use it to tell "has issues" from * "nobody asked yet". Without this the only options were a disclosure on * every row, including a Peek topic that can never have one, or none at all. * * Note what it is *not*: a promise that children exist. A project with no * issues still declares the list, and expanding it is the honest empty answer * rather than a control that was wrong to offer. */ listsChildren?: boolean; /** * The file this one names as its parent, when its app declares the relation. * * **For a listing that draws nesting.** A folder read by containment lists an * app's records by `h`, and Ship writes `h` on an issue as well as on its * project — so the project and its issues arrive as peers. Drawing the * project's children under it then shows every issue twice: once nested and * once at the top level. A consumer cannot tell which files are children, * because the tag that says so is named by the *parent kind's* projection and * never reaches the consumer. * * So this is that tag, resolved: the address in the child's own `via` tag. * It is set whether or not the parent is in the same listing — whether to * nest, hide, or label "in " is the consumer's decision, and the two * cases genuinely differ. * * Absent for a file whose app declares no such relation, and for a `match: * 'identifier'` list, where the child names only the parent's `d` and an * address cannot be built from it without inventing a pubkey. */ parentRef?: string; /** * The folded field that re-parents this object, when its owner declares one * — SPEC §7.2's `movedBy`: `parent` on a bare file (§6.7), `project` on an * issue whose app declares it (MAN-1). Hard-coded to the bare file before * 0.36.0. * * **For a consumer that draws field-setting actions as property rows.** The * action setting this field is a move, and its value is an address: drawn as * a text box under a card it asks a person to type `30840::`. It * is the tree's control, offered where the tree is — the same step aside the * title's rename takes, matched on the field for the same reason. */ parentField?: string; /** * The kind a move may name — the kind whose projection declares the * `movedBy` (MAN-1): `30850` for a Ship issue, so a move control offers * projects and not other issues. Absent with `parentField` on a bare file, * or a kind declared with `anyParent` (a document), which may sit under any * kind (§6.7). {@link nestingOf}'s `moveTargetsOf` * reads it. */ parentKind?: number; kind: number; /** * The layout hint the owner declared — **a type or an ordered chain of them.** * * `["message", "card"]` means *render me as a message if you know it, else as * a card*, and a chain MUST terminate in a type RFC 0.4 §13.3 closes * (`card`/`row`/`table`/`stat`). A bare string is the older form and is a * chain of one. * * **This was typed `string` until PRO-7**, while Peek's own published manifest * declares `["message","card"]` — so the type said one thing and the wire said * another, and a consumer writing `widget === 'card'` compared a string to an * array and silently drew nothing. Found by Ship, the second consumer, the * first time anything typechecked a renderer against a real chain. That is * the answer to PRO-1's "what did the second consumer force to change". */ widget: string | string[]; appName?: string; /** * What the owning app calls one of these — "issue", "project" — read off * its projection's `noun`, lower case and singular (PEE-41). * * For a consumer that names the object in its own words: a menu's "Delete * issue", a confirm's "Delete this project?". Without it Peek called a Ship * issue a topic, because the only way to know better was to check the kind * number, which is the check a manifest exists to replace. Absent when the * owner declares none. A bare file's is the `fileNoun` of the app that * opens it (PRO-24), and absent when that app declares none. */ noun?: string; /** Named single slots: title, subtitle, status. */ slots: Record; /** Repeating slots, e.g. `meta`. */ meta: ResolvedSlot[]; comments: { id: string; author: string; body: string; createdAt: number; }[]; /** * The Folder this object lives in: the root's `h`, or `buzz-channel` on a * record that names its Folder that way, as Ship's projects do * ({@link folderOf}, SPEC §7.3). * * Needed to *write*: a change event carries it as `h` (SPEC §6.10), and * without it the relay rejects the write outright. Reading it off the object * rather than asking the user is the difference between an action that works * and a form with a "Folder id" box in it. Until LTX-4 it read `h` only, so a * Ship project had none and no other app could save or comment on it. */ folder?: string; /** * The channel that gates who can read the root itself: its `h`, and absent * for a record stored globally (a `buzz-channel` project, whose name anyone * can read). Not where writes go — that is {@link folder}. A move into a * private Folder reads this (`planMoveFile`), because a listing never changes * who can read a file (SPEC §3.1). */ scopedTo?: string; /** * Where to open this object in the app that owns it, from NIP-89's `web` tag. * * The return leg of the roundtrip. Without it a reference is a read-only * snapshot: Peek can render a Linear-lite issue and change its status, and * getting back to the issue itself means alt-tabbing and hunting for a row. * * Absent when the owning app published no template — the widget simply does * not offer the link, rather than guessing a URL. */ openUrl?: string; /** What the owning app says we may do to this object. */ actions: ResolvedAction[]; /** * Names and faces for every pubkey this object shows (FEE-1). * * Carried on the object rather than looked up by the renderer so a widget is * still a pure render of what it was handed — the same property that lets * Storybook draw a real person from a fixture. Empty when nothing published a * profile, which the renderer treats as "someone we cannot name" rather than * falling back to the key. */ people?: People; /** False when no author recommendation existed and a handler was guessed. */ viaRecommendation: boolean; /** * Set when the manifest resolved but the object itself did not. * * Almost always a permission problem rather than a missing object, and worth * distinguishing because the relay makes them look identical. Objects and * change events are channel-scoped, so a reader who is not a member of the * Folder gets zero rows — no error, just silence. The manifest and the * recommendation are global kinds and resolve fine without membership, so the * failure lands late and presents as a broken reference. Telling the user * "you may not have access to this Folder" is a far better guess than * rendering nothing. */ unreachable?: boolean; /** * Present when the file is archived, itself or with an ancestor (FOL-46). * * A Folder's contents leave archived files out, so this is set where a file * is reached by address: its page draws an Archived banner with the * resolution and an Unarchive control rather than drawing it as live work. * See {@link Archive} and {@link isArchived}. */ archived?: Archive; /** * When this file last changed, in seconds: the newest of its root's * `created_at` and every folded field's winning change (5ebf2756). * * For a card that lists some of a file's children rather than all of them — * a project's most recently changed issues. Its comments are not changes to * it. A regular event, which nothing changes by address, has its own * `created_at`. Absent on an unreachable stub, which has no root to read. */ changedAt?: number; } /** * Resolve a `nostr:naddr…` into a renderable widget. * * Returns `null` rather than throwing when the object cannot be rendered — an * unresolvable reference in a chat message should degrade to plain text, not * break the message around it. */ /** * Resolve one event by id — `nevent1…`, or a bare 64-hex id. * * The counterpart to `resolveForeignObject`, and the reason it has to exist: * **not every object has an address.** A `kind:9` message carries no `d`, so * `(kind, pubkey, d)` cannot be built for it and every resolver keyed on an * address is blind to it. Measured on production during PRO-6; PRO-11 is this. * * ## What it does *not* do, and why the function is short * * A regular event is immutable and has no folded state, so there is no `records` * rule to apply, no change events to fetch, and no "current value" that differs * from what is on the event. It also cannot be the target of an `a` tag, so it * has no comments addressed to it and **no actions** — a change names its target * by address, and there is nothing here to name. That absence is the model being * honest rather than a gap to fill later. * * ## Two round trips, and the order depends on the reference * * A manifest is found by kind. An `nevent` *may* carry its kind, and when it * does the manifest and the event can be fetched together. When it does not — * a bare id, which is what a pasted `e` tag gives you — the event has to be * read first to learn what kind it is. Both paths are supported because both * arrive in practice, and a resolver that required the richer form would refuse * references other clients legitimately produce. */ export declare function resolveForeignEvent(reference: string, query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */ cache?: ProjectionCache): Promise; export declare function resolveForeignObject(naddr: string, query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** * How many `list` levels have already been followed. Callers outside this * module leave it at 0; it is the budget that stops a child's own `list` * recursing forever. See `MAX_LIST_DEPTH`. */ depth?: number, /** * Optional memo for the NIP-89 discovery half. See {@link ProjectionCache} — * omitting it is exactly the old behaviour. */ cache?: ProjectionCache, options?: ArchiveReadOptions): Promise; /** * Many addressed objects in one round trip — PER-10. * * {@link resolveForeignObject} costs one request per object once the manifest * is cached, and a consumer holding a set re-reads the whole set on every * refresh: Peek's Screener asked once per followed file, every minute, and an * idle Desk spent 144 of 194 requests in five minutes on exactly that, against * a relay that meters 300 a minute per person across every tab. This reads the * same objects with the same filters, **all of them in one POST** — chunked at * `MAX_FILTERS_PER_QUERY` the way {@link conversationsOf} is, since each object * asks up to four filters — then one people lookup for the whole set. * * So a refresh is `ceil(filters / 128)` requests and a people lookup, where it * was one request per object and a lookup each: two for any set of up to 32 * objects, whatever their number. * * ## Same filters, same objects * * Each object's filters are exactly the ones {@link resolveForeignObject} * sends, so each keeps its own `limit` — one busy file cannot starve another's * comments, which is why this is not one filter naming every address. The * answers come back pooled, and every object is picked back out of the pool on * its filters' own criteria, the rule `childrenFrom` already follows for the * children that share a request with their parent. The result is keyed by the * reference as given, and each object is what `resolveForeignObject` returns * for it. * * A reference that is not an address, or whose app has no manifest or no * projection for its kind, is `null` — as it is from the single form. An event * reference (`nevent`, a bare id) is not an address and is `null` here; that * is {@link resolveForeignEvent}'s. * * **A failed read rejects the whole call**, as the single form throws. There is * no partial answer to give: the objects arrive pooled, so a chunk that failed * could have held any of them. */ export declare function resolveForeignObjects(references: readonly string[], query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** See {@link ProjectionCache}. Omitting it still reads each app's manifest once per call. */ cache?: ProjectionCache, options?: ArchiveReadOptions): Promise>; /** * Every object in a set of events the caller already holds, projected — PER-21. * **No requests.** * * {@link resolveForeignObjects} reads each object's own filters, which is the * right price for a sidebar holding a few dozen and the wrong one for a reader * that wants a whole app's workspace: interop's own measurement put 110 issues * at 20 POSTs and a megabyte (FOL-50), and estiva-agent re-reads its workspace * on every command. A reader like that can fetch the roots and the app's change * kind in bulk — a few paged queries — and hand them here. * * Each root of a kind the manifest projects becomes exactly the object * `resolveForeignObject` builds for it: the same fold (`records`, §6.2–6.3), * the same archive read, the same slots, meta, parent and actions. What is * deliberately absent is everything that would need another read: * * - **`comments` is `[]`.** A comment is a separate event in a Folder, and * which comments the caller fetched says nothing about which it did not. * Read conversations with {@link conversationsOf} and {@link threadsOf}. * - **No `children`.** A `list` slot is containment, and the children are * other objects in the same answer: nest them by `parentRef`. * - **No `people`, and no borrowed actions** (MAN-8) — both are reads. * * - **No archive inherited from a parent.** `archived` is the object's own * change; `resolveForeignObjects`'s `archivedWith` reads the parents' too, * and here the parent is simply another object in the answer, whose * `archived` the consumer can read by `parentRef`. * * Two versions of one root (a replay, two relays) collapse by NIP-01's rule, * as the relay's own replacement does: later `created_at`, then the lower id. * Objects come back in no particular order; sort them as the consumer draws. * * Linear in the events: the changes are grouped by target once, so a * workspace of hundreds of roots does not rescan thousands of changes per root. */ export declare function projectEvents(events: readonly SignedEvent[], /** * The manifest each root is read through, **per author** — what * {@link resolveManifest} answered for that root's `kind:pubkey`, from the * caller's own resolution (a {@link ProjectionCache} keeps it to one read per * author). Per author, not one for all, because that is the trust anchor * `resolveForeignObject` keeps: an object is drawn by the app *its author* * recommended, and `viaRecommendation` says whether they did. A root this * answers `undefined` for, or whose kind its manifest does not project, is * not an object here. */ manifestFor: (pointer: AddressPointer) => ResolvedManifest | undefined): ForeignObject[]; /** * The objects at a set of addresses, as their roots and changes alone draw * them — PEE-21. * * For a reader that needs what a file *is* now (its folded title, status, * parent, own archive) and not what is said about it: {@link projectEvents} * over each address's root and change filters — the same two * {@link resolveForeignObjects} sends — without the comments, children and * people that make up most of that call's bytes. `[` reads a search answer's * hits through this on every pause in typing. * * An address whose root the reader is not handed is simply absent, so a file * in a Folder they cannot read never comes back. A failed read rejects. */ export declare function resolveForeignRoots(references: readonly string[], query: QueryFn, cache?: ProjectionCache): Promise; /** * The parent each file sits under, and nothing else — FOL-50. * * {@link resolveForeignObjects} answers this on the way to a whole object, and * for a caller that needs only the parent that is the wrong price: it reads * every object's comments and children too. Peek's Folder dots place an * issue's talk by its project, and on production on 2026-09-30 the 110 issues * the dots name cost 20 POSTs and a megabyte that way. This asks for the roots, * and for the changes only of a kind whose app declares a `movedBy` — a filter * per 100 roots and one per movable file, so a POST per ~120 files: one for the * set the dots held on production. * * Folded by the rule the card and the listing use (`parentRefOf`), so a moved * issue is under the project it was moved to here as everywhere else, and a * file is answered only when its app draws its kind — as it is by the object * readers. * * Keyed by the reference as given: the parent's address, or `null` for a file * at the top. **Absent** when there is nothing to tell — not an address, no * manifest, or a root this reader cannot see — because "at the top" and * "unknown" are different sentences. * * **A failed read rejects the whole call**, as {@link resolveForeignObjects} * does. */ export declare function resolveParents(references: readonly string[], query: QueryFn, /** See {@link ProjectionCache}. Omitting it still reads each app's manifest once per call. */ cache?: ProjectionCache): Promise>; /** What {@link resolveForeignObject} and {@link resolveForeignObjects} may read beyond the file itself. */ export interface ArchiveReadOptions { /** * Also read whether each file's parent is archived, and mark the file * archived **with** it (`archived.with`) — FOL-46. For the page a link opens, * which draws the banner; off by default because it is one more request * whenever a file has a parent, and a refresh tick that resolves an issue * card would pay it every time (SHI-13's one request per tick). */ archivedWith?: boolean; /** * For a Folder's card: also list the files in it this reader can open, as * `children` — SPEC §3.2. What its relay-signed `30890` lists, read as * {@link resolveFolderContents} reads it, so a file this reader cannot * resolve is absent and nothing counts the rest. Off by default, so a Folder * chip in a sentence pays nothing for a list it does not draw; ignored for * anything that is not a Folder. * * A Folder with no `30890` (a DM has none) and one whose listing cannot be * read still draw, without `children`: no list, rather than an empty one * that says it holds nothing. * * The resolve returns once the files have, so read a card's files in a call * of their own: what else is on the page should not wait for them. * * **It costs what a Folder's page costs**, almost all of it its files' * change history (a description edit is a whole `kind:1851`, and no relay * can filter one by its `field`). Estiva HQ on 2026-10-16, 24 files: 11 * requests and 7.6 MB cold, 3 and 7.6 MB warm, ~2 s — where the chip is 1 * request and nothing warm. Read it on open and focus, not on a refresh tick. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/folders-belong-to-the-protocol.md */ folderFiles?: boolean; } /** * A Folder's project and the tickets in motion in it. * * Both are ordinary `ForeignObject`s — the same shape an `naddr` in a message * resolves to, carrying the same slots, the same declared actions and the same * link back into the owning app. The sidebar therefore renders the owning app's * objects with the owning app's affordances rather than a reduced copy of them, * and a ticket is as actionable there as it is inline in a conversation. */ export interface FolderProject { project: ForeignObject; /** * Every ticket in the project, finished or not: started work first, then the * queue, then what is done. Archived ones are left out — an app hiding a * record from its own lists is saying it is no longer part of the project. */ tickets: ForeignObject[]; /** Tickets still to do. With `doneCount`, the total the panel counts against. */ openCount: number; /** Finished tickets — the numerator in the panel's "1/3". */ doneCount: number; } /** * Is a resolved `status` slot finished work — done or dropped — by its words? * * For a consumer holding only the object, not its manifest: the declared stage * is not on a {@link ResolvedSlot}, so this is the word-list fallback alone. * `[` uses it to put a closed issue after an open one (PEE-21). */ export declare function isClosedStatus(slot: ResolvedSlot | undefined): boolean; /** * The project living in a Folder, plus the work currently in motion in it. * * Starts from a **Folder** rather than an naddr, which is the difference * between this and `resolveForeignObject`: a Peek topic and a Linear-lite * project are one container (RFC_UPDATES.md §1.1), so a topic already knows * enough to ask "what project is this?" without anyone pasting a reference. * * Still knows nothing about Linear-lite. Which kind is a project, which kind is * an issue, how they link, how status folds and what it is called all come off * published manifests at runtime — the same discipline as the rest of this file. * * Returns null for "nothing to show": no manifest declares a container, no * project in this Folder and none named by the tickets in it, or the object * cannot be read. All of those are ordinary states the caller renders as an * empty sidebar, not errors. */ export declare function resolveFolderProject(folder: string, query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** * How many `list` levels have already been followed to get here. Callers * outside this module leave it at 0; it exists so that following a child's * own `list` is a budget check rather than a thing nobody remembered. */ depth?: number, /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */ cache?: ProjectionCache): Promise; /** What {@link buildActionEvent} and {@link buildActionEvents} are handed. */ export interface ActionEventArgs { manifest: { records?: RecordsRule; actions?: ManifestAction[]; vocabularies?: Manifest['vocabularies']; }; kind: number; /** Address of the object being acted on. */ address: string; /** Author of the object — needed for NIP-22's `P`/`p` tags. */ objectAuthor: string; folder: string; actionId: string; /** * A scalar for a change or a comment; `{ property: value }` for an * object-creating action, whose form has several fields. */ value: string | Record; /** * A fresh identifier for an object being created, when its kind is * parameterized-replaceable and therefore needs a `d`. * * Supplied rather than generated: ADR 0002 §10 constraint 2 — the runtime * reaches for nothing and is handed everything. It also makes the built event * a pure function of its inputs, which is what lets a test assert on one. */ newId?: string; /** * The comment being answered, when this comment is a reply (PRO-20). * * SPEC §6.4's table: the uppercase `A`/`K`/`P` still name the file, and the * lowercase trio names this comment — its id, its kind and its author — * instead of repeating the file. So a reply carries no lowercase `a` and is * absent from the file's `#a` read; {@link threadsOf} is the read that finds * it. `kind` is the parent's own, which for one of Ship's replies is `9`. * * Refused on any action that is not a comment: nothing else has a parent. * * `place` is the comment's own `block` and `quote` tags, when it is on part * of the file: the reply copies them, so its thread keeps its place once the * comment is deleted (SPEC §13.6, as conversation's `buildReply` does). * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-reply-on-a-page-keeps-its-threads-place.md */ replyTo?: { id: string; kind: number; author: string; place?: readonly string[][]; }; /** * The format the action's prose value is in, when it is not marker text — * `'estiva-blocks-1'` for a block document (SPEC §7.3's `format`, MAN-1). * * The value of a scalar change, or of a creation's `content` field. Only a * format the action declares for that value is accepted, and the value is * parsed before it is tagged. Omitted, the value is written untagged as * marker text, which is always correct. */ contentFormat?: string; /** * What the person said about a change, carried as its `content` — the * resolution summary of an archive (FOL-46), or any change's note. A change * only: a comment's text is its `value`, and a creation has its own body. */ note?: string; pubkey: string; createdAtMs: number; } /** * Build the event that performs a manifest-declared action — one event. * * **Refuses an action declaring `emits.listed`**, which is more than one event * (SPEC §7.3): building the root alone would publish an object its Folder does * not show, and report success. {@link buildActionEvents} builds those. */ export declare function buildActionEvent(args: ActionEventArgs): UnsignedActionEvent | string; /** * Every event an action publishes, **in publish order** — SPEC §7.3, MAN-1. * * The first is what {@link buildActionEvent} builds. An action declaring * `emits.listed` is followed by the Folder commands that keep its Folder's * listing true, planned by `planPlaceFile` / `planUnlistFile`: * * - a creation: `kind:1852 add` naming the new address in the Folder it was * placed in, when `folderHasState` — none when the Folder has no state, where * a command would hide everything else filed there; * - a deletion: `kind:1852 remove` from each of `listedIn` that has state. * * Both facts come from the caller's read of `kind:30890` and are required * rather than defaulted, because a guess here is silent loss. Publish in order * and stop at the first refusal: each event assumes the ones before it landed. */ export declare function buildActionEvents(args: ActionEventArgs & { /** For a `listed` creation: whether the Folder has state — from a read. */ folderHasState?: boolean; /** For a `listed` deletion: every Folder that lists the object, with `hasState` — from a read. */ listedIn?: readonly FolderRef[]; }): UnsignedActionEvent[] | string; /** * Test seam: resolve one projection's `title` slot against one event. * * Exported so the tag-fallback rules can be pinned without standing up a fake * relay. `resolveSlots` and `resolveSlot` are the real path; this only picks * the one slot out of them. */ export declare function resolveFolderProjectSlotsForTest(manifest: Manifest, root: SignedEvent): string | undefined; /** * The Folder as the relay maintains it — RFC 0.4 §12.1. * * Relay-signed, addressable, and **global**. §4.2 is worth reading before * changing that last word: three of the four properties a folder must have are * impossible if its state carries an `h`, because `h` files it under a channel * and gates reads by access. Exploring folders you are not in, following one, * and a file having one home while being referenced from elsewhere all need an * address that resolves without membership. So the channel keeps doing access * and conversation, and the folder becomes a layer above it. * * **Read-only here.** A client changes a folder by publishing a `kind:1852` * command and the relay emits the new state (§4.1). A folder is not a * user-signed event because NIP-01 addressable events are single-signer by * construction — the address is `(pubkey, kind, d)`, so a second person adding * a file would not replace your folder, they would create a different one. */ export declare const KIND_FOLDER_STATE = 30890; /** One folder, enough to draw a sidebar row. */ export interface FolderSummary { /** * The folder's uuid. **One uuid, three roles** — the channel id, the `h` on * everything in it, and the `d` on both its `39000` and its `30890` (§5.2, * measured across all of production's channels). */ id: string; /** * The title: its `kind:39000`'s `name`, and only that (SPEC §3.2). A `name` * an older `kind:1852` left on the state is never read — any member may send * one, and only owners and admins may rename the channel. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/only-owners-and-admins-rename-or-archive-a-folder.md */ name?: string; /** * True once the relay maintains state for it, rather than it being a bare channel. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-folder-is-a-group-the-relay-lists.md */ hasState: boolean; /** * Present when the Folder's relay-signed `kind:39000` carries `["private"]` * (NIP-29). What `planMoveFile` reads as `FolderRef.private`. */ private?: true; /** * Who may rename and archive it: the owners and admins on its relay-signed * `kind:39001`. Offer Archive only to them — a reader ignores an archive from * anybody else (§3.2). Absent when the relay returned no admin list. */ admins?: string[]; /** * The folders whose state lists this one as a file — present only when there * are any. * * A channel is addressable (§5.2), so a folder can be placed inside another * the way any file is, and Peek's topics are (FOL-22): each is a channel * listed by its Folder's state. A sidebar that drew every channel as a top-level * folder would show the Folder and, beside it, every topic in it. This is what * lets a consumer tell the two apart without reading any folder's contents: * `listFolders` already holds every state, so the answer is free. */ listedIn?: string[]; /** * The addresses this folder's state lists, in the state's order — present * only when there are any. * * `listedIn` in the other direction, and free for the same reason: the * states are already in hand. It is the unresolved list — a row that names * an archived or unreachable record is still here — so a consumer that * draws one app's own records under a folder (Ship's projects under a Folder, * FOL-5) intersects it with the records it holds, and a consumer that draws * another app's files resolves them with {@link resolveFolderContents}. */ addresses?: string[]; /** * The Folder's own address as a file — its `kind:39000`, `39000::`. * What a state lists when it holds this Folder, and what an `archived` * change on the Folder names. Absent only for a state whose channel the * relay did not return. */ channel?: string; /** * Present when the Folder is archived (FOL-46) — or, for a record's channel, * when the record it belongs to is (`with` names the record). See * {@link Archive}. * * {@link topLevelFolders} leaves an archived Folder out, and * {@link resolveFolderContents} leaves one out of the Folder that lists it, * so its subtree goes with it. What it holds stays readable: a link still * opens it, and a file it lists that another Folder also lists stays visible * there. */ archived?: Archive; } /** * The folders a person navigates by: the ones with state that no other folder * lists (FOL-5). * * Most channels a relay answers with are nobody's folder — an app's record * channels, the channel a record's conversation lives in, a test topic — and * each is reached through the object it belongs to. Two facts the summary * already carries decide it, with no further read: a `kind:30890` exists once * somebody has sent a folder command at the channel, which a record channel * never gets; and a folder another one lists (`listedIn`) is a row inside * that one, not a section beside it. * * Not a list of ids: a new Folder is a folder somebody creates, not a code * change. Moved here from Peek and Ship, which each held the same line. * * An archived Folder is not navigated by (FOL-46), and a listing that has not * read archives — a summary with no `archived` field — keeps every Folder. */ export declare function topLevelFolders>(folders: T[]): T[]; /** A folder and everything in it, each file drawn through its owner's manifest. */ export interface FolderContents extends FolderSummary { /** The folder state's address, absent until a state event exists. */ address?: string; /** * The files this folder holds. * * **Peers by construction, not by special case.** A Peek topic and a Ship * project sit side by side because both are `a` tags in one list, and §5.2's * finding is exactly that one tag type is enough to name either: a channel * turned out to be addressable after all, so a topic needs no wrapper and no * second mechanism. Nothing in this function knows what either app is. * * Ordered as the folder lists them. A file that resolved to nothing is * **absent rather than marked** — see {@link resolveFolderContents}. */ files: ForeignObject[]; /** * Where the list came from, because the two are not equivalent. * * `state` is the model. `channel` is the approximation available before a * folder has any state: containment inferred from `h`, which can list an * app's records and **can never list a topic**, because under `h` the topic * *is* the container rather than a thing inside it. A consumer that needs to * explain a short list is reading this field. */ source: 'state' | 'channel'; } /** * Every folder this identity can see, for a sidebar. * * Both shapes in one pass: folders the relay maintains state for, and bare * channels that have none yet. A channel with state appears once, titled by its * `kind:39000` (SPEC §3.2), with the owners and admins its `kind:39001` lists. * * **A direct route, deliberately.** §4.2's post-mortem on REW-11 is that * discovering children only through their parent loses them when the parent * goes; a sidebar built by walking something else would inherit exactly that. * This asks for folders by kind. */ export declare function listFolders(query: QueryFn): Promise; /** What archiving one file would hide, for the warning a person reads first — FOL-46. */ export interface ArchiveImpact { /** * The files that would go out of every list with it, counted by kind: its * children, and — for a Folder — every file only that Folder lists, with * their children. Files already archived are not counted; they are hidden * already. Kinds with no files are absent. */ hides: { kind: number; count: number; }[]; /** * Files a Folder lists that another Folder, not archived, lists too. They * stay visible there, and a warning that said they would be hidden would * make Archive look more destructive than it is. Empty for a file that is * not a Folder. */ staysVisible: { address: string; kind: number; in: { id: string; name?: string; }[]; }[]; } /** * Count what archiving `address` would hide — the read behind the warning. * * "Hides 14 issues and 6 topics that are only here; 2 topics also in Estiva HQ * stay visible." The words are the app's; the counts are the same in every * app, which is why they are read here. * * - **A Folder** (a `kind:39000` address, `FolderSummary.channel`): what it * lists, split by whether another Folder lists it too, the children of what * would be hidden, and — three Folders deep — what a nested Folder only it * lists holds. Pass `folders` from the app's own `listFolders` to save that * read. * - **Any other file**: the children its kind declares (a project's issues), and * bare files placed under it (sub-topics), by their root's `a` tag. * * One level of children, as a listing draws them. A few requests, whatever the * size: the Folder, one per app's manifest (memoised by `cache`), the children, * and their archives. */ export declare function archiveImpact(address: string, query: QueryFn, options?: { folders?: FolderSummary[]; cache?: ProjectionCache; }): Promise; /** * Everything in one folder, resolved through the manifests of the apps that own it. * * The cross-app read the whole model rests on: given a folder id, list its * files whatever kind they are and whoever wrote them, so two apps drawing the * same folder show the same things. Both apps consume this rather than either * one owning it. * * ## A file the reader cannot see is absent, not "unavailable" * * The one rule here that is a product decision rather than a mechanism, and a * deliberate divergence from upstream's NIP-MP, whose fold requires the * opposite for public repositories. **The count is the disclosure**: a folder * that renders three rows and two greyed-out placeholders has told an outsider * how much they are missing, which for a folder named after a person is the * sensitive part. So an address that resolves to nothing is dropped, and * nothing in the return value counts what was dropped. * * This is why the batch below cannot use {@link resolveForeignObject} * unchanged: that returns `unreachable: true` so an inline reference can say * "you may not have access", which is right for one pasted link and wrong for * a list. * * ## Round trips do not grow with the folder * * **Flat in the number of files, linear in the number of apps.** The folder * itself, the handler sweep, the contents, one query for every root at once * and every change with it, and one for the people — then two more per * distinct `(kind, author)` for NIP-89 discovery, which {@link ProjectionCache} * memoises away. * * Measured against production, reading three folders through one cache: * * | folder | files | cold | warm | * | --- | --- | --- | --- | * | Shared foundation packages | 24 | 11 | 5 | * | Feedback on Peek | 25 | 9 | 5 | * | Folders | 9 | 6 | 4 | * * Twenty-five files and nine cost the same warm, which is the property worth * having. A resolve per file would have been four *each* against a relay that * meters reads at 300 a minute — a folder of twenty-five would not have loaded. */ export declare function resolveFolderContents(folder: string, query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */ cache?: ProjectionCache, options?: { /** * Return archived files — and files hidden with an archived parent — each * carrying its {@link Archive}, instead of leaving them out. For a view * that lists what was archived, and for the listings a move plans from * (`planMoveFromFolders`): what is archived beneath a file moves with it. * Every other list wants the default. */ includeArchived?: boolean; /** * Only what the relay-signed `30890` lists: a Folder with none lists no * files, rather than the files that name it themselves. For a Folder's * card ({@link ArchiveReadOptions.folderFiles}). */ listedOnly?: boolean; }): Promise; //# sourceMappingURL=projection.d.ts.map