@kernhq/module-quire 0.10.9 → 0.11.1

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 (53) hide show
  1. package/dist/contract/models.d.ts +87 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +73 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts.map +1 -1
  6. package/dist/contract/permissions.js +32 -0
  7. package/dist/contract/permissions.js.map +1 -1
  8. package/dist/contract/router.d.ts +645 -0
  9. package/dist/contract/router.d.ts.map +1 -1
  10. package/dist/contract/router.js +137 -2
  11. package/dist/contract/router.js.map +1 -1
  12. package/dist/server/_impl.d.ts +877 -0
  13. package/dist/server/_impl.d.ts.map +1 -1
  14. package/dist/server/_impl.js +127 -1
  15. package/dist/server/_impl.js.map +1 -1
  16. package/dist/server/schema.d.ts +482 -1
  17. package/dist/server/schema.d.ts.map +1 -1
  18. package/dist/server/schema.js +115 -1
  19. package/dist/server/schema.js.map +1 -1
  20. package/dist/server/services/index.d.ts +3 -0
  21. package/dist/server/services/index.d.ts.map +1 -1
  22. package/dist/server/services/index.js +3 -0
  23. package/dist/server/services/index.js.map +1 -1
  24. package/dist/server/services/organisation.d.ts +117 -0
  25. package/dist/server/services/organisation.d.ts.map +1 -0
  26. package/dist/server/services/organisation.js +319 -0
  27. package/dist/server/services/organisation.js.map +1 -0
  28. package/dist/server/services/pages.d.ts +16 -1
  29. package/dist/server/services/pages.d.ts.map +1 -1
  30. package/dist/server/services/pages.js +62 -3
  31. package/dist/server/services/pages.js.map +1 -1
  32. package/migrations/0007_organisation.sql +114 -0
  33. package/migrations/meta/0007_snapshot.json +1588 -0
  34. package/migrations/meta/_journal.json +7 -0
  35. package/package.json +1 -1
  36. package/src/client/components/ConfirmDialog.svelte +123 -0
  37. package/src/client/components/FavoriteStar.svelte +81 -0
  38. package/src/client/components/LabelChip.svelte +46 -0
  39. package/src/client/components/LabelManager.svelte +336 -0
  40. package/src/client/components/PageLabels.svelte +158 -0
  41. package/src/client/components/SidebarFavorites.svelte +262 -0
  42. package/src/client/components/SidebarRecents.svelte +98 -0
  43. package/src/client/components/SidebarSpaces.svelte +266 -1
  44. package/src/client/i18n.ts +387 -0
  45. package/src/client/index.ts +8 -0
  46. package/src/client/mock.ts +306 -0
  47. package/src/client/module.ts +18 -0
  48. package/src/client/pages/PageView.svelte +284 -9
  49. package/src/client/pages/TrashPage.svelte +378 -0
  50. package/src/client/query.ts +24 -0
  51. package/src/contract/models.ts +83 -0
  52. package/src/contract/permissions.ts +36 -0
  53. package/src/contract/router.ts +153 -1
@@ -5,18 +5,24 @@ import type {
5
5
  Database,
6
6
  DatabaseRef,
7
7
  Row as DatabaseRow,
8
+ FavoriteEntry,
9
+ Label,
10
+ LabelColour,
8
11
  Page,
9
12
  PageNode,
10
13
  PageVersion,
11
14
  Property,
12
15
  PropertyConfig,
13
16
  PropertyType,
17
+ RecentEntry,
14
18
  RowRef,
15
19
  Space,
16
20
  View,
17
21
  ViewConfig,
18
22
  ViewKind,
23
+ WatchState,
19
24
  } from '../contract/index.js'
25
+ import { rankBetween, rankSequence } from './rank.js'
20
26
 
