@kernhq/module-quire 0.2.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.
Files changed (72) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +71 -0
  3. package/dist/client/rank.d.ts +42 -0
  4. package/dist/client/rank.d.ts.map +1 -0
  5. package/dist/client/rank.js +97 -0
  6. package/dist/client/rank.js.map +1 -0
  7. package/dist/contract/capabilities.d.ts +35 -0
  8. package/dist/contract/capabilities.d.ts.map +1 -0
  9. package/dist/contract/capabilities.js +35 -0
  10. package/dist/contract/capabilities.js.map +1 -0
  11. package/dist/contract/events.d.ts +58 -0
  12. package/dist/contract/events.d.ts.map +1 -0
  13. package/dist/contract/events.js +18 -0
  14. package/dist/contract/events.js.map +1 -0
  15. package/dist/contract/index.d.ts +6 -0
  16. package/dist/contract/index.d.ts.map +1 -0
  17. package/dist/contract/index.js +6 -0
  18. package/dist/contract/index.js.map +1 -0
  19. package/dist/contract/models.d.ts +95 -0
  20. package/dist/contract/models.d.ts.map +1 -0
  21. package/dist/contract/models.js +82 -0
  22. package/dist/contract/models.js.map +1 -0
  23. package/dist/contract/permissions.d.ts +51 -0
  24. package/dist/contract/permissions.d.ts.map +1 -0
  25. package/dist/contract/permissions.js +59 -0
  26. package/dist/contract/permissions.js.map +1 -0
  27. package/dist/contract/router.d.ts +713 -0
  28. package/dist/contract/router.d.ts.map +1 -0
  29. package/dist/contract/router.js +109 -0
  30. package/dist/contract/router.js.map +1 -0
  31. package/dist/server/_impl.d.ts +815 -0
  32. package/dist/server/_impl.d.ts.map +1 -0
  33. package/dist/server/_impl.js +189 -0
  34. package/dist/server/_impl.js.map +1 -0
  35. package/dist/server/index.d.ts +3 -0
  36. package/dist/server/index.d.ts.map +1 -0
  37. package/dist/server/index.js +87 -0
  38. package/dist/server/index.js.map +1 -0
  39. package/dist/server/schema.d.ts +540 -0
  40. package/dist/server/schema.d.ts.map +1 -0
  41. package/dist/server/schema.js +86 -0
  42. package/dist/server/schema.js.map +1 -0
  43. package/dist/server/services/access.d.ts +78 -0
  44. package/dist/server/services/access.d.ts.map +1 -0
  45. package/dist/server/services/access.js +96 -0
  46. package/dist/server/services/access.js.map +1 -0
  47. package/dist/server/services/index.d.ts +15 -0
  48. package/dist/server/services/index.d.ts.map +1 -0
  49. package/dist/server/services/index.js +22 -0
  50. package/dist/server/services/index.js.map +1 -0
  51. package/dist/server/services/pages.d.ts +190 -0
  52. package/dist/server/services/pages.d.ts.map +1 -0
  53. package/dist/server/services/pages.js +242 -0
  54. package/dist/server/services/pages.js.map +1 -0
  55. package/dist/server/services/spaces.d.ts +95 -0
  56. package/dist/server/services/spaces.d.ts.map +1 -0
  57. package/dist/server/services/spaces.js +100 -0
  58. package/dist/server/services/spaces.js.map +1 -0
  59. package/migrations/0000_init.sql +43 -0
  60. package/migrations/0001_rls.sql +17 -0
  61. package/migrations/meta/0000_snapshot.json +358 -0
  62. package/migrations/meta/_journal.json +20 -0
  63. package/package.json +78 -0
  64. package/src/client/index.ts +67 -0
  65. package/src/client/rank.test.ts +92 -0
  66. package/src/client/rank.ts +100 -0
  67. package/src/contract/capabilities.ts +36 -0
  68. package/src/contract/events.ts +22 -0
  69. package/src/contract/index.ts +5 -0
  70. package/src/contract/models.ts +93 -0
  71. package/src/contract/permissions.ts +59 -0
  72. package/src/contract/router.ts +121 -0
