@crossworks/client-types 0.300.2 → 0.300.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/client-types",
3
- "version": "0.300.2",
3
+ "version": "0.300.5",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The kind registry (workspaces arch A1) and the lists of this package that
3
+ * derive from it. Each derived list is pinned to its value before the
4
+ * registry (v0.300.2): a change to who may share, search or see a kind is a
5
+ * reviewed edit of the pin below, never a side effect of a registry field.
6
+ *
7
+ * A list whose order is a contract (the tree's) is pinned in order; a list
8
+ * the code only asks membership of is pinned as a set (it is now in schema
9
+ * order). The parity with SQL (the enum, mantle_kind_class) is a db test:
10
+ * packages/db/src/kind-registry.db.test.ts.
11
+ */
12
+ import { describe, expect, it } from 'vitest';
13
+ import { ITEM_KINDS, ITEM_KIND_NAMES, kindClassOf, kindSpecOf, kindsWhere } from './kinds';
14
+ import { TREE_KIND_SPECS, TREE_KINDS } from './tree';
15
+ import { SEARCH_NODE_TYPES } from './search-query';
16
+ import { CLIENT_ITEM_KINDS, MEMBER_ITEM_KINDS } from './member-kinds';
17
+
18
+ const set = (xs: readonly string[]) => [...xs].sort();
19
+
20
+ describe('the kind registry', () => {
21
+ it('names every node_type value once, in the schema order', () => {
22
+ // The enum (packages/db/src/schema/nodes.ts); the db test checks the
23
+ // live enum_range too.
24
+ expect(ITEM_KIND_NAMES).toEqual([
25
+ 'branch',
26
+ 'email',
27
+ 'email_thread',
28
+ 'file',
29
+ 'note',
30
+ 'sermon',
31
+ 'contact',
32
+ 'secret',
33
+ 'task',
34
+ 'event',
35
+ 'printer_project',
36
+ 'telegram_message',
37
+ 'page',
38
+ 'table',
39
+ 'mantle_peer',
40
+ 'documentation',
41
+ 'journal',
42
+ 'location',
43
+ 'app',
44
+ 'formula',
45
+ 'draw',
46
+ 'recall',
47
+ ]);
48
+ });
49
+
50
+ it('classes: the workspace kinds, one conversation kind, the rest Admin only', () => {
51
+ expect(set(kindsWhere((s) => s.class === 'workspace'))).toEqual(
52
+ set([
53
+ 'branch',
54
+ 'file',
55
+ 'note',
56
+ 'page',
57
+ 'table',
58
+ 'app',
59
+ 'formula',
60
+ 'draw',
61
+ 'task',
62
+ 'event',
63
+ 'recall',
64
+ ]),
65
+ );
66
+ expect(kindsWhere((s) => s.class === 'conversation')).toEqual(['telegram_message']);
67
+ expect(kindClassOf('journal')).toBe('admin_only');
68
+ expect(kindClassOf('no-such-kind')).toBe('admin_only');
69
+ expect(kindSpecOf('no-such-kind')).toBeNull();
70
+ expect(kindSpecOf('toString')).toBeNull();
71
+ });
72
+
73
+ it('an Admin-only kind is never linked, shared with a contact or a folder share', () => {
74
+ for (const t of ITEM_KIND_NAMES) {
75
+ const s = ITEM_KINDS[t];
76
+ if (s.class !== 'admin_only') continue;
77
+ expect(s.publicLink, t).toBe(false);
78
+ expect(s.contactShare, t).toBe(false);
79
+ expect(s.tree?.shareable ?? false, t).toBe(false);
80
+ expect(s.library, t).toBe(false);
81
+ }
82
+ });
83
+
84
+ it('tree orders are unique and dense', () => {
85
+ const trees = ITEM_KIND_NAMES.flatMap((t) => {
86
+ const tree = ITEM_KINDS[t].tree as { order: number; liveOrder: number } | null;
87
+ return tree ? [tree] : [];
88
+ });
89
+ const n = trees.length;
90
+ expect(set(trees.map((t) => String(t.order)))).toEqual(set([...Array(n).keys()].map(String)));
91
+ expect(set(trees.map((t) => String(t.liveOrder)))).toEqual(
92
+ set([...Array(n).keys()].map(String)),
93
+ );
94
+ });
95
+
96
+ it('route prefixes are unique', () => {
97
+ const content = ITEM_KIND_NAMES.map((t) => ITEM_KINDS[t].route.content).filter(Boolean);
98
+ expect(new Set(content).size).toBe(content.length);
99
+ const v1 = ITEM_KIND_NAMES.map((t) => ITEM_KINDS[t].route.v1).filter(Boolean);
100
+ expect(new Set(v1).size).toBe(v1.length);
101
+ });
102
+ });
103
+
104
+ describe('derived lists, pinned to their value before the registry', () => {
105
+ it('TREE_KINDS keeps its order (a jackdaw contract)', () => {
106
+ expect(TREE_KINDS).toEqual([
107
+ 'files',
108
+ 'notes',
109
+ 'pages',
110
+ 'draw',
111
+ 'tables',
112
+ 'formulas',
113
+ 'apps',
114
+ 'tasks',
115
+ 'events',
116
+ 'contacts',
117
+ 'secrets',
118
+ 'recall',
119
+ ]);
120
+ });
121
+
122
+ it('TREE_KIND_SPECS: every spec as before, keyed in navigation order', () => {
123
+ expect(Object.keys(TREE_KIND_SPECS)).toEqual([...TREE_KINDS]);
124
+ expect(TREE_KIND_SPECS).toEqual({
125
+ files: {
126
+ kind: 'files',
127
+ root: 'files',
128
+ nodeType: 'file',
129
+ shareable: true,
130
+ sorts: ['name', 'updated'],
131
+ },
132
+ notes: {
133
+ kind: 'notes',
134
+ root: 'notes',
135
+ nodeType: 'note',
136
+ shareable: true,
137
+ sorts: ['updated', 'name'],
138
+ },
139
+ pages: {
140
+ kind: 'pages',
141
+ root: 'pages',
142
+ nodeType: 'page',
143
+ shareable: true,
144
+ sorts: ['updated', 'name'],
145
+ },
146
+ draw: {
147
+ kind: 'draw',
148
+ root: 'draw',
149
+ nodeType: 'draw',
150
+ shareable: true,
151
+ sorts: ['updated', 'name'],
152
+ },
153
+ tables: {
154
+ kind: 'tables',
155
+ root: 'tables',
156
+ nodeType: 'table',
157
+ shareable: true,
158
+ sorts: ['updated', 'name'],
159
+ },
160
+ formulas: {
161
+ kind: 'formulas',
162
+ root: 'formulas',
163
+ nodeType: 'formula',
164
+ shareable: true,
165
+ sorts: ['name', 'updated'],
166
+ },
167
+ apps: {
168
+ kind: 'apps',
169
+ root: 'apps',
170
+ nodeType: 'app',
171
+ shareable: true,
172
+ sorts: ['name', 'updated'],
173
+ },
174
+ tasks: {
175
+ kind: 'tasks',
176
+ root: 'tasks',
177
+ nodeType: 'task',
178
+ shareable: false,
179
+ sorts: ['due', 'updated', 'name'],
180
+ },
181
+ events: {
182
+ kind: 'events',
183
+ root: 'events',
184
+ nodeType: 'event',
185
+ shareable: false,
186
+ sorts: ['start', 'name'],
187
+ },
188
+ contacts: {
189
+ kind: 'contacts',
190
+ root: 'contacts',
191
+ nodeType: 'contact',
192
+ shareable: false,
193
+ sorts: ['name', 'updated'],
194
+ },
195
+ secrets: {
196
+ kind: 'secrets',
197
+ root: 'secrets',
198
+ nodeType: 'secret',
199
+ shareable: false,
200
+ sorts: ['name', 'updated'],
201
+ },
202
+ recall: {
203
+ kind: 'recall',
204
+ root: 'recall',
205
+ nodeType: 'recall',
206
+ shareable: false,
207
+ sorts: ['name', 'updated'],
208
+ },
209
+ });
210
+ });
211
+
212
+ it('SEARCH_NODE_TYPES (a set)', () => {
213
+ expect(set(SEARCH_NODE_TYPES)).toEqual(
214
+ set([
215
+ 'branch',
216
+ 'email',
217
+ 'email_thread',
218
+ 'file',
219
+ 'note',
220
+ 'page',
221
+ 'sermon',
222
+ 'contact',
223
+ 'task',
224
+ 'event',
225
+ 'printer_project',
226
+ 'telegram_message',
227
+ 'documentation',
228
+ 'journal',
229
+ 'formula',
230
+ 'draw',
231
+ ]),
232
+ );
233
+ });
234
+
235
+ it('MEMBER_ITEM_KINDS and CLIENT_ITEM_KINDS keep their order (deprecated, one pair release)', () => {
236
+ expect(MEMBER_ITEM_KINDS).toEqual(['page', 'note', 'draw', 'table', 'file']);
237
+ expect(CLIENT_ITEM_KINDS).toEqual(['page', 'note', 'file']);
238
+ });
239
+ });
package/src/kinds.ts ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * The kind registry (workspaces arch plan v2, section 9; A1): ONE entry per
3
+ * value of the `node_type` enum (packages/db/src/schema/nodes.ts). Every other
4
+ * TS kind list is derived from it, never copied:
5
+ *
6
+ * - WORKSPACE_NODE_TYPES (@mantle/db), the class;
7
+ * - TREE_KINDS and TREE_KIND_SPECS (./tree), TREE_LIVE_KINDS and
8
+ * READER_TREE_KINDS (@mantle/content/tree), TREE_TOOL_KINDS
9
+ * (@mantle/tools), the tree entry;
10
+ * - SHAREABLE_TYPES and canShareNode, CONTACT_SHAREABLE_TYPES and
11
+ * PEER_SHAREABLE_TYPES (@mantle/content), the share fields;
12
+ * - SEARCH_NODE_TYPES (./search-query), searchable;
13
+ * - TEAM_PRIVATE_CORPUS_TYPES and TEAM_NEVER_TYPES
14
+ * (@mantle/content-core/profile-projections), teamHidden;
15
+ * - MEMBER_ITEM_KINDS and CLIENT_ITEM_KINDS (./member-kinds), library: kept
16
+ * one pair release, then they go with the field.
17
+ *
18
+ * Each field holds the value the code had when the registry came in (A1).
19
+ * Who may share or see a kind changes here only as a reviewed diff of the
20
+ * snapshot tests (kinds.test.ts and the tests next to each derived list),
21
+ * never as a side effect of another list.
22
+ *
23
+ * `class` must equal mantle_kind_class(t) in SQL (migration 0241); a db test
24
+ * checks it, and that the enum and these keys are the same set. The keys are
25
+ * in the order of the Drizzle enum (schema/nodes.ts); a list derived here
26
+ * keeps that order unless it says otherwise (the tree lists and the old
27
+ * Library lists keep their own).
28
+ *
29
+ * Pure and dependency free: the `@mantle/client-types/kinds` subpath, so
30
+ * jackdaw reads the same registry.
31
+ */
32
+ import type { TreeSort } from './tree';
33
+
34
+ /** 'workspace': granted to any workspace. 'conversation': belongs to the
35
+ * workspace of the assistant that holds the chat. 'admin_only': lives in the
36
+ * Admin workspace only. */
37
+ export type KindClass = 'workspace' | 'conversation' | 'admin_only';
38
+
39
+ /** A kind's place in the item tree. */
40
+ export type KindTreeSpec = {
41
+ /** The tree kind (the URL segment, /api/tree/:kind). */
42
+ readonly kind: string;
43
+ /** The ltree root label of the kind (the `*_ROOT_LABEL` constants). */
44
+ readonly root: string;
45
+ /** Navigation order (TREE_KINDS). A jackdaw contract: never reorder. */
46
+ readonly order: number;
47
+ /** The order the brain lists the kinds it serves (TREE_LIVE_KINDS, the
48
+ * shell's `treeKinds`, the tree tools' enum): apps joined after secrets. */
49
+ readonly liveOrder: number;
50
+ /** The item orders offered, the first being the default. */
51
+ readonly sorts: readonly TreeSort[];
52
+ /** Whether a folder of this kind may be shared below Admin. */
53
+ readonly shareable: boolean;
54
+ };
55
+
56
+ export type KindSpec = {
57
+ readonly class: KindClass;
58
+ readonly tree: KindTreeSpec | null;
59
+ /** May carry a public link (SHAREABLE_TYPES). 'files-folder': a folder,
60
+ * only strictly under the `files` root (canShareNode). */
61
+ readonly publicLink: boolean | 'files-folder';
62
+ /** May be shared with one contact (CONTACT_SHAREABLE_TYPES). */
63
+ readonly contactShare: boolean;
64
+ /** May be granted to a linked brain by category (PEER_SHAREABLE_TYPES). */
65
+ readonly peerShare: boolean;
66
+ /** A `type` filter of GET /api/search (SEARCH_NODE_TYPES). */
67
+ readonly searchable: boolean;
68
+ /** The kind's item routes: the content prefix (`:id` follows it; the
69
+ * member route lists are generated from it, route-scope.ts) and the
70
+ * public API v1 prefix. Null where the kind has none. Nothing reads `v1`
71
+ * yet: A4 generates the content rows of ROUTE_POLICY from both (plan v2,
72
+ * section 7; D3, v1 answers as its base route). */
73
+ readonly route: { readonly content: string | null; readonly v1: string | null };
74
+ /** Hidden from a team surface: 'never' (opt-in or not), 'private-corpus'
75
+ * (unless the owner opted in, teamPrivateReads), or not. */
76
+ readonly teamHidden: 'never' | 'private-corpus' | false;
77
+ /** The old Library lists: in MEMBER_ITEM_KINDS at `order`, and in
78
+ * CLIENT_ITEM_KINDS too when `client`. Kept one pair release (A1), then
79
+ * removed with them. */
80
+ readonly library: { readonly order: number; readonly client: boolean } | false;
81
+ };
82
+
83
+ const none = { content: null, v1: null } as const;
84
+
85
+ export const ITEM_KINDS = {
86
+ branch: {
87
+ class: 'workspace',
88
+ tree: null,
89
+ publicLink: 'files-folder',
90
+ contactShare: false,
91
+ peerShare: false,
92
+ searchable: true,
93
+ route: { content: '/api/files/folders', v1: null },
94
+ teamHidden: false,
95
+ library: false,
96
+ },
97
+ email: {
98
+ class: 'admin_only',
99
+ tree: null,
100
+ publicLink: false,
101
+ contactShare: false,
102
+ peerShare: false,
103
+ searchable: true,
104
+ route: { content: '/api/email/messages', v1: null },
105
+ teamHidden: 'private-corpus',
106
+ library: false,
107
+ },
108
+ email_thread: {
109
+ class: 'admin_only',
110
+ tree: null,
111
+ publicLink: false,
112
+ contactShare: false,
113
+ peerShare: false,
114
+ searchable: true,
115
+ route: none,
116
+ teamHidden: 'private-corpus',
117
+ library: false,
118
+ },
119
+ file: {
120
+ class: 'workspace',
121
+ tree: {
122
+ kind: 'files',
123
+ root: 'files',
124
+ order: 0,
125
+ liveOrder: 0,
126
+ sorts: ['name', 'updated'],
127
+ shareable: true,
128
+ },
129
+ publicLink: true,
130
+ contactShare: true,
131
+ peerShare: true,
132
+ searchable: true,
133
+ route: { content: '/api/files/files', v1: '/api/v1/files' },
134
+ teamHidden: false,
135
+ library: { order: 4, client: true },
136
+ },
137
+ note: {
138
+ class: 'workspace',
139
+ tree: {
140
+ kind: 'notes',
141
+ root: 'notes',
142
+ order: 1,
143
+ liveOrder: 1,
144
+ sorts: ['updated', 'name'],
145
+ shareable: true,
146
+ },
147
+ publicLink: true,
148
+ contactShare: true,
149
+ peerShare: true,
150
+ searchable: true,
151
+ route: { content: '/api/notes', v1: '/api/v1/notes' },
152
+ teamHidden: false,
153
+ library: { order: 1, client: true },
154
+ },
155
+ sermon: {
156
+ class: 'admin_only',
157
+ tree: null,
158
+ publicLink: false,
159
+ contactShare: false,
160
+ peerShare: false,
161
+ searchable: true,
162
+ route: none,
163
+ teamHidden: false,
164
+ library: false,
165
+ },
166
+ contact: {
167
+ class: 'admin_only',
168
+ tree: {
169
+ kind: 'contacts',
170
+ root: 'contacts',
171
+ order: 9,
172
+ liveOrder: 8,
173
+ sorts: ['name', 'updated'],
174
+ shareable: false,
175
+ },
176
+ publicLink: false,
177
+ contactShare: false,
178
+ peerShare: true,
179
+ searchable: true,
180
+ route: { content: '/api/contacts', v1: '/api/v1/contacts' },
181
+ teamHidden: false,
182
+ library: false,
183
+ },
184
+ secret: {
185
+ class: 'admin_only',
186
+ tree: {
187
+ kind: 'secrets',
188
+ root: 'secrets',
189
+ order: 10,
190
+ liveOrder: 9,
191
+ sorts: ['name', 'updated'],
192
+ shareable: false,
193
+ },
194
+ publicLink: false,
195
+ contactShare: false,
196
+ peerShare: false,
197
+ searchable: false,
198
+ route: { content: '/api/secrets', v1: null },
199
+ teamHidden: 'never',
200
+ library: false,
201
+ },
202
+ task: {
203
+ class: 'workspace',
204
+ tree: {
205
+ kind: 'tasks',
206
+ root: 'tasks',
207
+ order: 7,
208
+ liveOrder: 6,
209
+ sorts: ['due', 'updated', 'name'],
210
+ shareable: false,
211
+ },
212
+ publicLink: true,
213
+ contactShare: false,
214
+ peerShare: true,
215
+ searchable: true,
216
+ route: { content: '/api/tasks', v1: '/api/v1/tasks' },
217
+ teamHidden: false,
218
+ library: false,
219
+ },
220
+ event: {
221
+ class: 'workspace',
222
+ tree: {
223
+ kind: 'events',
224
+ root: 'events',
225
+ order: 8,
226
+ liveOrder: 7,
227
+ sorts: ['start', 'name'],
228
+ shareable: false,
229
+ },
230
+ publicLink: true,
231
+ contactShare: false,
232
+ peerShare: true,
233
+ searchable: true,
234
+ route: { content: '/api/events', v1: '/api/v1/events' },
235
+ teamHidden: false,
236
+ library: false,
237
+ },
238
+ printer_project: {
239
+ class: 'admin_only',
240
+ tree: null,
241
+ publicLink: false,
242
+ contactShare: false,
243
+ peerShare: false,
244
+ searchable: true,
245
+ route: none,
246
+ teamHidden: false,
247
+ library: false,
248
+ },
249
+ telegram_message: {
250
+ class: 'conversation',
251
+ tree: null,
252
+ publicLink: false,
253
+ contactShare: false,
254
+ peerShare: false,
255
+ searchable: true,
256
+ route: none,
257
+ teamHidden: 'never',
258
+ library: false,
259
+ },
260
+ page: {
261
+ class: 'workspace',
262
+ tree: {
263
+ kind: 'pages',
264
+ root: 'pages',
265
+ order: 2,
266
+ liveOrder: 2,
267
+ sorts: ['updated', 'name'],
268
+ shareable: true,
269
+ },
270
+ publicLink: true,
271
+ contactShare: true,
272
+ peerShare: true,
273
+ searchable: true,
274
+ route: { content: '/api/pages', v1: '/api/v1/pages' },
275
+ teamHidden: false,
276
+ library: { order: 0, client: true },
277
+ },
278
+ table: {
279
+ class: 'workspace',
280
+ tree: {
281
+ kind: 'tables',
282
+ root: 'tables',
283
+ order: 4,
284
+ liveOrder: 4,
285
+ sorts: ['updated', 'name'],
286
+ shareable: true,
287
+ },
288
+ publicLink: true,
289
+ contactShare: true,
290
+ peerShare: true,
291
+ searchable: false,
292
+ route: { content: '/api/tables', v1: '/api/v1/tables' },
293
+ teamHidden: false,
294
+ library: { order: 3, client: false },
295
+ },
296
+ mantle_peer: {
297
+ class: 'admin_only',
298
+ tree: null,
299
+ publicLink: false,
300
+ contactShare: false,
301
+ peerShare: false,
302
+ searchable: false,
303
+ route: none,
304
+ teamHidden: 'never',
305
+ library: false,
306
+ },
307
+ documentation: {
308
+ class: 'admin_only',
309
+ tree: null,
310
+ publicLink: false,
311
+ contactShare: false,
312
+ peerShare: false,
313
+ searchable: true,
314
+ route: { content: '/api/docs/reader', v1: null },
315
+ teamHidden: false,
316
+ library: false,
317
+ },
318
+ journal: {
319
+ class: 'admin_only',
320
+ tree: null,
321
+ publicLink: false,
322
+ contactShare: false,
323
+ peerShare: false,
324
+ searchable: true,
325
+ route: { content: '/api/journal', v1: '/api/v1/journal' },
326
+ teamHidden: 'private-corpus',
327
+ library: false,
328
+ },
329
+ location: {
330
+ class: 'admin_only',
331
+ tree: null,
332
+ publicLink: false,
333
+ contactShare: false,
334
+ peerShare: false,
335
+ searchable: false,
336
+ route: none,
337
+ teamHidden: 'never',
338
+ library: false,
339
+ },
340
+ app: {
341
+ class: 'workspace',
342
+ tree: {
343
+ kind: 'apps',
344
+ root: 'apps',
345
+ order: 6,
346
+ liveOrder: 10,
347
+ sorts: ['name', 'updated'],
348
+ shareable: true,
349
+ },
350
+ publicLink: true,
351
+ contactShare: true,
352
+ peerShare: false,
353
+ searchable: false,
354
+ route: { content: '/api/apps', v1: null },
355
+ teamHidden: false,
356
+ library: false,
357
+ },
358
+ formula: {
359
+ class: 'workspace',
360
+ tree: {
361
+ kind: 'formulas',
362
+ root: 'formulas',
363
+ order: 5,
364
+ liveOrder: 5,
365
+ sorts: ['name', 'updated'],
366
+ shareable: true,
367
+ },
368
+ publicLink: true,
369
+ contactShare: true,
370
+ peerShare: false,
371
+ searchable: true,
372
+ route: { content: '/api/formulas', v1: null },
373
+ teamHidden: false,
374
+ library: false,
375
+ },
376
+ draw: {
377
+ class: 'workspace',
378
+ tree: {
379
+ kind: 'draw',
380
+ root: 'draw',
381
+ order: 3,
382
+ liveOrder: 3,
383
+ sorts: ['updated', 'name'],
384
+ shareable: true,
385
+ },
386
+ publicLink: true,
387
+ contactShare: true,
388
+ peerShare: true,
389
+ searchable: true,
390
+ route: { content: '/api/draws', v1: null },
391
+ teamHidden: false,
392
+ library: { order: 2, client: false },
393
+ },
394
+ recall: {
395
+ class: 'workspace',
396
+ // The tree lists maps only; a map's cards are recall_nodes rows, never
397
+ // nodes. Its folders are not shared below Admin yet (Recall R6).
398
+ tree: {
399
+ kind: 'recall',
400
+ root: 'recall',
401
+ order: 11,
402
+ liveOrder: 11,
403
+ sorts: ['name', 'updated'],
404
+ shareable: false,
405
+ },
406
+ publicLink: false,
407
+ contactShare: false,
408
+ peerShare: false,
409
+ searchable: false,
410
+ // A native map's id is its node's id (createRecallMap).
411
+ route: { content: '/api/recall/maps', v1: null },
412
+ teamHidden: false,
413
+ library: false,
414
+ },
415
+ } as const satisfies Record<string, KindSpec>;
416
+
417
+ type Kinds = typeof ITEM_KINDS;
418
+
419
+ /** Every node type, as the registry names it (the `node_type` enum). */
420
+ export type ItemKind = keyof Kinds;
421
+
422
+ /** The registry keys, in the Drizzle enum's order. */
423
+ export const ITEM_KIND_NAMES = Object.keys(ITEM_KINDS) as ItemKind[];
424
+
425
+ /** The node types whose spec matches `P` (a type-level filter). */
426
+ export type KindsWhere<P> = { [K in ItemKind]: Kinds[K] extends P ? K : never }[ItemKind];
427
+
428
+ /** The node types whose spec passes `pick`, in registry order. Never empty for
429
+ * the lists derived here (their snapshot tests say so). */
430
+ export function kindsWhere<K extends ItemKind = ItemKind>(
431
+ pick: (spec: KindSpec, type: ItemKind) => boolean,
432
+ ): readonly [K, ...K[]] {
433
+ return ITEM_KIND_NAMES.filter((t) => pick(ITEM_KINDS[t], t)) as unknown as readonly [K, ...K[]];
434
+ }
435
+
436
+ /** The spec of `type`, or null for a value the registry does not know. */
437
+ export function kindSpecOf(type: string): KindSpec | null {
438
+ return Object.prototype.hasOwnProperty.call(ITEM_KINDS, type)
439
+ ? ITEM_KINDS[type as ItemKind]
440
+ : null;
441
+ }
442
+
443
+ /** The class of `type` (mantle_kind_class in TS); an unknown value is
444
+ * 'admin_only', as the SQL's ELSE. */
445
+ export function kindClassOf(type: string): KindClass {
446
+ return kindSpecOf(type)?.class ?? 'admin_only';
447
+ }
448
+
449
+ // ── Typed selections the derived lists use ──────────────────────────────────
450
+
451
+ export type WorkspaceKind = KindsWhere<{ class: 'workspace' }>;
452
+ export type PublicLinkKind = KindsWhere<{ publicLink: true | 'files-folder' }>;
453
+ export type ContactShareKind = KindsWhere<{ contactShare: true }>;
454
+ export type PeerShareKind = KindsWhere<{ peerShare: true }>;
455
+ export type SearchableKind = KindsWhere<{ searchable: true }>;
456
+ export type MemberLibraryKind = KindsWhere<{ library: { order: number } }>;
457
+ export type ClientLibraryKind = KindsWhere<{ library: { client: true } }>;
458
+
459
+ /** The tree kinds the registry names (`files`, `notes`, ...). */
460
+ export type RegistryTreeKind = {
461
+ [K in ItemKind]: Kinds[K]['tree'] extends { kind: infer T } ? T : never;
462
+ }[ItemKind];
463
+
464
+ /** The node types with a tree entry, ordered by `key` of that entry. */
465
+ export function treeKindsBy(key: 'order' | 'liveOrder'): readonly RegistryTreeKind[] {
466
+ return ITEM_KIND_NAMES.flatMap((t) => {
467
+ const tree: KindTreeSpec | null = ITEM_KINDS[t].tree;
468
+ return tree ? [tree] : [];
469
+ })
470
+ .sort((a, b) => a[key] - b[key])
471
+ .map((t) => t.kind as RegistryTreeKind);
472
+ }
@@ -3,8 +3,31 @@
3
3
  * what the Library lists. The ONE list, for the brain (member-space.ts,
4
4
  * member-library.ts) and the client alike (audit M2); a runtime constant, so
5
5
  * it is the `@mantle/client-types/member-kinds` subpath.
6
+ *
7
+ * DEPRECATED (workspaces arch A1): derived from the kind registry's `library`
8
+ * field (./kinds) and kept exported for one pair release, then removed. Use
9
+ * the registry.
6
10
  */
7
- export const MEMBER_ITEM_KINDS = ['page', 'note', 'draw', 'table', 'file'] as const;
11
+ import {
12
+ ITEM_KINDS,
13
+ kindsWhere,
14
+ type ClientLibraryKind,
15
+ type ItemKind,
16
+ type MemberLibraryKind,
17
+ } from './kinds';
18
+
19
+ /** The registry's `library` kinds that pass `pick`, in the lists' own order. */
20
+ function libraryKinds<K extends ItemKind>(pick: (client: boolean) => boolean): readonly K[] {
21
+ const order = (t: ItemKind) => {
22
+ const lib = ITEM_KINDS[t].library as { order: number } | false;
23
+ return lib ? lib.order : 0;
24
+ };
25
+ return kindsWhere<K>((s) => s.library !== false && pick(s.library.client))
26
+ .slice()
27
+ .sort((a, b) => order(a) - order(b));
28
+ }
29
+
30
+ export const MEMBER_ITEM_KINDS = libraryKinds<MemberLibraryKind>(() => true);
8
31
  export type MemberItemKind = (typeof MEMBER_ITEM_KINDS)[number];
9
32
 
10
33
  export function isMemberItemKind(v: unknown): v is MemberItemKind {
@@ -47,7 +70,7 @@ export function adminListStateOf(v: unknown): AdminListState {
47
70
 
48
71
  /** The kinds a CLIENT works with in their own space (client logins C5, plan
49
72
  * section 6): pages and notes they write, files they upload. */
50
- export const CLIENT_ITEM_KINDS = ['page', 'note', 'file'] as const;
73
+ export const CLIENT_ITEM_KINDS = libraryKinds<ClientLibraryKind>((client) => client);
51
74
  export type ClientItemKind = (typeof CLIENT_ITEM_KINDS)[number];
52
75
 
53
76
  export function isClientItemKind(v: unknown): v is ClientItemKind {
@@ -3,26 +3,12 @@
3
3
  * the `search_nodes` / `search_chunks` MCP tools (mobile companion's search).
4
4
  * Pure so it can be unit-tested without the route runtime.
5
5
  */
6
+ import { kindsWhere, type SearchableKind } from './kinds';
6
7
 
7
- /** Mirrors the `search_nodes` tool's `type` enum — the API's contract. */
8
- export const SEARCH_NODE_TYPES = [
9
- 'branch',
10
- 'email',
11
- 'email_thread',
12
- 'file',
13
- 'note',
14
- 'page',
15
- 'sermon',
16
- 'contact',
17
- 'task',
18
- 'event',
19
- 'printer_project',
20
- 'telegram_message',
21
- 'documentation',
22
- 'journal',
23
- 'formula',
24
- 'draw',
25
- ] as const;
8
+ /** The `type` filters of GET /api/search: the kind registry's `searchable`
9
+ * (./kinds). Near the `search_nodes` tool's `type` enum, which also takes
10
+ * `secret` (an Admin-only tool argument). */
11
+ export const SEARCH_NODE_TYPES = kindsWhere<SearchableKind>((s) => s.searchable);
26
12
 
27
13
  export type SearchNodeType = (typeof SEARCH_NODE_TYPES)[number];
28
14
 
package/src/tree.ts CHANGED
@@ -15,25 +15,19 @@ import type { AccessLevel } from './dto/access';
15
15
  import type { GrantWorkspaceRef, ListWorkspace } from './dto/grants';
16
16
  import type { TaskStatus } from './dto/rows';
17
17
  import type { AppTint } from './app-nav';
18
+ import {
19
+ ITEM_KINDS,
20
+ ITEM_KIND_NAMES,
21
+ treeKindsBy,
22
+ type KindTreeSpec,
23
+ type RegistryTreeKind,
24
+ } from './kinds';
18
25
 
19
- /** The kinds the tree serves, in navigation order. */
20
- export const TREE_KINDS = [
21
- 'files',
22
- 'notes',
23
- 'pages',
24
- 'draw',
25
- 'tables',
26
- 'formulas',
27
- 'apps',
28
- 'tasks',
29
- 'events',
30
- 'contacts',
31
- 'secrets',
32
- // Recall v2's maps (its own content type, not pages). Appended last so no
33
- // earlier kind's index moves. Served since the Recall screen (R3).
34
- 'recall',
35
- ] as const;
36
- export type TreeKind = (typeof TREE_KINDS)[number];
26
+ /** The kinds the tree serves, in navigation order: derived from the kind
27
+ * registry (./kinds, each kind's `tree.order`). A jackdaw contract: the
28
+ * order never changes (Recall, appended last, kept every earlier index). */
29
+ export const TREE_KINDS = treeKindsBy('order') as readonly RegistryTreeKind[];
30
+ export type TreeKind = RegistryTreeKind;
37
31
 
38
32
  export function isTreeKind(v: unknown): v is TreeKind {
39
33
  return typeof v === 'string' && (TREE_KINDS as readonly string[]).includes(v);
@@ -77,97 +71,25 @@ export type TreeKindSpec = {
77
71
  sorts: readonly TreeSort[];
78
72
  };
79
73
 
80
- export const TREE_KIND_SPECS: Readonly<Record<TreeKind, TreeKindSpec>> = {
81
- files: {
82
- kind: 'files',
83
- root: 'files',
84
- nodeType: 'file',
85
- shareable: true,
86
- sorts: ['name', 'updated'],
87
- },
88
- notes: {
89
- kind: 'notes',
90
- root: 'notes',
91
- nodeType: 'note',
92
- shareable: true,
93
- sorts: ['updated', 'name'],
94
- },
95
- pages: {
96
- kind: 'pages',
97
- root: 'pages',
98
- nodeType: 'page',
99
- shareable: true,
100
- sorts: ['updated', 'name'],
101
- },
102
- draw: {
103
- kind: 'draw',
104
- root: 'draw',
105
- nodeType: 'draw',
106
- shareable: true,
107
- sorts: ['updated', 'name'],
108
- },
109
- tables: {
110
- kind: 'tables',
111
- root: 'tables',
112
- nodeType: 'table',
113
- shareable: true,
114
- sorts: ['updated', 'name'],
115
- },
116
- formulas: {
117
- kind: 'formulas',
118
- root: 'formulas',
119
- nodeType: 'formula',
120
- shareable: true,
121
- sorts: ['name', 'updated'],
122
- },
123
- apps: {
124
- kind: 'apps',
125
- root: 'apps',
126
- nodeType: 'app',
127
- shareable: true,
128
- sorts: ['name', 'updated'],
129
- },
130
- tasks: {
131
- kind: 'tasks',
132
- root: 'tasks',
133
- nodeType: 'task',
134
- shareable: false,
135
- sorts: ['due', 'updated', 'name'],
136
- },
137
- events: {
138
- kind: 'events',
139
- root: 'events',
140
- nodeType: 'event',
141
- shareable: false,
142
- sorts: ['start', 'name'],
143
- },
144
- contacts: {
145
- kind: 'contacts',
146
- root: 'contacts',
147
- nodeType: 'contact',
148
- shareable: false,
149
- sorts: ['name', 'updated'],
150
- },
151
- secrets: {
152
- kind: 'secrets',
153
- root: 'secrets',
154
- nodeType: 'secret',
155
- shareable: false,
156
- sorts: ['name', 'updated'],
157
- },
158
- // The tree lists maps only; a map's cards are recall_nodes rows, never
159
- // nodes, so they never appear as items.
160
- // Not shareable YET: a map is granted beyond Admin only once Recall R6
161
- // lets a workspace's assistant read maps. Until then the tree must not
162
- // offer it.
163
- recall: {
164
- kind: 'recall',
165
- root: 'recall',
166
- nodeType: 'recall',
167
- shareable: false,
168
- sorts: ['name', 'updated'],
169
- },
170
- };
74
+ /** Derived from the kind registry (./kinds): one spec per tree kind, keyed
75
+ * in navigation order. */
76
+ export const TREE_KIND_SPECS: Readonly<Record<TreeKind, TreeKindSpec>> = Object.fromEntries(
77
+ ITEM_KIND_NAMES.flatMap((nodeType) => {
78
+ const tree: KindTreeSpec | null = ITEM_KINDS[nodeType].tree;
79
+ return tree ? [{ tree, nodeType }] : [];
80
+ })
81
+ .sort((a, b) => a.tree.order - b.tree.order)
82
+ .map(({ tree, nodeType }): [string, TreeKindSpec] => [
83
+ tree.kind,
84
+ {
85
+ kind: tree.kind as TreeKind,
86
+ root: tree.root,
87
+ nodeType,
88
+ shareable: tree.shareable,
89
+ sorts: tree.sorts,
90
+ },
91
+ ]),
92
+ ) as Record<TreeKind, TreeKindSpec>;
171
93
 
172
94
  /** One folder as the tree shows it. Counts are what THIS reader can see. */
173
95
  export type TreeFolder = {