21
27
  /**
22
28
  * The in-memory quire API.
@@ -150,6 +156,16 @@ export function createMockQuireApi() {
150
156
  page(103, uid(1), 'Your first week', 'ba', 102),
151
157
  page(104, uid(1), 'Time off', 'bb', 102),
152
158
  page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
159
+ /*
160
+ * A subtree in the trash, because that is the case the trash screen exists for.
161
+ *
162
+ * Deleting a page takes everything under it, and the flat listing the server answers with has
163
+ * one row per page — so a demo whose trash holds a single orphan never exercises the grouping,
164
+ * which is the whole difference between "one page was deleted" and "three were". These two are
165
+ * a parent and its child, deleted together.
166
+ */
167
+ page(106, uid(1), 'Old expenses policy', 'ca', null, { deletedAt: iso(1728e5) }),
168
+ page(107, uid(1), 'Receipts', 'caa', 106, { deletedAt: iso(1728e5) }),
153
169
  page(110, uid(1), 'Onboarding tasks', 'd', null, { kind: 'database' }),
154
170
  page(201, uid(2), 'Architecture', 'a'),
155
171
  page(202, uid(2), 'Runbooks', 'b'),
@@ -391,6 +407,70 @@ export function createMockQuireApi() {
391
407
  comment(161, uid(101), uid(160), uid(160), ME, 'Good point — I will link to it from here.', 36e5),
392
408
  ]
393
409
 
410
+ /**
411
+ * How a space is organised, and what one person has made of it.
412
+ *
413
+ * Seeded rather than left empty for the same reason the versions and comments above are: a screen
414
+ * with no data in the demo is a screen the end-to-end sweep cannot see, so an empty favourites
415
+ * group and an empty label picker would ship without anything ever rendering the populated case.
416
+ *
417
+ * Two spaces on purpose. Labels belong to a space — two teams both wanting "Draft" should not
418
+ * have to agree on what it means — and a seed with one space's vocabulary would let a bug that
419
+ * leaks labels across the boundary pass unnoticed.
420
+ */
421
+ const label = (n: number, spaceId: string, name: string, colour: LabelColour, msAgo: number): Label => ({
422
+ id: uid(n),
423
+ workspaceId: '' as Label['workspaceId'],
424
+ spaceId,
425
+ name,
426
+ colour,
427
+ createdAt: iso(msAgo),
428
+ })
429
+
430
+ const labels: Label[] = [
431
+ label(300, uid(1), 'Draft', 'warning', 8e7),
432
+ label(301, uid(1), 'Needs review', 'info', 79e6),
433
+ label(302, uid(1), 'Reference', 'purple', 78e6),
434
+ label(303, uid(2), 'ADR', 'slate', 77e6),
435
+ ]
436
+
437
+ /** page id → label ids. A page wears a set, and `pages.setLabels` replaces the whole of it. */
438
+ const pageLabels = new Map<string, string[]>([
439
+ [uid(101), [uid(302)]],
440
+ [uid(102), [uid(300), uid(301)]],
441
+ [uid(103), [uid(300)]],
442
+ [uid(201), [uid(303)]],
443
+ ])
444
+
445
+ /**
446
+ * One person's shortcuts, in the order they arranged them.
447
+ *
448
+ * Ranks come from the real `rankBetween` rather than from the plain sortable strings the rest of
449
+ * this file uses. Reordering favourites is a drag, so the mock is the only thing the interaction
450
+ * is ever tested against — and a second implementation of fractional indexing is a second place
451
+ * for it to be wrong. Three of them, across two spaces, because a favourite is a workspace list
452
+ * and a demo confined to one space would never show that.
453
+ */
454
+ const favouriteSeed = rankSequence(3)
455
+ const favorites: { pageId: string; position: string; createdAt: string }[] = [
456
+ { pageId: uid(101), position: favouriteSeed[0] as string, createdAt: iso(72e5) },
457
+ { pageId: uid(105), position: favouriteSeed[1] as string, createdAt: iso(54e5) },
458
+ { pageId: uid(202), position: favouriteSeed[2] as string, createdAt: iso(36e5) },
459
+ ]
460
+
461
+ /** page id → the people watching it. Deliberately not the same list as the favourites above. */
462
+ const watchers = new Map<string, string[]>([
463
+ [uid(101), [ME, COLLEAGUE]],
464
+ [uid(102), [COLLEAGUE]],
465
+ ])
466
+
467
+ /** One row per page, bumped in place — never a visit log. */
468
+ const recents: { pageId: string; viewedAt: string }[] = [
469
+ { pageId: uid(102), viewedAt: iso(6e5) },
470
+ { pageId: uid(110), viewedAt: iso(18e5) },
471
+ { pageId: uid(201), viewedAt: iso(9e6) },
472
+ ]
473
+
394
474
  let seq = 900