@@ -0,0 +1,358 @@
1
+ {
2
+ "id": "73e5ffbb-8a14-4b41-bdfd-776e51bec7d7",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "mod_quire.pages": {
8
+ "name": "pages",
9
+ "schema": "mod_quire",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "uuidv7()"
17
+ },
18
+ "workspace_id": {
19
+ "name": "workspace_id",
20
+ "type": "uuid",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "space_id": {
25
+ "name": "space_id",
26
+ "type": "uuid",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "parent_id": {
31
+ "name": "parent_id",
32
+ "type": "uuid",
33
+ "primaryKey": false,
34
+ "notNull": false
35
+ },
36
+ "position": {
37
+ "name": "position",
38
+ "type": "text",
39
+ "primaryKey": false,
40
+ "notNull": true
41
+ },
42
+ "kind": {
43
+ "name": "kind",
44
+ "type": "text",
45
+ "primaryKey": false,
46
+ "notNull": true,
47
+ "default": "'page'"
48
+ },
49
+ "title": {
50
+ "name": "title",
51
+ "type": "text",
52
+ "primaryKey": false,
53
+ "notNull": true,
54
+ "default": "''"
55
+ },
56
+ "icon": {
57
+ "name": "icon",
58
+ "type": "text",
59
+ "primaryKey": false,
60
+ "notNull": false
61
+ },
62
+ "cover_url": {
63
+ "name": "cover_url",
64
+ "type": "text",
65
+ "primaryKey": false,
66
+ "notNull": false
67
+ },
68
+ "published_version_id": {
69
+ "name": "published_version_id",
70
+ "type": "uuid",
71
+ "primaryKey": false,
72
+ "notNull": false
73
+ },
74
+ "has_unpublished_changes": {
75
+ "name": "has_unpublished_changes",
76
+ "type": "boolean",
77
+ "primaryKey": false,
78
+ "notNull": true,
79
+ "default": false
80
+ },
81
+ "text": {
82
+ "name": "text",
83
+ "type": "text",
84
+ "primaryKey": false,
85
+ "notNull": true,
86
+ "default": "''"
87
+ },
88
+ "created_by": {
89
+ "name": "created_by",
90
+ "type": "uuid",
91
+ "primaryKey": false,
92
+ "notNull": false
93
+ },
94
+ "updated_by": {
95
+ "name": "updated_by",
96
+ "type": "uuid",
97
+ "primaryKey": false,
98
+ "notNull": false
99
+ },
100
+ "created_at": {
101
+ "name": "created_at",
102
+ "type": "timestamp with time zone",
103
+ "primaryKey": false,
104
+ "notNull": true,
105
+ "default": "now()"
106
+ },
107
+ "updated_at": {
108
+ "name": "updated_at",
109
+ "type": "timestamp with time zone",
110
+ "primaryKey": false,
111
+ "notNull": true,
112
+ "default": "now()"
113
+ },
114
+ "archived_at": {
115
+ "name": "archived_at",
116
+ "type": "timestamp with time zone",
117
+ "primaryKey": false,
118
+ "notNull": false
119
+ },
120
+ "deleted_at": {
121
+ "name": "deleted_at",
122
+ "type": "timestamp with time zone",
123
+ "primaryKey": false,
124
+ "notNull": false
125
+ }
126
+ },
127
+ "indexes": {
128
+ "pages_ws_space_idx": {
129
+ "name": "pages_ws_space_idx",
130
+ "columns": [
131
+ {
132
+ "expression": "workspace_id",
133
+ "isExpression": false,
134
+ "asc": true,
135
+ "nulls": "last"
136
+ },
137
+ {
138
+ "expression": "space_id",
139
+ "isExpression": false,
140
+ "asc": true,
141
+ "nulls": "last"
142
+ },
143
+ {
144
+ "expression": "position",
145
+ "isExpression": false,
146
+ "asc": true,
147
+ "nulls": "last"
148
+ }
149
+ ],
150
+ "isUnique": false,
151
+ "concurrently": false,
152
+ "method": "btree",
153
+ "with": {}
154
+ },
155
+ "pages_ws_parent_idx": {
156
+ "name": "pages_ws_parent_idx",
157
+ "columns": [
158
+ {
159
+ "expression": "workspace_id",
160
+ "isExpression": false,
161
+ "asc": true,
162
+ "nulls": "last"
163
+ },
164
+ {
165
+ "expression": "parent_id",
166
+ "isExpression": false,
167
+ "asc": true,
168
+ "nulls": "last"
169
+ },
170
+ {
171
+ "expression": "position",
172
+ "isExpression": false,
173
+ "asc": true,
174
+ "nulls": "last"
175
+ }
176
+ ],
177
+ "isUnique": false,
178
+ "concurrently": false,
179
+ "method": "btree",
180
+ "with": {}
181
+ },
182
+ "pages_ws_updated_idx": {
183
+ "name": "pages_ws_updated_idx",
184
+ "columns": [
185
+ {
186
+ "expression": "workspace_id",
187
+ "isExpression": false,
188
+ "asc": true,
189
+ "nulls": "last"
190
+ },
191
+ {
192
+ "expression": "updated_at",
193
+ "isExpression": false,
194
+ "asc": true,
195
+ "nulls": "last"
196
+ }
197
+ ],
198
+ "isUnique": false,
199
+ "concurrently": false,
200
+ "method": "btree",
201
+ "with": {}
202
+ }
203
+ },
204
+ "foreignKeys": {},
205
+ "compositePrimaryKeys": {},
206
+ "uniqueConstraints": {},
207
+ "policies": {},
208
+ "checkConstraints": {},
209
+ "isRLSEnabled": false
210
+ },
211
+ "mod_quire.spaces": {
212
+ "name": "spaces",
213
+ "schema": "mod_quire",
214
+ "columns": {
215
+ "id": {
216
+ "name": "id",
217
+ "type": "uuid",
218
+ "primaryKey": true,
219
+ "notNull": true,
220
+ "default": "uuidv7()"
221
+ },
222
+ "workspace_id": {
223
+ "name": "workspace_id",
224
+ "type": "uuid",
225
+ "primaryKey": false,
226
+ "notNull": true
227
+ },
228
+ "key": {
229
+ "name": "key",
230
+ "type": "text",
231
+ "primaryKey": false,
232
+ "notNull": true
233
+ },
234
+ "name": {
235
+ "name": "name",
236
+ "type": "text",
237
+ "primaryKey": false,
238
+ "notNull": true
239
+ },
240
+ "description": {
241
+ "name": "description",
242
+ "type": "text",
243
+ "primaryKey": false,
244
+ "notNull": true,
245
+ "default": "''"
246
+ },
247
+ "icon": {
248
+ "name": "icon",
249
+ "type": "text",
250
+ "primaryKey": false,
251
+ "notNull": false
252
+ },
253
+ "visibility": {
254
+ "name": "visibility",
255
+ "type": "text",
256
+ "primaryKey": false,
257
+ "notNull": true,
258
+ "default": "'open'"
259
+ },
260
+ "homepage_id": {
261
+ "name": "homepage_id",
262
+ "type": "uuid",
263
+ "primaryKey": false,
264
+ "notNull": false
265
+ },
266
+ "created_by": {
267
+ "name": "created_by",
268
+ "type": "uuid",
269
+ "primaryKey": false,
270
+ "notNull": false
271
+ },
272
+ "created_at": {
273
+ "name": "created_at",
274
+ "type": "timestamp with time zone",
275
+ "primaryKey": false,
276
+ "notNull": true,
277
+ "default": "now()"
278
+ },
279
+ "updated_at": {
280
+ "name": "updated_at",
281
+ "type": "timestamp with time zone",
282
+ "primaryKey": false,
283
+ "notNull": true,
284
+ "default": "now()"
285
+ },
286
+ "archived_at": {
287
+ "name": "archived_at",
288
+ "type": "timestamp with time zone",
289
+ "primaryKey": false,
290
+ "notNull": false
291
+ }
292
+ },
293
+ "indexes": {
294
+ "spaces_ws_key_uq": {
295
+ "name": "spaces_ws_key_uq",
296
+ "columns": [
297
+ {
298
+ "expression": "workspace_id",
299
+ "isExpression": false,
300
+ "asc": true,
301
+ "nulls": "last"
302
+ },
303
+ {
304
+ "expression": "key",
305
+ "isExpression": false,
306
+ "asc": true,
307
+ "nulls": "last"
308
+ }
309
+ ],
310
+ "isUnique": true,
311
+ "concurrently": false,
312
+ "method": "btree",
313
+ "with": {}
314
+ },
315
+ "spaces_ws_idx": {
316
+ "name": "spaces_ws_idx",
317
+ "columns": [
318
+ {
319
+ "expression": "workspace_id",
320
+ "isExpression": false,
321
+ "asc": true,
322
+ "nulls": "last"
323
+ },
324
+ {
325
+ "expression": "created_at",
326
+ "isExpression": false,
327
+ "asc": true,
328
+ "nulls": "last"
329
+ }
330
+ ],
331
+ "isUnique": false,
332
+ "concurrently": false,
333
+ "method": "btree",
334
+ "with": {}
335
+ }
336
+ },
337
+ "foreignKeys": {},
338
+ "compositePrimaryKeys": {},
339
+ "uniqueConstraints": {},
340
+ "policies": {},
341
+ "checkConstraints": {},
342
+ "isRLSEnabled": false
343
+ }
344
+ },
345
+ "enums": {},
346
+ "schemas": {
347
+ "mod_quire": "mod_quire"
348
+ },
349
+ "sequences": {},
350
+ "roles": {},
351
+ "policies": {},
352
+ "views": {},
353
+ "_meta": {
354
+ "columns": {},
355
+ "schemas": {},
356
+ "tables": {}
357
+ }
358
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1787566823449,
9
+ "tag": "0000_init",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1787566824449,
16
+ "tag": "0001_rls",
17
+ "breakpoints": true
18
+ }
19
+ ]
20
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@kernhq/module-quire",
3
+ "version": "0.2.0",
4
+ "description": "Kern Quire: collaborative documents, spaces and page trees",
5
+ "homepage": "https://kernaio.com",
6
+ "license": "AGPL-3.0-only",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/KernAIO/modules",
11
+ "directory": "packages/quire"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "migrations",
19
+ "src/client",
20
+ "src/contract"
21
+ ],
22
+ "exports": {
23
+ "./contract": {
24
+ "types": "./dist/contract/index.d.ts",
25
+ "import": "./dist/contract/index.js",
26
+ "default": "./dist/contract/index.js"
27
+ },
28
+ "./server": {
29
+ "types": "./dist/server/index.d.ts",
30
+ "import": "./dist/server/index.js",
31
+ "default": "./dist/server/index.js"
32
+ },
33
+ "./client": {
34
+ "types": "./src/client/index.ts",
35
+ "svelte": "./src/client/index.ts",
36
+ "default": "./src/client/index.ts"
37
+ },
38
+ "./migrations": "./migrations",
39
+ "./client/*": {
40
+ "types": "./src/client/*",
41
+ "svelte": "./src/client/*",
42
+ "default": "./src/client/*"
43
+ }
44
+ },
45
+ "dependencies": {
46
+ "@kernhq/contracts": "^0.5.0",
47
+ "@kernhq/kernel": "^0.6.0",
48
+ "@kernhq/sdk": "^0.1.0",
49
+ "@orpc/contract": "^1.15.0",
50
+ "@orpc/server": "^1.15.0",
51
+ "drizzle-orm": "^0.45.0",
52
+ "zod": "^4.1.0"
53
+ },
54
+ "peerDependencies": {
55
+ "svelte": "^5.0.0"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "svelte": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "@kernhq/tsconfig": "^0.1.0",
64
+ "@types/node": "^24.0.0",
65
+ "@types/pg": "^8.15.0",
66
+ "drizzle-kit": "^0.31.0",
67
+ "pg": "^8.16.0",
68
+ "typescript": "~5.9.3",
69
+ "vitest": "^4.0.0"
70
+ },
71
+ "scripts": {
72
+ "build": "tsc -p tsconfig.json",
73
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
74
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
75
+ "test": "vitest run",
76
+ "db:generate": "drizzle-kit generate"
77
+ }
78
+ }
@@ -0,0 +1,67 @@
1
+ import { createModuleClient, type KernClientOptions } from '@kernhq/sdk'
2
+ import type { ContractRouterClient } from '@orpc/contract'
3
+ import type { PageNode, QuireContract } from '../contract/index.js'
4
+
5
+ /**
6
+ * The client half.
7
+ *
8
+ * Published as **source**, not compiled: the consumer builds the TypeScript and Svelte with its own
9
+ * toolchain, which is what lets `$state` in a module store stay reactive inside the app. Two
10
+ * consequences worth knowing before you edit anything here — nothing in this package compiles it,
11
+ * so `pnpm build` passes over a syntax error and only the app finds it; and `files` in package.json
12
+ * must cover every directory this entry reaches, contract source included.
13
+ *
14
+ * What lives here: the typed API client, and any logic that is about this module but not about a
15
+ * screen (formatting, grouping, parsing). What does not: the `defineClientModule` manifest and the
16
+ * Svelte components, which live in the app so their labels can go through its message catalogue.
17
+ * `pnpm new-module` generates both halves.
18
+ */
19
+ export type QuireApi = ContractRouterClient<QuireContract>
20
+
21
+ export function createQuireClient(opts: KernClientOptions): QuireApi {
22
+ return createModuleClient<QuireApi>(opts, 'quire')
23
+ }
24
+
25
+ export {
26
+ MODULE_ID,
27
+ type Page,
28
+ type PageKind,
29
+ type PageNode,
30
+ quirePermissions,
31
+ type Space,
32
+ type SpaceVisibility,
33
+ } from '../contract/index.js'
34
+ export * from './rank.js'
35
+
36
+ /** The permission keys, so the app gates on a constant rather than a string it retyped. */
37
+ export const QUIRE_PERMISSIONS = {
38
+ spaceView: 'quire.space.view',
39
+ spaceManage: 'quire.space.manage',
40
+ pageView: 'quire.page.view',
41
+ pageCreate: 'quire.page.create',
42
+ pageEdit: 'quire.page.edit',
43
+ pageDelete: 'quire.page.delete',
44
+ } as const
45
+
46
+ /**
47
+ * Build the sidebar tree from the flat, position-ordered list `pages.tree` returns.
48
+ *
49
+ * Isomorphic on purpose: the app draws it and the published-site renderer will need the same shape,
50
+ * and neither should re-derive it. Rows whose parent is missing — because it was archived and this
51
+ * caller asked for the live tree — are lifted to the top rather than dropped, so a page is never
52
+ * invisible because of where it happens to sit.
53
+ */
54
+ export interface PageTreeNode extends PageNode {
55
+ children: PageTreeNode[]
56
+ }
57
+
58
+ export function buildPageTree(nodes: readonly PageNode[]): PageTreeNode[] {
59
+ const byId = new Map<string, PageTreeNode>(nodes.map((n) => [n.id, { ...n, children: [] }]))
60
+ const roots: PageTreeNode[] = []
61
+ for (const node of byId.values()) {
62
+ const parent = node.parentId ? byId.get(node.parentId) : undefined
63
+ if (parent) parent.children.push(node)
64
+ else roots.push(node)
65
+ }
66
+ return roots
67
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The ordering invariants a page tree depends on.
3
+ *
4
+ * This algorithm is duplicated from the tracker on purpose — modules do not import one another — so
5
+ * it carries its own test rather than trusting that the original still holds.
6
+ */
7
+ import { describe, expect, it } from 'vitest'
8
+ import { initialRank, isValidRank, rankBetween, rankSequence } from './rank.js'
9
+
10
+ describe('rankBetween', () => {
11
+ it('lands strictly between its neighbours', () => {
12
+ const a = initialRank()
13
+ const c = rankBetween(a, null)
14
+ const b = rankBetween(a, c)
15
+ expect(a < b).toBe(true)
16
+ expect(b < c).toBe(true)
17
+ })
18
+
19
+ it('opens either end', () => {
20
+ const first = initialRank()
21
+ expect(rankBetween(null, first) < first).toBe(true)
22
+ expect(rankBetween(first, null) > first).toBe(true)
23
+ })
24
+
25
+ it('refuses neighbours that are the wrong way round', () => {
26
+ const a = initialRank()
27
+ const b = rankBetween(a, null)
28
+ expect(() => rankBetween(b, a)).toThrow()
29
+ })
30
+
31
+ it('keeps finding room, however many times the same gap is split', () => {
32
+ // The failure this guards against is a key that runs out of precision and starts colliding, so
33
+ // two pages dropped in the same place end up in an order nobody chose.
34
+ let lo = initialRank()
35
+ const hi = rankBetween(lo, null)
36
+ const seen = new Set<string>([lo, hi])
37
+ for (let i = 0; i < 200; i++) {
38
+ const mid = rankBetween(lo, hi)
39
+ expect(lo < mid, `${lo} < ${mid} failed at split ${i}`).toBe(true)
40
+ expect(mid < hi, `${mid} < ${hi} failed at split ${i}`).toBe(true)
41
+ expect(seen.has(mid), `collision at split ${i}`).toBe(false)
42
+ expect(isValidRank(mid)).toBe(true)
43
+ seen.add(mid)
44
+ lo = mid
45
+ }
46
+ })
47
+
48
+ it('never mints a key ending in the lowest digit, so there is always room below', () => {
49
+ let prev: string | null = null
50
+ for (let i = 0; i < 50; i++) {
51
+ const next: string = rankBetween(prev, null)
52
+ expect(isValidRank(next)).toBe(true)
53
+ prev = next
54
+ }
55
+ })
56
+ })
57
+
58
+ describe('inserting at the front, over and over', () => {
59
+ it('keeps finding room instead of looping until the process dies', () => {
60
+ // The regression this guards: an absent lower bound was treated as "below digit 0", so the
61
+ // fifth insertion at the top of a list minted '0', and the sixth searched for something below
62
+ // it for ever — growing a string until the heap gave out. It took five pages created at the top
63
+ // of one space to hit it.
64
+ let first = initialRank()
65
+ const seen = new Set<string>([first])
66
+ for (let i = 0; i < 200; i++) {
67
+ const next = rankBetween(null, first)
68
+ expect(next < first, `${next} < ${first} failed at insertion ${i}`).toBe(true)
69
+ expect(isValidRank(next), `${next} is not a valid key at insertion ${i}`).toBe(true)
70
+ expect(seen.has(next), `collision at insertion ${i}`).toBe(false)
71
+ seen.add(next)
72
+ first = next
73
+ }
74
+ })
75
+
76
+ it('never mints a key with no room below it', () => {
77
+ let first = initialRank()
78
+ for (let i = 0; i < 50; i++) {
79
+ first = rankBetween(null, first)
80
+ expect(first.endsWith('0'), `${first} ends in the lowest digit`).toBe(false)
81
+ }
82
+ })
83
+ })
84
+
85
+ describe('rankSequence', () => {
86
+ it('produces keys that already sort into the order they were asked for', () => {
87
+ const keys = rankSequence(25)
88
+ expect(keys).toHaveLength(25)
89
+ expect([...keys].sort()).toEqual(keys)
90
+ expect(new Set(keys).size).toBe(25)
91
+ })
92
+ })