@estiva-app/interop 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/projection.ts CHANGED
@@ -240,25 +240,6 @@ interface RecordsRule {
240
240
  * which field and which value, and this stays out of it.
241
241
  */
242
242
  hiddenWhen?: { field: string; equals: string }
243
- /**
244
- * Where this app's objects say which Folder they belong to — PRO-18.
245
- *
246
- * Absent, and the answer is the tags: NIP-29's `h`, or the relay's own
247
- * `buzz-channel` for a record published *globally* while still naming a
248
- * Folder. {@link folderOf} reads both and always has to.
249
- *
250
- * `"identifier"` is the third case and the reason this field exists: an
251
- * object that **is** a container names no Folder because it is one, and its
252
- * `d` is that Folder's id. A Peek topic is the case in hand — a `kind:39000`
253
- * whose identifier is the channel — and without this an action performed on
254
- * one fails with "that object has no Folder, so there is nowhere to write",
255
- * which is true of the tags and false of the object.
256
- *
257
- * Declared rather than inferred from the kind. A consumer that special-cased
258
- * 39000 would know what Peek is, which is the one thing this layer may not
259
- * do; the owner says it, and the rule works for an app nobody here wrote.
260
- */
261
- folder?: 'identifier'
262
243
  }
263
244
 
264
245
  interface SlotSpec {
@@ -578,7 +559,7 @@ function resolveActions(
578
559
  const tagValue = (e: SignedEvent, name: string) => e.tags.find((t) => t[0] === name)?.[1]
579
560
 
580
561
  /**
581
- * Which Folder an object belongs to — the three spellings, in one place.
562
+ * Which Folder an object belongs to — both spellings, in one place.
582
563
  *
583
564
  * **This lived in Peek and had to move** (PRO-18). Its comment there said so:
584
565
  * reading only `h` made five of Ship's fifteen projects unactionable, because
@@ -588,22 +569,21 @@ const tagValue = (e: SignedEvent, name: string) => e.tags.find((t) => t[0] === n
588
569
  * are the relay's and NIP-29's, so knowing them is not knowing what any app
589
570
  * is.
590
571
  *
591
- * The third case is new and is the one an app must declare: an object that is
592
- * itself a container carries no Folder tag, because it *is* the Folder, and
593
- * its identifier is that Folder's id. See {@link RecordsRule.folder}.
594
- *
595
- * Returns `null` when nothing sayswhich is a real answer. An object with no
596
- * Folder has nowhere for a write to go, and guessing at one publishes into
572
+ * **0.13.0 had a third case and 0.14.0 withdrew it.** A manifest could declare
573
+ * `records.folder: "identifier"`, meaning *my objects are containers, so the
574
+ * Folder is the object's own `d`*. Its only instance was a Peek topic, and
575
+ * RFC 0.5 §1 retires that shape: a Folder holds several files of the same kind
576
+ * three topics and two projects so a topic becomes a file inside a Folder
577
+ * and carries an `h` like everything else. A vocabulary field whose only
578
+ * instance is going away is one every future producer has to read and none can
579
+ * use.
580
+ *
581
+ * Returns `null` when neither tag says — which is a real answer. An object with
582
+ * no Folder has nowhere for a write to go, and guessing at one publishes into
597
583
  * somebody else's channel.
598
584
  */
599
- export function folderOf(
600
- root: SignedEvent,
601
- records?: { folder?: 'identifier' },
602
- ): string | null {
603
- const tagged = tagValue(root, 'h') ?? tagValue(root, 'buzz-channel')
604
- if (tagged) return tagged
605
- if (records?.folder === 'identifier') return tagValue(root, 'd') ?? null
606
- return null
585
+ export function folderOf(root: SignedEvent): string | null {
586
+ return tagValue(root, 'h') ?? tagValue(root, 'buzz-channel') ?? null
607
587
  }
608
588
 
609
589
  /**
@@ -2818,3 +2798,354 @@ export function resolveFolderProjectSlotsForTest(
2818
2798
  if (!projection) return undefined
2819
2799
  return resolveSlots(projection, root, {}, manifest).slots.title?.value
2820
2800
  }
2801
+
2802
+ // ── The folder read model — RFC 0.4 §4 and §5 ───────────────────────────────
2803
+
2804
+ /**
2805
+ * The Folder as the relay maintains it — RFC 0.4 §12.1.
2806
+ *
2807
+ * Relay-signed, addressable, and **global**. §4.2 is worth reading before
2808
+ * changing that last word: three of the four properties a folder must have are
2809
+ * impossible if its state carries an `h`, because `h` files it under a channel
2810
+ * and gates reads by access. Exploring folders you are not in, following one,
2811
+ * and a file having one home while being referenced from elsewhere all need an
2812
+ * address that resolves without membership. So the channel keeps doing access
2813
+ * and conversation, and the folder becomes a layer above it.
2814
+ *
2815
+ * **Read-only here.** A client changes a folder by publishing a `kind:1852`
2816
+ * command and the relay emits the new state (§4.1). A folder is not a
2817
+ * user-signed event because NIP-01 addressable events are single-signer by
2818
+ * construction — the address is `(pubkey, kind, d)`, so a second person adding
2819
+ * a file would not replace your folder, they would create a different one.
2820
+ */
2821
+ export const KIND_FOLDER_STATE = 30890
2822
+
2823
+ /**
2824
+ * NIP-29 group metadata — the Folder's channel.
2825
+ *
2826
+ * Named here for two unrelated jobs: it carries the folder's `name` before any
2827
+ * folder state exists, and its own address is what makes a Peek topic a file
2828
+ * like any other (§5.2).
2829
+ */
2830
+ const KIND_CHANNEL = 39000
2831
+
2832
+ /** The `name` tag, when there is an event to read it off at all. */
2833
+ const nameOf = (event: SignedEvent | undefined) => (event ? tagValue(event, 'name') : undefined)
2834
+
2835
+ /** One folder, enough to draw a sidebar row. */
2836
+ export interface FolderSummary {
2837
+ /**
2838
+ * The folder's uuid. **One uuid, three roles** — the channel id, the `h` on
2839
+ * everything in it, and the `d` on both its `39000` and its `30890` (§5.2,
2840
+ * measured across all of production's channels).
2841
+ */
2842
+ id: string
2843
+ name?: string
2844
+ /** True once the relay maintains state for it, rather than it being a bare channel. */
2845
+ hasState: boolean
2846
+ }
2847
+
2848
+ /** A folder and everything in it, each file drawn through its owner's manifest. */
2849
+ export interface FolderContents extends FolderSummary {
2850
+ /** The folder state's address, absent until a state event exists. */
2851
+ address?: string
2852
+ /**
2853
+ * The files this folder holds.
2854
+ *
2855
+ * **Peers by construction, not by special case.** A Peek topic and a Ship
2856
+ * project sit side by side because both are `a` tags in one list, and §5.2's
2857
+ * finding is exactly that one tag type is enough to name either: a channel
2858
+ * turned out to be addressable after all, so a topic needs no wrapper and no
2859
+ * second mechanism. Nothing in this function knows what either app is.
2860
+ *
2861
+ * Ordered as the folder lists them. A file that resolved to nothing is
2862
+ * **absent rather than marked** — see {@link resolveFolderContents}.
2863
+ */
2864
+ files: ForeignObject[]
2865
+ /**
2866
+ * Where the list came from, because the two are not equivalent.
2867
+ *
2868
+ * `state` is the model. `channel` is the approximation available before a
2869
+ * folder has any state: containment inferred from `h`, which can list an
2870
+ * app's records and **can never list a topic**, because under `h` the topic
2871
+ * *is* the container rather than a thing inside it. A consumer that needs to
2872
+ * explain a short list is reading this field.
2873
+ */
2874
+ source: 'state' | 'channel'
2875
+ }
2876
+
2877
+ /**
2878
+ * Every folder this identity can see, for a sidebar.
2879
+ *
2880
+ * Both shapes in one pass: folders the relay maintains state for, and bare
2881
+ * channels that have none yet. A channel with state appears once, named by its
2882
+ * state — the folder's name is the folder's to say.
2883
+ *
2884
+ * **A direct route, deliberately.** §4.2's post-mortem on REW-11 is that
2885
+ * discovering children only through their parent loses them when the parent
2886
+ * goes; a sidebar built by walking something else would inherit exactly that.
2887
+ * This asks for folders by kind.
2888
+ */
2889
+ export async function listFolders(query: QueryFn): Promise<FolderSummary[]> {
2890
+ const events = await query([
2891
+ { kinds: [KIND_FOLDER_STATE], limit: 500 },
2892
+ { kinds: [KIND_CHANNEL], limit: 500 },
2893
+ ])
2894
+ const byId = new Map<string, FolderSummary>()
2895
+ for (const event of events) {
2896
+ const id = tagValue(event, 'd')
2897
+ if (!id) continue
2898
+ const state = event.kind === KIND_FOLDER_STATE
2899
+ const existing = byId.get(id)
2900
+ // State wins over the channel for the name, whichever order they arrived.
2901
+ if (existing && !state) continue
2902
+ byId.set(id, {
2903
+ id,
2904
+ name: tagValue(event, 'name') ?? existing?.name,
2905
+ hasState: state || (existing?.hasState ?? false),
2906
+ })
2907
+ }
2908
+ return [...byId.values()].sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id))
2909
+ }
2910
+
2911
+ /**
2912
+ * Everything in one folder, resolved through the manifests of the apps that own it.
2913
+ *
2914
+ * The cross-app read the whole model rests on: given a folder id, list its
2915
+ * files whatever kind they are and whoever wrote them, so two apps drawing the
2916
+ * same folder show the same things. Both apps consume this rather than either
2917
+ * one owning it.
2918
+ *
2919
+ * ## A file the reader cannot see is absent, not "unavailable"
2920
+ *
2921
+ * The one rule here that is a product decision rather than a mechanism, and a
2922
+ * deliberate divergence from upstream's NIP-MP, whose fold requires the
2923
+ * opposite for public repositories. **The count is the disclosure**: a folder
2924
+ * that renders three rows and two greyed-out placeholders has told an outsider
2925
+ * how much they are missing, which for a folder named after a person is the
2926
+ * sensitive part. So an address that resolves to nothing is dropped, and
2927
+ * nothing in the return value counts what was dropped.
2928
+ *
2929
+ * This is why the batch below cannot use {@link resolveForeignObject}
2930
+ * unchanged: that returns `unreachable: true` so an inline reference can say
2931
+ * "you may not have access", which is right for one pasted link and wrong for
2932
+ * a list.
2933
+ *
2934
+ * ## Round trips do not grow with the folder
2935
+ *
2936
+ * **Flat in the number of files, linear in the number of apps.** The folder
2937
+ * itself, the handler sweep, the contents, one query for every root at once
2938
+ * and every change with it, and one for the people — then two more per
2939
+ * distinct `(kind, author)` for NIP-89 discovery, which {@link ProjectionCache}
2940
+ * memoises away.
2941
+ *
2942
+ * Measured against production, reading three folders through one cache:
2943
+ *
2944
+ * | folder | files | cold | warm |
2945
+ * | --- | --- | --- | --- |
2946
+ * | Shared foundation packages | 24 | 11 | 5 |
2947
+ * | Feedback on Peek | 25 | 9 | 5 |
2948
+ * | Folders | 9 | 6 | 4 |
2949
+ *
2950
+ * Twenty-five files and nine cost the same warm, which is the property worth
2951
+ * having. A resolve per file would have been four *each* against a relay that
2952
+ * meters reads at 300 a minute — a folder of twenty-five would not have loaded.
2953
+ */
2954
+ export async function resolveFolderContents(
2955
+ folder: string,
2956
+ query: QueryFn,
2957
+ /** Defaults to asking the relay. The browser passes a cached lookup. */
2958
+ lookupPeople?: PeopleFn,
2959
+ /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */
2960
+ cache?: ProjectionCache,
2961
+ ): Promise<FolderContents> {
2962
+ // 1. The folder itself. Both kinds in one trip: the state is the model and
2963
+ // the channel is what names a folder that has none yet.
2964
+ const identity = await query([
2965
+ { kinds: [KIND_FOLDER_STATE], '#d': [folder], limit: 1 },
2966
+ { kinds: [KIND_CHANNEL], '#d': [folder], limit: 1 },
2967
+ ])
2968
+ const state = identity.find((e) => e.kind === KIND_FOLDER_STATE && tagValue(e, 'd') === folder)
2969
+ const channel = identity.find((e) => e.kind === KIND_CHANNEL && tagValue(e, 'd') === folder)
2970
+
2971
+ const summary: FolderSummary & { address?: string } = {
2972
+ id: folder,
2973
+ name: nameOf(state) ?? nameOf(channel),
2974
+ hasState: !!state,
2975
+ ...(state
2976
+ ? { address: pointerToAddress({ kind: state.kind, pubkey: state.pubkey, identifier: folder, relays: [] }) }
2977
+ : {}),
2978
+ }
2979
+
2980
+ const addresses = state
2981
+ ? // §5.2: one tag type lists every file, topics included, with no special
2982
+ // case. The order is the folder's, so it is preserved rather than sorted.
2983
+ [...new Set(state.tags.filter((t) => t[0] === 'a' && t[1]).map((t) => t[1]))]
2984
+ : await addressesByContainment(folder, query)
2985
+
2986
+ if (addresses.length === 0) {
2987
+ return { ...summary, files: [], source: state ? 'state' : 'channel' }
2988
+ }
2989
+
2990
+ // 2. Group by (kind, author): one manifest answers for every file an app owns
2991
+ // in this folder, and the cache makes the second folder free.
2992
+ const pointers = addresses.flatMap((address) => {
2993
+ try {
2994
+ return [{ address, pointer: referenceToPointer(address) }]
2995
+ } catch {
2996
+ // An `a` tag nothing can parse is somebody else's bug and not worth a
2997
+ // whole folder. Dropped like any other unresolvable file.
2998
+ return []
2999
+ }
3000
+ })
3001
+ const groups = new Map<string, { pointer: AddressPointer; addresses: string[] }>()
3002
+ for (const { address, pointer } of pointers) {
3003
+ const key = `${pointer.kind}:${pointer.pubkey}`
3004
+ const group = groups.get(key)
3005
+ if (group) group.addresses.push(address)
3006
+ else groups.set(key, { pointer, addresses: [address] })
3007
+ }
3008
+
3009
+ const manifests = new Map<string, ResolvedManifest>()
3010
+ await Promise.all(
3011
+ [...groups].map(async ([key, { pointer }]) => {
3012
+ const resolved = await resolveManifest(pointer, query, cache)
3013
+ if (resolved) manifests.set(key, resolved)
3014
+ }),
3015
+ )
3016
+
3017
+ // 3. Every root, and every change against every address, in two filters.
3018
+ // Change kinds are a set because two apps may fold differently, and a kind
3019
+ // is a u16 — the relay refuses an out-of-range one outright, so an app
3020
+ // declaring no `records` contributes no filter rather than an empty one.
3021
+ const changeKinds = [
3022
+ ...new Set(
3023
+ [...manifests.values()].flatMap((r) => (r.manifest.records ? [r.manifest.records.changeKind] : [])),
3024
+ ),
3025
+ ]
3026
+ const events = await query([
3027
+ ...[...groups.values()].map(({ pointer, addresses: group }) => ({
3028
+ kinds: [pointer.kind],
3029
+ authors: [pointer.pubkey],
3030
+ '#d': group.map((a) => referenceToPointer(a).identifier),
3031
+ limit: group.length,
3032
+ })),
3033
+ ...(changeKinds.length
3034
+ ? [{ kinds: changeKinds, '#a': addresses, limit: 500 }]
3035
+ : []),
3036
+ ])
3037
+
3038
+ // 4. Build each file, in the order the folder listed them.
3039
+ const files: ForeignObject[] = []
3040
+ for (const { address, pointer } of pointers) {
3041
+ const resolved = manifests.get(`${pointer.kind}:${pointer.pubkey}`)
3042
+ // No app claims this kind, so there is no projection to draw it with.
3043
+ if (!resolved) continue
3044
+ const projection = resolved.manifest.projections?.[String(pointer.kind)]
3045
+ if (!projection) continue
3046
+ const root = events.find(
3047
+ (e) => e.kind === pointer.kind && e.pubkey === pointer.pubkey && tagValue(e, 'd') === pointer.identifier,
3048
+ )
3049
+ // The disclosure rule. Absent, not "unavailable".
3050
+ if (!root) continue
3051
+
3052
+ const records = foldRuleOf(resolved.manifest)
3053
+ const folded = foldChanges(
3054
+ events.filter((e) => e.kind === records.changeKind && hasTagValue(e, records.targetTag, address)),
3055
+ records,
3056
+ )
3057
+ // An app hiding a record from its own lists is saying it is not part of the
3058
+ // folder any more. `hiddenWhen` is the app's own declaration of that.
3059
+ if (records.hiddenWhen && folded[records.hiddenWhen.field]?.value === records.hiddenWhen.equals) {
3060
+ continue
3061
+ }
3062
+ files.push(
3063
+ buildObject({
3064
+ root,
3065
+ pointer,
3066
+ manifest: resolved.manifest,
3067
+ projection,
3068
+ folded,
3069
+ viaRecommendation: resolved.viaRecommendation,
3070
+ webTemplate: resolved.webTemplate,
3071
+ }),
3072
+ )
3073
+ }
3074
+
3075
+ const people = await (lookupPeople ?? peopleViaRelay(query))([
3076
+ ...new Set(files.flatMap(pubkeysIn)),
3077
+ ])
3078
+ return {
3079
+ ...summary,
3080
+ files: files.map((file) => ({ ...file, people })),
3081
+ source: state ? 'state' : 'channel',
3082
+ }
3083
+ }
3084
+
3085
+ /**
3086
+ * The contents of a folder that has no state event — containment by `h`.
3087
+ *
3088
+ * **The approximation, and it is worth being precise about what it cannot do.**
3089
+ * Before folder state exists, the only thing on the wire saying a file is in a
3090
+ * folder is the file's own `h` (or `buzz-channel`, for a record published
3091
+ * globally — {@link folderOf} has both spellings and why). That lists an app's
3092
+ * records perfectly well and **cannot list a topic**: under `h` the topic is
3093
+ * the container, so it would have to be inside itself.
3094
+ *
3095
+ * Kept because both wire shapes coexist permanently — no migration is
3096
+ * available, and a reader that only understood folder state would show every
3097
+ * folder on production as empty.
3098
+ *
3099
+ * Two filters rather than one: an `#h` query does not return a record placed
3100
+ * globally, and reading only `h` was what made five of Ship's fifteen projects
3101
+ * unactionable before `folderOf` existed.
3102
+ */
3103
+ async function addressesByContainment(folder: string, query: QueryFn): Promise<string[]> {
3104
+ // Which kinds could be files? Every kind any app declares a projection for.
3105
+ // The only non-app-specific source for a kind number is a published manifest.
3106
+ const handlers = await query([{ kinds: [KIND_HANDLER_INFORMATION], limit: 50 }])
3107
+ const kinds = [
3108
+ ...new Set(
3109
+ handlers.flatMap((event) =>
3110
+ Object.keys(parseManifest(event)?.projections ?? {}).map(Number).filter(Number.isFinite),
3111
+ ),
3112
+ ),
3113
+ ]
3114
+ if (kinds.length === 0) return []
3115
+
3116
+ const held = await query([
3117
+ { '#h': [folder], kinds, limit: 500 },
3118
+ { '#buzz-channel': [folder], kinds, limit: 500 },
3119
+ ])
3120
+ return [
3121
+ ...new Set(
3122
+ held
3123
+ .filter((event) => {
3124
+ const d = tagValue(event, 'd')
3125
+ /*
3126
+ Two exclusions, and both are about what a file *is*.
3127
+
3128
+ **A file is addressable** — RFC 0.4 §5, "anything with an address
3129
+ that a folder can list". A `kind:9` message carries no `d`, so it
3130
+ has no address, so it is conversation rather than contents. That is
3131
+ the whole discriminator and it needs no kind numbers.
3132
+
3133
+ **A folder is not a file inside itself.** A channel's own `39000`
3134
+ comes back from an `#h` query for that channel — the relay scopes a
3135
+ discovery event to the channel it describes, so it arrives with the
3136
+ contents. What marks it out is that its `d` *is* the folder uuid.
3137
+ */
3138
+ return d !== undefined && d !== folder
3139
+ })
3140
+ .sort((a, b) => b.created_at - a.created_at)
3141
+ .map((event) =>
3142
+ pointerToAddress({
3143
+ kind: event.kind,
3144
+ pubkey: event.pubkey,
3145
+ identifier: tagValue(event, 'd') ?? '',
3146
+ relays: [],
3147
+ }),
3148
+ ),
3149
+ ),
3150
+ ]
3151
+ }