395
475
  const nextId = () => uid(++seq)
396
476
  const strip = ({ _order, ...p }: Row): Page => p
@@ -472,6 +552,73 @@ export function createMockQuireApi() {
472
552
  throw notFound('View')
473
553
  }
474
554
 
555
+ const theLabel = (id: string): Label => {
556
+ const found = labels.find((l) => l.id === id)
557
+ if (!found) throw notFound('Label')
558
+ return found
559
+ }
560
+
561
+ /**
562
+ * The page fields a shortcut row draws — or nothing, when the page has been trashed or purged.
563
+ *
564
+ * The server composes these with a join to `pages`, which is why a favourite whose page is gone
565
+ * simply stops being drawn: the row survives (nothing cascades from a purge), and every read
566
+ * joins. Reproducing that here is the difference between a demo where trashing a favourited page
567
+ * quietly removes it from the sidebar and one where the sidebar keeps a shortcut to nothing.
568
+ */
569
+ const pageBitsOf = (pageId: string) => {
570
+ const row = pages.find((p) => p.id === pageId && !p.deletedAt)
571
+ return row ? { spaceId: row.spaceId, title: row.title, icon: row.icon, kind: row.kind } : null
572
+ }
573
+
574
+ const byPosition = (a: { position: string }, b: { position: string }) =>
575
+ a.position < b.position ? -1 : a.position > b.position ? 1 : 0
576
+
577
+ const favoriteList = (): FavoriteEntry[] =>
578
+ [...favorites].sort(byPosition).flatMap((f) => {
579
+ const bits = pageBitsOf(f.pageId)
580
+ if (!bits) return []
581
+ return [
582
+ {
583
+ workspaceId: '' as FavoriteEntry['workspaceId'],
584
+ userId: ME as FavoriteEntry['userId'],
585
+ pageId: f.pageId,
586
+ position: f.position,
587
+ createdAt: f.createdAt,
588
+ ...bits,
589
+ },
590
+ ]
591
+ })
592
+
593
+ const recentList = (limit: number): RecentEntry[] =>
594
+ [...recents]
595
+ .sort((a, b) => (a.viewedAt < b.viewedAt ? 1 : -1))
596
+ .flatMap((r) => {
597
+ const bits = pageBitsOf(r.pageId)
598
+ if (!bits) return []
599
+ return [
600
+ {
601
+ workspaceId: '' as RecentEntry['workspaceId'],
602
+ userId: ME as RecentEntry['userId'],
603
+ pageId: r.pageId,
604
+ viewedAt: r.viewedAt,
605
+ ...bits,
606
+ },
607
+ ]
608
+ })
609
+ .slice(0, limit)
610
+
611
+ const watchStateOf = (pageId: string): WatchState => {
612
+ const list = watchers.get(pageId) ?? []
613
+ return { watching: list.includes(ME), watchers: list as WatchState['watchers'] }
614
+ }
615
+
616
+ const labelsOn = (pageId: string): Label[] =>
617
+ (pageLabels.get(pageId) ?? []).flatMap((id) => {
618
+ const found = labels.find((l) => l.id === id)
619
+ return found ? [found] : []
620
+ })
621
+
475
622
  /**
476
623
  * A copy on the way out, because a real API answers with fresh JSON every time.
477
624
  *
@@ -750,8 +897,167 @@ export function createMockQuireApi() {
750
897
  purge: async ({ pageId }: { pageId: string }) => {
751
898
  const ids = new Set(subtree(pageId).map((r) => r.id))
752
899
  for (let i = pages.length - 1; i >= 0; i--) if (ids.has(pages[i]!.id)) pages.splice(i, 1)
900
+ /*
901
+ * The favourites, watches and labels of a purged page are deliberately left behind, because
902
+ * that is what the server does: there is no foreign key, so the rows survive and every read
903
+ * joins to `pages` — which is why they are invisible rather than broken. Cleaning them up
904
+ * here would hide the one thing worth noticing about that decision.
905
+ */
753
906
  return { ok: true as const, count: ids.size }
