@kernhq/module-inventory 0.1.2 → 0.3.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 (213) hide show
  1. package/README.md +134 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -0,0 +1,80 @@
1
+ import { type Kernel, type Tx } from '@kernhq/kernel';
2
+ import type { Attachment as AttachmentModel } from '../../contract/models.js';
3
+ import { attachments } from '../schema.js';
4
+ import type { HistoryInput, NotifyService } from './notify.js';
5
+ type Row = typeof attachments.$inferSelect;
6
+ /** What this module copies out of core's file record, and all it copies. */
7
+ export interface FileFacts {
8
+ id: string;
9
+ name: string;
10
+ mimeType: string | null;
11
+ size: number | null;
12
+ }
13
+ /** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
14
+ export declare function toAttachment(row: Row): AttachmentModel;
15
+ /**
16
+ * Files kept against an asset — receipts, warranties, manuals, a repair invoice.
17
+ *
18
+ * **A module does not upload, and this class is what that sentence means in code.** The browser
19
+ * sends the bytes to core's file service and is handed an id; this records that the asset has that
20
+ * file, with the name and size copied at attach time so a list can be drawn without asking core once
21
+ * per row. Nothing here streams, signs, stores or deletes a byte — `remove` detaches, because the
22
+ * same file may be attached elsewhere and a module has no standing to destroy another module's row.
23
+ *
24
+ * The copied name is a snapshot on purpose: renaming a file in core does not rename it here, which
25
+ * is the same trade every module makes when it copies a label rather than joining across a schema
26
+ * boundary. What it buys is a files list that is one query rather than one query plus a call per row.
27
+ */
28
+ export declare class AttachmentService {
29
+ private readonly kernel;
30
+ private readonly notify;
31
+ constructor(kernel: Kernel, notify: NotifyService);
32
+ /**
33
+ * What core knows about these files — **called before the transaction opens, never inside one.**
34
+ *
35
+ * `kernel.call` is a request to another service over the broker, and awaiting one while holding a
36
+ * pooled connection is the failure `AssetService.codeFormat` documents: the pool starves under
37
+ * concurrent writes, and an attach fails outright whenever core is briefly away. So the router
38
+ * gathers the facts first and hands them to `add`, which does nothing but write.
39
+ *
40
+ * Two checks, and the first one is the one that matters: **a file has to belong to this
41
+ * workspace.** The id arrives in the request, so without this a member of one workspace could
42
+ * attach another workspace's file and read its name and size back out of the list — the module
43
+ * boundary does not help here, because core answers this module as a service.
44
+ */
45
+ describe(workspaceId: string, fileIds: readonly string[]): Promise<FileFacts[]>;
46
+ /**
47
+ * Every file on one asset, its repairs' included, oldest first.
48
+ *
49
+ * Not paged and not filtered by repair: one asset's files are bounded by how much paperwork one
50
+ * item collects, and the panel groups them in the browser. That is the one case where filtering
51
+ * client-side is right — the list is entirely loaded — and it is the opposite of what a paged
52
+ * asset list may do.
53
+ */
54
+ list(tx: Tx, workspaceId: string, assetId: string): Promise<AttachmentModel[]>;
55
+ /**
56
+ * Record that this asset has these files.
57
+ *
58
+ * `onConflictDoNothing` on `(asset_id, file_id)`: attaching the same file to the same asset twice
59
+ * is not an error worth refusing — somebody pressed the button twice, or dropped the same receipt
60
+ * in again — and the second attempt simply adds nothing. The rows that *were* inserted come back,
61
+ * so the caller announces exactly what changed.
62
+ *
63
+ * An archived asset is deliberately allowed: finding the receipt for something you retired last
64
+ * month is a reason to file it, not a reason to be refused.
65
+ */
66
+ add(tx: Tx, workspaceId: string, actorId: string | null, assetId: string, repairId: string | null, files: readonly FileFacts[]): Promise<{
67
+ rows: Row[];
68
+ activities: HistoryInput[];
69
+ }>;
70
+ /** Detach one file. Core's copy of it is untouched — see the class docblock. */
71
+ remove(tx: Tx, workspaceId: string, actorId: string | null, attachmentId: string): Promise<{
72
+ row: Row;
73
+ activity: HistoryInput;
74
+ }>;
75
+ private requireAsset;
76
+ /** A repair the file is filed under has to be one of *this* asset's, not merely one that exists. */
77
+ private requireRepair;
78
+ }
79
+ export {};
80
+ //# sourceMappingURL=attachments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../../../src/server/services/attachments.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,MAAM,EAAE,KAAK,EAAE,EAAU,MAAM,gBAAgB,CAAA;AAExE,OAAO,KAAK,EAAE,UAAU,IAAI,eAAe,EAAE,MAAM,0BAA0B,CAAA;AAC7E,OAAO,EAAU,WAAW,EAAW,MAAM,cAAc,CAAA;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE9D,KAAK,GAAG,GAAG,OAAO,WAAW,CAAC,YAAY,CAAA;AAE1C,4EAA4E;AAC5E,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CACpB;AAED,oGAAoG;AACpG,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,GAAG,eAAe,CAatD;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,aAAa;IAGxC;;;;;;;;;;;;OAYG;IACG,QAAQ,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAmBrF;;;;;;;OAOG;IACG,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IASpF;;;;;;;;;;OAUG;IACG,GAAG,CACP,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,KAAK,EAAE,SAAS,SAAS,EAAE,GAC1B,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,EAAE,CAAC;QAAC,UAAU,EAAE,YAAY,EAAE,CAAA;KAAE,CAAC;IA6CvD,gFAAgF;IAC1E,MAAM,CACV,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC;QAAE,GAAG,EAAE,GAAG,CAAC;QAAC,QAAQ,EAAE,YAAY,CAAA;KAAE,CAAC;YA0BlC,YAAY;IAQ1B,oGAAoG;YACtF,aAAa;CAS5B"}
@@ -0,0 +1,182 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, asc, eq } from 'drizzle-orm';
3
+ import { assets, attachments, repairs } from '../schema.js';
4
+ /** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
5
+ export function toAttachment(row) {
6
+ return {
7
+ id: row.id,
8
+ workspaceId: row.workspaceId,
9
+ assetId: row.assetId,
10
+ repairId: row.repairId,
11
+ fileId: row.fileId,
12
+ name: row.name,
13
+ mimeType: row.mimeType,
14
+ size: row.size,
15
+ uploadedBy: row.uploadedBy,
16
+ createdAt: row.createdAt.toISOString(),
17
+ };
18
+ }
19
+ /**
20
+ * Files kept against an asset — receipts, warranties, manuals, a repair invoice.
21
+ *
22
+ * **A module does not upload, and this class is what that sentence means in code.** The browser
23
+ * sends the bytes to core's file service and is handed an id; this records that the asset has that
24
+ * file, with the name and size copied at attach time so a list can be drawn without asking core once
25
+ * per row. Nothing here streams, signs, stores or deletes a byte — `remove` detaches, because the
26
+ * same file may be attached elsewhere and a module has no standing to destroy another module's row.
27
+ *
28
+ * The copied name is a snapshot on purpose: renaming a file in core does not rename it here, which
29
+ * is the same trade every module makes when it copies a label rather than joining across a schema
30
+ * boundary. What it buys is a files list that is one query rather than one query plus a call per row.
31
+ */
32
+ export class AttachmentService {
33
+ kernel;
34
+ notify;
35
+ constructor(kernel, notify) {
36
+ this.kernel = kernel;
37
+ this.notify = notify;
38
+ }
39
+ /**
40
+ * What core knows about these files — **called before the transaction opens, never inside one.**
41
+ *
42
+ * `kernel.call` is a request to another service over the broker, and awaiting one while holding a
43
+ * pooled connection is the failure `AssetService.codeFormat` documents: the pool starves under
44
+ * concurrent writes, and an attach fails outright whenever core is briefly away. So the router
45
+ * gathers the facts first and hands them to `add`, which does nothing but write.
46
+ *
47
+ * Two checks, and the first one is the one that matters: **a file has to belong to this
48
+ * workspace.** The id arrives in the request, so without this a member of one workspace could
49
+ * attach another workspace's file and read its name and size back out of the list — the module
50
+ * boundary does not help here, because core answers this module as a service.
51
+ */
52
+ async describe(workspaceId, fileIds) {
53
+ const unique = [...new Set(fileIds)];
54
+ const found = await Promise.all(unique.map(async (id) => {
55
+ const file = await this.kernel.call('core.files.get', { id });
56
+ if (!file || file.workspaceId !== workspaceId)
57
+ throw KernError.badRequest('That file is not one this workspace can attach.');
58
+ if (file.status !== 'ready')
59
+ throw KernError.badRequest('That file has not finished uploading yet.');
60
+ return {
61
+ id: file.id,
62
+ name: file.name,
63
+ mimeType: file.mimeType || null,
64
+ size: typeof file.size === 'number' ? file.size : null,
65
+ };
66
+ }));
67
+ return found;
68
+ }
69
+ /**
70
+ * Every file on one asset, its repairs' included, oldest first.
71
+ *
72
+ * Not paged and not filtered by repair: one asset's files are bounded by how much paperwork one
73
+ * item collects, and the panel groups them in the browser. That is the one case where filtering
74
+ * client-side is right — the list is entirely loaded — and it is the opposite of what a paged
75
+ * asset list may do.
76
+ */
77
+ async list(tx, workspaceId, assetId) {
78
+ const rows = await tx
79
+ .select()
80
+ .from(attachments)
81
+ .where(and(eq(attachments.workspaceId, workspaceId), eq(attachments.assetId, assetId)))
82
+ .orderBy(asc(attachments.createdAt), asc(attachments.id));
83
+ return rows.map(toAttachment);
84
+ }
85
+ /**
86
+ * Record that this asset has these files.
87
+ *
88
+ * `onConflictDoNothing` on `(asset_id, file_id)`: attaching the same file to the same asset twice
89
+ * is not an error worth refusing — somebody pressed the button twice, or dropped the same receipt
90
+ * in again — and the second attempt simply adds nothing. The rows that *were* inserted come back,
91
+ * so the caller announces exactly what changed.
92
+ *
93
+ * An archived asset is deliberately allowed: finding the receipt for something you retired last
94
+ * month is a reason to file it, not a reason to be refused.
95
+ */
96
+ async add(tx, workspaceId, actorId, assetId, repairId, files) {
97
+ await this.requireAsset(tx, workspaceId, assetId);
98
+ if (repairId)
99
+ await this.requireRepair(tx, workspaceId, assetId, repairId);
100
+ if (!files.length)
101
+ return { rows: [], activities: [] };
102
+ const inserted = await tx
103
+ .insert(attachments)
104
+ .values(files.map((file) => ({
105
+ id: uuidv7(),
106
+ workspaceId,
107
+ assetId,
108
+ repairId,
109
+ fileId: file.id,
110
+ name: file.name,
111
+ mimeType: file.mimeType,
112
+ size: file.size,
113
+ uploadedBy: actorId,
114
+ })))
115
+ .onConflictDoNothing({ target: [attachments.assetId, attachments.fileId] })
116
+ .returning();
117
+ /**
118
+ * One timeline entry per file, rather than one saying "3 files".
119
+ *
120
+ * A timeline is read to find out what happened to a thing, and "attached the purchase receipt"
121
+ * answers that where "attached 3 files" sends the reader looking. Attaching several at once is
122
+ * rare enough that the extra rows cost nothing.
123
+ */
124
+ const activities = inserted.map((row) => ({
125
+ workspaceId,
126
+ assetId,
127
+ actorId,
128
+ action: 'attachment_added',
129
+ data: {
130
+ attachmentId: row.id,
131
+ name: row.name,
132
+ ...(row.repairId ? { repairId: row.repairId } : {}),
133
+ },
134
+ }));
135
+ for (const activity of activities)
136
+ await this.notify.history(tx, activity);
137
+ return { rows: inserted, activities };
138
+ }
139
+ /** Detach one file. Core's copy of it is untouched — see the class docblock. */
140
+ async remove(tx, workspaceId, actorId, attachmentId) {
141
+ const [row] = await tx
142
+ .select()
143
+ .from(attachments)
144
+ .where(and(eq(attachments.workspaceId, workspaceId), eq(attachments.id, attachmentId)));
145
+ if (!row)
146
+ throw KernError.notFound('Attachment');
147
+ // Filtered by workspace as well as by id, like every other write in this module: `core`
148
+ // connects as a superuser with RLS bypassed, so the predicate in the statement is the only
149
+ // barrier there is, and a barrier that holds only because of what an earlier statement happened
150
+ // to do is not one.
151
+ await tx
152
+ .delete(attachments)
153
+ .where(and(eq(attachments.workspaceId, workspaceId), eq(attachments.id, attachmentId)));
154
+ const activity = {
155
+ workspaceId,
156
+ assetId: row.assetId,
157
+ actorId,
158
+ action: 'attachment_removed',
159
+ data: { attachmentId: row.id, name: row.name },
160
+ };
161
+ await this.notify.history(tx, activity);
162
+ return { row, activity };
163
+ }
164
+ async requireAsset(tx, workspaceId, assetId) {
165
+ const [row] = await tx
166
+ .select({ id: assets.id })
167
+ .from(assets)
168
+ .where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)));
169
+ if (!row)
170
+ throw KernError.notFound('Asset');
171
+ }
172
+ /** A repair the file is filed under has to be one of *this* asset's, not merely one that exists. */
173
+ async requireRepair(tx, workspaceId, assetId, repairId) {
174
+ const [row] = await tx
175
+ .select({ id: repairs.id })
176
+ .from(repairs)
177
+ .where(and(eq(repairs.workspaceId, workspaceId), eq(repairs.id, repairId), eq(repairs.assetId, assetId)));
178
+ if (!row)
179
+ throw KernError.notFound('Repair');
180
+ }
181
+ }
182
+ //# sourceMappingURL=attachments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.js","sourceRoot":"","sources":["../../../src/server/services/attachments.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import type { Kernel } from '@kernhq/kernel';
2
+ /**
3
+ * The members of a workspace who genuinely hold a permission.
4
+ *
5
+ * Genuinely, not "hold the role it defaults to": `kernel.authz.can` reads custom roles and scope
6
+ * bindings as well as the built-in defaults, so a workspace that took `inventory.repair.manage` away
7
+ * from `member` and gave it to one custom role is answered correctly. Guessing from the role would
8
+ * have been one local call instead of a handful, and would have quietly told the wrong people.
9
+ *
10
+ * Every failure here is swallowed: this decides an audience for a best-effort notification, and a
11
+ * member whose principal core cannot produce right now is one fewer recipient, not a reason for a
12
+ * nightly sweep to stop half-way through a workspace.
13
+ */
14
+ export declare function membersWithPermission(kernel: Kernel, workspaceId: string, permission: string): Promise<string[]>;
15
+ //# sourceMappingURL=audience.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audience.d.ts","sourceRoot":"","sources":["../../../src/server/services/audience.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAuC5C;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,EAAE,CAAC,CAoBnB"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Who to tell when the thing that happened belongs to the workspace rather than to a person.
3
+ *
4
+ * Custody notifications have an obvious recipient — the person the item was handed to. A warranty
5
+ * running out on a spare in a cupboard, or a repair nobody has chased, does not: it belongs to
6
+ * whoever looks after the register. There is no "the office manager" field, and inventing one would
7
+ * be a setting somebody has to fill in before the feature works at all, so the question is asked of
8
+ * the permission system instead: **who may actually do the thing this message is asking for.**
9
+ *
10
+ * A warranty notice asks somebody to renew or replace, which is `inventory.asset.manage`. An overdue
11
+ * repair asks somebody to chase the vendor, which is `inventory.repair.manage`. Somebody leaving
12
+ * with a laptop asks somebody to take it back, which is `inventory.custody.manage`. Each message
13
+ * names the key it needs rather than settling for "the admins", because a workspace that gave its
14
+ * office manager a custom role did that on purpose.
15
+ */
16
+ /** Most senior first, so the cap below keeps the people most likely to act. */
17
+ const ROLE_RANK = { owner: 3, admin: 2, member: 1, guest: 0 };
18
+ /**
19
+ * How many people one workspace-level notification may reach.
20
+ *
21
+ * A message sent to five thousand people is not a notification, it is a mailing list nobody reads —
22
+ * and on a large instance every member holds `inventory.asset.manage` by default, so an uncapped
23
+ * audience would be exactly that. Twenty is well past any real "who looks after the register" group
24
+ * and small enough that the check below stays a handful of calls rather than one per seat.
25
+ *
26
+ * The cap is applied **after** sorting by role, so the people it keeps are the senior ones rather
27
+ * than whichever rows the database happened to return first.
28
+ */
29
+ const RECIPIENT_CAP = 20;
30
+ /**
31
+ * The members of a workspace who genuinely hold a permission.
32
+ *
33
+ * Genuinely, not "hold the role it defaults to": `kernel.authz.can` reads custom roles and scope
34
+ * bindings as well as the built-in defaults, so a workspace that took `inventory.repair.manage` away
35
+ * from `member` and gave it to one custom role is answered correctly. Guessing from the role would
36
+ * have been one local call instead of a handful, and would have quietly told the wrong people.
37
+ *
38
+ * Every failure here is swallowed: this decides an audience for a best-effort notification, and a
39
+ * member whose principal core cannot produce right now is one fewer recipient, not a reason for a
40
+ * nightly sweep to stop half-way through a workspace.
41
+ */
42
+ export async function membersWithPermission(kernel, workspaceId, permission) {
43
+ const members = await kernel
44
+ .call('core.workspaces.members', { workspaceId })
45
+ .catch(() => []);
46
+ const ordered = [...members].sort((a, b) => (ROLE_RANK[b.role] ?? 0) - (ROLE_RANK[a.role] ?? 0));
47
+ const allowed = [];
48
+ for (const member of ordered) {
49
+ if (allowed.length >= RECIPIENT_CAP)
50
+ break;
51
+ const principal = await kernel
52
+ .call('core.users.principal', { userId: member.userId })
53
+ .catch(() => null);
54
+ if (!principal)
55
+ continue;
56
+ const can = await kernel.authz
57
+ .can(principal, permission, { kind: 'workspace', workspaceId })
58
+ .catch(() => false);
59
+ if (can)
60
+ allowed.push(member.userId);
61
+ }
62
+ return allowed;
63
+ }
64
+ //# sourceMappingURL=audience.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audience.js","sourceRoot":"","sources":["../../../src/server/services/audience.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;GAcG;AAEH,+EAA+E;AAC/E,MAAM,SAAS,GAAgC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAA;AAE1F;;;;;;;;;;GAUG;AACH,MAAM,aAAa,GAAG,EAAE,CAAA;AAOxB;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,MAAc,EACd,WAAmB,EACnB,UAAkB;IAElB,MAAM,OAAO,GAAG,MAAM,MAAM;SACzB,IAAI,CAAW,yBAAyB,EAAE,EAAE,WAAW,EAAE,CAAC;SAC1D,KAAK,CAAC,GAAG,EAAE,CAAC,EAAc,CAAC,CAAA;IAE9B,MAAM,OAAO,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAEhG,MAAM,OAAO,GAAa,EAAE,CAAA;IAC5B,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,OAAO,CAAC,MAAM,IAAI,aAAa;YAAE,MAAK;QAC1C,MAAM,SAAS,GAAG,MAAM,MAAM;aAC3B,IAAI,CAAmB,sBAAsB,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;aACzE,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;QACpB,IAAI,CAAC,SAAS;YAAE,SAAQ;QACxB,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK;aAC3B,GAAG,CAAC,SAAS,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;aAC9D,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAA;QACrB,IAAI,GAAG;YAAE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IACtC,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC"}
@@ -0,0 +1,57 @@
1
+ import { type Tx } from '@kernhq/kernel';
2
+ import type { Category as CategoryModel } from '../../contract/models.js';
3
+ import { categories } from '../schema.js';
4
+ type Row = typeof categories.$inferSelect;
5
+ /** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
6
+ export declare function toCategory(row: Row): CategoryModel;
7
+ /**
8
+ * How a workspace groups what it owns.
9
+ *
10
+ * Small on purpose. A category is a name and a position in a list; everything interesting about an
11
+ * asset belongs to the asset. The one thing here worth reading twice is that nothing deletes — see
12
+ * `archive` below.
13
+ */
14
+ export declare class CategoryService {
15
+ /**
16
+ * Ordered by `order` and then by name, which is what makes a workspace that never touches the
17
+ * order field still get an alphabetical picker rather than an arbitrary one — every row has
18
+ * `order` 0, so the tiebreak is doing all the work and has to be a name rather than an id.
19
+ */
20
+ list(tx: Tx, workspaceId: string, includeArchived: boolean): Promise<CategoryModel[]>;
21
+ get(tx: Tx, workspaceId: string, categoryId: string): Promise<Row>;
22
+ /**
23
+ * A duplicate name is a `CONFLICT` with the name in it, never a 500.
24
+ *
25
+ * The unique index is what actually decides — checking first and inserting after is a race that
26
+ * two people adding "Laptops" at once will find — so the check is the insert, and the driver's
27
+ * 23505 is translated into a sentence rather than shown as "Failed query: insert into
28
+ * mod_inventory.categories …".
29
+ */
30
+ create(tx: Tx, workspaceId: string, name: string, order: number): Promise<Row>;
31
+ update(tx: Tx, workspaceId: string, categoryId: string, patch: {
32
+ name?: string;
33
+ order?: number;
34
+ }): Promise<Row>;
35
+ /**
36
+ * Archive and restore, which are one procedure because they are one column.
37
+ *
38
+ * **Nothing here deletes, and that is the decision this file exists to record.**
39
+ * `assets.category_id` carries no foreign key — a module keeps its ids plain — so a delete would
40
+ * leave every asset filed under this category pointing at a row that is not there: a blank column
41
+ * on the row, a picker that cannot explain what the asset used to be, and an `asset_history`
42
+ * entry saying "category changed to <nothing>". None of it recoverable, all of it caused by a
43
+ * settings screen. An archived category disappears from every picker and every filter and leaves
44
+ * each asset able to say what it is.
45
+ */
46
+ archive(tx: Tx, workspaceId: string, categoryId: string, archived: boolean): Promise<Row>;
47
+ /**
48
+ * The unique index refused it, or something else did and must not be disguised.
49
+ *
50
+ * Rethrowing the original for anything that is not this constraint matters: turning every failed
51
+ * insert into "that name is taken" would hide a real fault behind a sentence a person would act
52
+ * on by renaming something, for ever.
53
+ */
54
+ private static nameTaken;
55
+ }
56
+ export {};
57
+ //# sourceMappingURL=categories.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"categories.d.ts","sourceRoot":"","sources":["../../../src/server/services/categories.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,EAAE,EAAU,MAAM,gBAAgB,CAAA;AAE3D,OAAO,KAAK,EAAE,QAAQ,IAAI,aAAa,EAAE,MAAM,0BAA0B,CAAA;AACzE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAGzC,KAAK,GAAG,GAAG,OAAO,UAAU,CAAC,YAAY,CAAA;AAKzC,oGAAoG;AACpG,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,aAAa,CAUlD;AAED;;;;;;GAMG;AACH,qBAAa,eAAe;IAC1B;;;;OAIG;IACG,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;IAWrF,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IASxE;;;;;;;OAOG;IACG,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAS9E,MAAM,CACV,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GACvC,OAAO,CAAC,GAAG,CAAC;IAqBf;;;;;;;;;;OAUG;IACG,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC;IAU/F;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,CAAC,SAAS;CAOzB"}
@@ -0,0 +1,124 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, asc, eq, isNull } from 'drizzle-orm';
3
+ import { categories } from '../schema.js';
4
+ import { violated } from './db-errors.js';
5
+ /** The unique index `0000_init.sql` put on (workspace_id, name). */
6
+ const NAME_TAKEN = 'inventory_categories_ws_name_uq';
7
+ /** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
8
+ export function toCategory(row) {
9
+ return {
10
+ id: row.id,
11
+ workspaceId: row.workspaceId,
12
+ name: row.name,
13
+ order: row.order,
14
+ createdAt: row.createdAt.toISOString(),
15
+ updatedAt: row.updatedAt.toISOString(),
16
+ archivedAt: row.archivedAt?.toISOString() ?? null,
17
+ };
18
+ }
19
+ /**
20
+ * How a workspace groups what it owns.
21
+ *
22
+ * Small on purpose. A category is a name and a position in a list; everything interesting about an
23
+ * asset belongs to the asset. The one thing here worth reading twice is that nothing deletes — see
24
+ * `archive` below.
25
+ */
26
+ export class CategoryService {
27
+ /**
28
+ * Ordered by `order` and then by name, which is what makes a workspace that never touches the
29
+ * order field still get an alphabetical picker rather than an arbitrary one — every row has
30
+ * `order` 0, so the tiebreak is doing all the work and has to be a name rather than an id.
31
+ */
32
+ async list(tx, workspaceId, includeArchived) {
33
+ const filters = [eq(categories.workspaceId, workspaceId)];
34
+ if (!includeArchived)
35
+ filters.push(isNull(categories.archivedAt));
36
+ const rows = await tx
37
+ .select()
38
+ .from(categories)
39
+ .where(and(...filters))
40
+ .orderBy(asc(categories.order), asc(categories.name));
41
+ return rows.map(toCategory);
42
+ }
43
+ async get(tx, workspaceId, categoryId) {
44
+ const [row] = await tx
45
+ .select()
46
+ .from(categories)
47
+ .where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)));
48
+ if (!row)
49
+ throw KernError.notFound('Category');
50
+ return row;
51
+ }
52
+ /**
53
+ * A duplicate name is a `CONFLICT` with the name in it, never a 500.
54
+ *
55
+ * The unique index is what actually decides — checking first and inserting after is a race that
56
+ * two people adding "Laptops" at once will find — so the check is the insert, and the driver's
57
+ * 23505 is translated into a sentence rather than shown as "Failed query: insert into
58
+ * mod_inventory.categories …".
59
+ */
60
+ async create(tx, workspaceId, name, order) {
61
+ try {
62
+ const [row] = await tx.insert(categories).values({ id: uuidv7(), workspaceId, name, order }).returning();
63
+ return row;
64
+ }
65
+ catch (err) {
66
+ throw CategoryService.nameTaken(err, name);
67
+ }
68
+ }
69
+ async update(tx, workspaceId, categoryId, patch) {
70
+ const previous = await this.get(tx, workspaceId, categoryId);
71
+ // `undefined` means "not mentioned". Neither field is nullable, so there is no "clear it" here
72
+ // and no reason for the `null`-versus-`undefined` care `assets.update` needs.
73
+ const values = {
74
+ name: patch.name ?? previous.name,
75
+ order: patch.order ?? previous.order,
76
+ updatedAt: new Date(),
77
+ };
78
+ try {
79
+ const [row] = await tx
80
+ .update(categories)
81
+ .set(values)
82
+ .where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)))
83
+ .returning();
84
+ return row;
85
+ }
86
+ catch (err) {
87
+ throw CategoryService.nameTaken(err, values.name);
88
+ }
89
+ }
90
+ /**
91
+ * Archive and restore, which are one procedure because they are one column.
92
+ *
93
+ * **Nothing here deletes, and that is the decision this file exists to record.**
94
+ * `assets.category_id` carries no foreign key — a module keeps its ids plain — so a delete would
95
+ * leave every asset filed under this category pointing at a row that is not there: a blank column
96
+ * on the row, a picker that cannot explain what the asset used to be, and an `asset_history`
97
+ * entry saying "category changed to <nothing>". None of it recoverable, all of it caused by a
98
+ * settings screen. An archived category disappears from every picker and every filter and leaves
99
+ * each asset able to say what it is.
100
+ */
101
+ async archive(tx, workspaceId, categoryId, archived) {
102
+ const [row] = await tx
103
+ .update(categories)
104
+ .set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
105
+ .where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)))
106
+ .returning();
107
+ if (!row)
108
+ throw KernError.notFound('Category');
109
+ return row;
110
+ }
111
+ /**
112
+ * The unique index refused it, or something else did and must not be disguised.
113
+ *
114
+ * Rethrowing the original for anything that is not this constraint matters: turning every failed
115
+ * insert into "that name is taken" would hide a real fault behind a sentence a person would act
116
+ * on by renaming something, for ever.
117
+ */
118
+ static nameTaken(err, name) {
119
+ if (!violated(err, NAME_TAKEN))
120
+ return err;
121
+ return KernError.conflict(`This workspace already has a category called “${name}”.`, 'inventory.category.name_taken');
122
+ }
123
+ }
124
+ //# sourceMappingURL=categories.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"categories.js","sourceRoot":"","sources":["../../../src/server/services/categories.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The one page boundary this module issues, for every list it pages.
3
+ *
4
+ * **The bookmarked row's id, and the sort it was issued under.** Nothing else — in particular not
5
+ * the sort key itself, which is what it used to carry. That earlier shape (`{k: <sort key>, i:
6
+ * <id>}`) was wrong three separate ways, each of them reachable by anyone who could type into the
7
+ * address bar:
8
+ *
9
+ * - **It could not be trusted.** `decode` checked only that `i` was a *string*, and `i` is
10
+ * interpolated into `(col, id) < ($1, $2::uuid)`. `{"k":"x","i":"not-a-uuid"}` therefore
11
+ * reached Postgres as a 22P02 nobody caught — an unhandled 500 and an error-level log line per
12
+ * request, at the 600-a-minute the rate limiter allows.
13
+ * - **It was not bound to its sort.** A cursor issued under `sort=recent` replayed under
14
+ * `sort=code` compared a uuid against a code, so page two came back equal to page one and
15
+ * "Load more" never ended.
16
+ * - **It was unbounded.** `sort=name` on a 200-character Persian name encoded to 602 characters,
17
+ * and `Cursor` in `@kernhq/contracts` is `max(512)` — so a long enough name broke "Load more"
18
+ * with a validation error, in exactly the locales least likely to be tested.
19
+ *
20
+ * Carrying the id alone and reading the sort key back from that row in SQL answers all three: the
21
+ * cursor is a fixed ~60 characters whatever the name, a value that is not a uuid is refused before
22
+ * it is anywhere near the database, and a cursor whose sort disagrees with the request is refused
23
+ * rather than quietly misread. Base64 so nothing in the product is tempted to read it — it is a
24
+ * bookmark, not an offset, and its shape is this file's business.
25
+ *
26
+ * It lives here rather than in `assets.ts` because the repair list pages too, and a second bookmark
27
+ * format would be a second set of those three bugs. `src/client/mock.ts` mirrors this byte for
28
+ * byte; read them as one pair and change them as one pair.
29
+ *
30
+ * The asset timeline is the one list that does **not** use this one: it is ordered by a sequence
31
+ * rather than by a row id, so its bookmark is `SeqBookmark` below.
32
+ */
33
+ export interface Bookmark<S extends string> {
34
+ i: string;
35
+ s: S;
36
+ }
37
+ export declare const encodeMark: <S extends string>(mark: Bookmark<S>) => string;
38
+ export declare function decodeMark<S extends string>(cursor: string, sort: S): Bookmark<S>;
39
+ /**
40
+ * The other page boundary: a row's **sequence number**, and the sort it was issued under.
41
+ *
42
+ * The asset timeline is ordered by `asset_history.seq` rather than by the row id, because a uuidv7
43
+ * is only ordered to the millisecond and two entries written inside one — an attach and a detach, a
44
+ * create and its first entry — sort by ten random bytes. `schema.ts` argues that at the column. A
45
+ * bookmark into that ordering has to name the sequence value, so it is a number rather than a uuid
46
+ * and needs its own codec: handing `decodeMark` a number would have it refused as "not a uuid", and
47
+ * relaxing `decodeMark` to accept either is how one of the two shapes ends up unvalidated.
48
+ *
49
+ * It keeps all three properties the uuid bookmark has, and for the same reasons:
50
+ *
51
+ * - **bounded** — a base64url of `{"n":<integer>,"s":"recent"}` is around forty characters
52
+ * whatever the row, well inside `Cursor`'s `max(512)` in `@kernhq/contracts`;
53
+ * - **checked before it reaches SQL** — anything that is not a safe positive integer is refused
54
+ * here rather than interpolated into a `bigint` comparison for Postgres to reject as a 22P02;
55
+ * - **bound to its sort** — a bookmark issued under one ordering is meaningless under another,
56
+ * and reading it anyway is what made "Load more" loop for ever.
57
+ *
58
+ * `Number.isSafeInteger` is the upper bound as well as the type check: the column is a `bigint` and
59
+ * the sequence would have to hand out nine quadrillion history rows to reach it, but a cursor
60
+ * arrives from outside and `JSON.parse` will happily produce `1e400`.
61
+ */
62
+ export interface SeqBookmark<S extends string> {
63
+ n: number;
64
+ s: S;
65
+ }
66
+ export declare const encodeSeqMark: <S extends string>(mark: SeqBookmark<S>) => string;
67
+ export declare function decodeSeqMark<S extends string>(cursor: string, sort: S): SeqBookmark<S>;
68
+ //# sourceMappingURL=cursor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cursor.d.ts","sourceRoot":"","sources":["../../../src/server/services/cursor.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,WAAW,QAAQ,CAAC,CAAC,SAAS,MAAM;IACxC,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,CAAC,CAAA;CACL;AAED,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,MAAM,EAAE,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAG,MACA,CAAA;AAKjE,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAcjF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,MAAM;IAC3C,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,CAAC,CAAA;CACL;AAED,eAAO,MAAM,aAAa,GAAI,CAAC,SAAS,MAAM,EAAE,MAAM,WAAW,CAAC,CAAC,CAAC,KAAG,MACN,CAAA;AAEjE,wBAAgB,aAAa,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAWvF"}