@crossworks/client-types 0.300.2 → 0.300.8

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.8",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -77,6 +77,29 @@ export type MemberHomeApp = {
77
77
  export type MemberHomeData<THub = unknown> =
78
78
  { homeApp: MemberHomeApp; hub: THub } | { homeApp: null; hub: null };
79
79
 
80
+ /**
81
+ * GET /api/home?ws=<id>: the landing page of a workspace, for every login
82
+ * (task d78e7bde). Without `ws` it is "All my workspaces": the Built-in
83
+ * home over every workspace of the login, never a home app.
84
+ *
85
+ * `ws` echoes the workspace asked for (null: all). `homeApp` is the
86
+ * workspace's home app when the workspace still holds it, its published
87
+ * build is green and the login may run it; null means the Built-in home.
88
+ * `hub` is always there: the data of the Built-in home and what a home
89
+ * app's `host.hub.get()` answers, read in the login's scope and kept to
90
+ * what the workspace reads (sections are its newest pages, counts its items
91
+ * per kind, apps the other apps it holds that the login may run). A
92
+ * workspace the login may not name is 404 `{ error, code: 'not_found' }`.
93
+ *
94
+ * `THub` is `HubData` from `@mantle/share-ui/app-bridge-protocol` (see
95
+ * MemberHomeData).
96
+ */
97
+ export type WorkspaceHomeData<THub = unknown> = {
98
+ ws: string | null;
99
+ homeApp: MemberHomeApp | null;
100
+ hub: THub;
101
+ };
102
+
80
103
  // ── Admin: member chats ──────────────────────────────────────────────────
81
104
 
82
105
  /** GET /api/team-admin/member-chats: one member login and its chat. */
package/src/dto/rows.ts CHANGED
@@ -184,7 +184,8 @@ export type AppRow = {
184
184
  * has never been shared / the share is revoked (owner-only).
185
185
  */
186
186
  shareMode: ShareMode | null;
187
- /** Whether this app is the designated Team Hub (prefs.teamHubAppId). */
187
+ /** Whether this app is the home app of a workspace the reader sees (its
188
+ * landing page, set on the workspace screen; task d78e7bde). */
188
189
  isHub: boolean;
189
190
  /** A live open public link exists (not revoked, not expired): the
190
191
  * "Public" pill. Absent from a brain before W5b2. */
@@ -567,11 +568,10 @@ export type ProfilePreferences = {
567
568
  * group grant, so the switch can't be bypassed by a manifest change. Flip it
568
569
  * from the Team admin surface. */
569
570
  teamPrivateReads?: boolean;
570
- /** Node id of the mini-app designated as the members' HOME APP (the name
571
- * is from the retired /team hub). A member login's home renders it
572
- * full-bleed while it has a green published build and is at a level
573
- * members may run (`resolveMemberHomeApp`, member-apps.ts); the built-in
574
- * member home is the fallback. No share link is involved. Read via
571
+ /** RETIRED (task d78e7bde, migration 0276): the brain-wide members' home
572
+ * app. 0276 made it the Team workspace's home app
573
+ * (workspaces.landing_app_id); nothing reads it any more. It stays in
574
+ * the stored preferences so a rollback finds it. Read via
575
575
  * projectTeamHubAppId, never raw. */
576
576
  teamHubAppId?: string;
577
577
  /** The email account client sign-in codes are sent from (client logins
@@ -92,16 +92,38 @@ export type WorkspaceDetailResponse = {
92
92
  /** The assistant has history (threads or messages): it cannot be swapped
93
93
  * or removed, only cloned to use elsewhere. */
94
94
  hasHistory: boolean;
95
+ /** The workspace's landing page (task d78e7bde): its home app, or null
96
+ * for the Built-in home. Absent from an older brain. */
97
+ landingApp?: WorkspaceLandingApp | null;
98
+ };
99
+
100
+ /**
101
+ * A workspace's home app, as its screen shows it. `held`: the workspace
102
+ * still holds the app; false is a pointer left from before (the landing
103
+ * page shows the Built-in home), with no id or title: only a clear
104
+ * (`landingAppId: null`) makes sense then. `appId` and `title` are named
105
+ * only to a user of the workspace (an Admin user outside it gets null for
106
+ * both). `ready`: a green published build, so the landing page shows it.
107
+ */
108
+ export type WorkspaceLandingApp = {
109
+ held: boolean;
110
+ appId: string | null;
111
+ title: string | null;
112
+ ready: boolean;
95
113
  };
96
114
 
97
115
  /** PATCH /api/workspaces/:id. Name and description: a Moderator or an Admin
98
116
  * user. Contact and assistant: an Admin user (assistant: area
99
- * 'assistants'). `null` clears. Answers the updated Workspace. */
117
+ * 'assistants'). The landing page (`landingAppId`): a Moderator of this
118
+ * workspace only, to an app the workspace holds with a green published
119
+ * build (409 `landing_app_not_held` or `landing_app_not_ready`). `null`
120
+ * clears (the Built-in home). Answers the updated Workspace. */
100
121
  export type WorkspacePatchBody = {
101
122
  name?: string;
102
123
  description?: string;
103
124
  contactNodeId?: string | null;
104
125
  assistantId?: string | null;
126
+ landingAppId?: string | null;
105
127
  };
106
128
 
107
129
  /** GET /api/workspaces/:id/archive-preview: what archiving would do.
@@ -203,6 +225,8 @@ export type WorkspaceErrorCode =
203
225
  | 'assistant_has_history'
204
226
  | 'assistant_in_use'
205
227
  | 'resource_exists'
228
+ | 'landing_app_not_held'
229
+ | 'landing_app_not_ready'
206
230
  | 'rate_limited';
207
231
 
208
232
  export type WorkspaceError = { error: string; code: WorkspaceErrorCode };
package/src/index.ts CHANGED
@@ -94,6 +94,7 @@ export type {
94
94
  MemberChatsResponse,
95
95
  MemberHomeApp,
96
96
  MemberHomeData,
97
+ WorkspaceHomeData,
97
98
  } from './dto/member-apps';
98
99
  export type {
99
100
  MemberInviteAccepted,
@@ -192,6 +193,7 @@ export type {
192
193
  WorkspaceDetailResponse,
193
194
  WorkspaceError,
194
195
  WorkspaceErrorCode,
196
+ WorkspaceLandingApp,
195
197
  WorkspaceListResponse,
196
198
  WorkspacePatchBody,
197
199
  WorkspaceResource,
@@ -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 = {