754
907
  },
908
+
909
+ setLabels: async ({ pageId, labelIds }: { pageId: string; labelIds: string[] }) => {
910
+ const row = found(pageId)
911
+ for (const id of labelIds) {
912
+ const label = theLabel(id)
913
+ // A label belongs to a space; putting another space's on a page would leak its vocabulary.
914
+ if (label.spaceId !== row.spaceId)
915
+ throw Object.assign(new Error('Every label has to be one this space declares'), {
916
+ code: 'BAD_REQUEST',
917
+ })
918
+ }
919
+ pageLabels.set(pageId, [...labelIds])
920
+ return labelsOn(pageId).map((l) => ({ ...l }))
921
+ },
922
+ },
923
+
924
+ /**
925
+ * The space's vocabulary. Names clash case-insensitively, as they do in the database — "Draft"
926
+ * beside "draft" in one picker is broken data rather than two labels — and the capitalisation
927
+ * somebody typed is what is kept.
928
+ */
929
+ labels: {
930
+ list: async ({ spaceId }: { spaceId: string }) =>
931
+ labels.filter((l) => l.spaceId === spaceId).map((l) => ({ ...l })),
932
+
933
+ forPage: async ({ pageId }: { pageId: string }) => labelsOn(pageId).map((l) => ({ ...l })),
934
+
935
+ create: async (input: { spaceId: string; name: string; colour?: LabelColour }) => {
936
+ const name = input.name.trim()
937
+ if (!name) throw Object.assign(new Error('A label needs a name'), { code: 'BAD_REQUEST' })
938
+ if (labels.some((l) => l.spaceId === input.spaceId && l.name.toLowerCase() === name.toLowerCase()))
939
+ throw Object.assign(new Error(`This space already has a label called "${name}"`), {
940
+ code: 'CONFLICT',
941
+ })
942
+ const made: Label = {
943
+ id: nextId(),
944
+ workspaceId: '' as Label['workspaceId'],
945
+ spaceId: input.spaceId,
946
+ name,
947
+ colour: input.colour ?? 'grey',
948
+ createdAt: new Date().toISOString(),
949
+ }
950
+ labels.push(made)
951
+ return { ...made }
952
+ },
953
+
954
+ update: async (input: { labelId: string; name?: string; colour?: LabelColour }) => {
955
+ const label = theLabel(input.labelId)
956
+ if (input.name !== undefined) {
957
+ const name = input.name.trim()
958
+ if (!name) throw Object.assign(new Error('A label needs a name'), { code: 'BAD_REQUEST' })
959
+ if (
960
+ labels.some(
961
+ (l) =>
962
+ l.id !== label.id &&
963
+ l.spaceId === label.spaceId &&
964
+ l.name.toLowerCase() === name.toLowerCase(),
965
+ )
966
+ )
967
+ throw Object.assign(new Error(`This space already has a label called "${name}"`), {
968
+ code: 'CONFLICT',
969
+ })
970
+ label.name = name
971
+ }
972
+ if (input.colour !== undefined) label.colour = input.colour
973
+ return { ...label }
974
+ },
975
+
976
+ remove: async ({ labelId }: { labelId: string }) => {
977
+ const label = theLabel(labelId)
978
+ labels.splice(labels.indexOf(label), 1)
979
+ // Off every page that wore it — a label nothing can name is not one anybody can remove.
980
+ for (const [pageId, ids] of pageLabels)
981
+ if (ids.includes(labelId))
982
+ pageLabels.set(
983
+ pageId,
984
+ ids.filter((id) => id !== labelId),
985
+ )
986
+ return { ok: true as const }
987
+ },
988
+ },
989
+
990
+ /**
991
+ * One person's own shortcuts. Every mutation answers with the whole ordered list, because a
992
+ * fractional-index reorder is only meaningful as an ordering — and because it saves the sidebar
993
+ * a refetch to redraw itself.
994
+ */
995
+ favorites: {
996
+ list: async () => favoriteList(),
997
+
998
+ add: async ({ pageId }: { pageId: string }) => {
999
+ found(pageId)
1000
+ // Starring the same page twice is the same star, not an error.
1001
+ if (!favorites.some((f) => f.pageId === pageId)) {
1002
+ const last = [...favorites].sort(byPosition).at(-1)?.position ?? null
1003
+ favorites.push({
1004
+ pageId,
1005
+ position: rankBetween(last, null),
1006
+ createdAt: new Date().toISOString(),
1007
+ })
1008
+ }
1009
+ return favoriteList()
1010
+ },
1011
+
1012
+ remove: async ({ pageId }: { pageId: string }) => {
1013
+ const at = favorites.findIndex((f) => f.pageId === pageId)
1014
+ if (at >= 0) favorites.splice(at, 1)
1015
+ return favoriteList()
1016
+ },
1017
+
1018
+ reorder: async ({ pageId, afterId = null }: { pageId: string; afterId?: string | null }) => {
1019
+ const moving = favorites.find((f) => f.pageId === pageId)
1020
+ if (!moving) throw notFound('Favourite')
1021
+ const rest = [...favorites].filter((f) => f.pageId !== pageId).sort(byPosition)
1022
+ const at = afterId ? rest.findIndex((f) => f.pageId === afterId) : -1
1023
+ if (afterId && at < 0)
1024
+ throw Object.assign(new Error('afterId is not one of your favourites'), {
1025
+ code: 'BAD_REQUEST',
1026
+ })
1027
+ moving.position = rankBetween(
1028
+ at >= 0 ? (rest[at]?.position ?? null) : null,
1029
+ rest[at + 1]?.position ?? null,
1030
+ )
1031
+ return favoriteList()
1032
+ },
1033
+ },
1034
+
1035
+ watchers: {
1036
+ get: async ({ pageId }: { pageId: string }) => {
1037
+ found(pageId)
1038
+ return watchStateOf(pageId)
1039
+ },
1040
+
1041
+ set: async ({ pageId, watching = true }: { pageId: string; watching?: boolean }) => {
1042
+ found(pageId)
1043
+ const list = watchers.get(pageId) ?? []
1044
+ const next = watching ? (list.includes(ME) ? list : [...list, ME]) : list.filter((id) => id !== ME)
1045
+ watchers.set(pageId, next)
1046
+ return watchStateOf(pageId)
1047
+ },
1048
+ },
1049
+
1050
+ recents: {
1051
+ list: async ({ limit = 10 }: { limit?: number } = {}) => recentList(limit),
1052
+
1053
+ record: async ({ pageId }: { pageId: string }) => {
1054
+ found(pageId)
1055
+ const existing = recents.find((r) => r.pageId === pageId)
1056
+ // Bumped in place, never appended: this is one row per page, not a visit log.
1057
+ if (existing) existing.viewedAt = new Date().toISOString()
1058
+ else recents.push({ pageId, viewedAt: new Date().toISOString() })
1059
+ return { ok: true as const }
1060
+ },
755
1061
  },
756
1062
 
757
1063
  versions: {
@@ -49,6 +49,24 @@ export const quireClientModule = defineClientModule({
49
49
  component: () => import('./pages/SpacePage.svelte'),
50
50
  permission: QUIRE_PERMISSIONS.spaceView,
51
51
  },
52
+ /**
53
+ * Declared before `/quire/:space/:page` for a reader's benefit only — the shell resolves by
54
+ * specificity, not by order, so the two literal segments here beat the one in the page route
55
+ * whatever sequence they are written in. A page whose id happened to be "trash" could not
56
+ * shadow this, and a space could not shadow the spaces index either.
57
+ *
58
+ * `page.edit`, matching what `pages.trash` asks on the server: the trash is where an edit is
59
+ * undone, so anybody who may edit a page in this space may see what has been taken out of it.
60
+ * Purging is `page.delete` and is gated on the row.
61
+ */
62
+ {
63
+ path: '/quire/:space/trash',
64
+ component: () => import('./pages/TrashPage.svelte'),
65
+ get title() {
66
+ return t('trash')
67
+ },
68
+ permission: QUIRE_PERMISSIONS.pageEdit,
69
+ },
52
70
  {
53
71
  path: '/quire/:space/:page',
54
72
  component: () => import('./pages/PageView.svelte'),