@kernhq/module-inventory 0.2.0 → 0.4.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 (198) hide show
  1. package/README.md +106 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +345 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +245 -0
  17. package/dist/contract/models.js.map +1 -1
  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 +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1108 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +253 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  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 +1392 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +385 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +169 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  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 +157 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +309 -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 +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  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 +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  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/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/0008_category_order_unique.sql +71 -0
  117. package/migrations/meta/0002_snapshot.json +1054 -0
  118. package/migrations/meta/0003_snapshot.json +1070 -0
  119. package/migrations/meta/0004_snapshot.json +1130 -0
  120. package/migrations/meta/0005_snapshot.json +1135 -0
  121. package/migrations/meta/_journal.json +49 -0
  122. package/package.json +2 -1
  123. package/src/client/api-instance.ts +27 -2
  124. package/src/client/bidi.test.ts +148 -0
  125. package/src/client/bidi.ts +85 -0
  126. package/src/client/components/AssetDetailPanel.svelte +614 -0
  127. package/src/client/components/AssetFormDialog.svelte +71 -22
  128. package/src/client/components/AssetPhoto.svelte +178 -0
  129. package/src/client/components/AttachmentsSection.svelte +327 -0
  130. package/src/client/components/CustodyDialog.svelte +201 -0
  131. package/src/client/components/RepairDialog.svelte +271 -0
  132. package/src/client/components/RepairsSection.svelte +318 -0
  133. package/src/client/components/Timeline.svelte +347 -0
  134. package/src/client/components/TimelineText.svelte +124 -0
  135. package/src/client/core-api.ts +71 -0
  136. package/src/client/custody.test.ts +31 -0
  137. package/src/client/custody.ts +34 -0
  138. package/src/client/errors.test.ts +395 -0
  139. package/src/client/errors.ts +229 -0
  140. package/src/client/links.test.ts +74 -0
  141. package/src/client/links.ts +44 -0
  142. package/src/client/members.test.ts +132 -0
  143. package/src/client/members.ts +116 -0
  144. package/src/client/messages.test.ts +127 -0
  145. package/src/client/messages.ts +1106 -18
  146. package/src/client/mock.test.ts +465 -1
  147. package/src/client/mock.ts +1105 -79
  148. package/src/client/module.ts +73 -1
  149. package/src/client/pages/AssetsPage.svelte +317 -45
  150. package/src/client/permissions.ts +7 -0
  151. package/src/client/price.test.ts +151 -3
  152. package/src/client/price.ts +155 -11
  153. package/src/client/query.ts +36 -0
  154. package/src/client/reorder.test.ts +100 -0
  155. package/src/client/reorder.ts +79 -0
  156. package/src/client/repairs.test.ts +38 -0
  157. package/src/client/repairs.ts +38 -0
  158. package/src/client/sequence.test.ts +248 -0
  159. package/src/client/sequence.ts +185 -0
  160. package/src/client/settings/CategoriesSettings.svelte +746 -0
  161. package/src/client/settings/GeneralSettings.svelte +0 -0
  162. package/src/client/status.ts +29 -0
  163. package/src/client/timeline.test.ts +175 -0
  164. package/src/client/timeline.ts +206 -0
  165. package/src/client/widgets/OverviewWidget.svelte +127 -26
  166. package/src/client/widgets/RepairsWidget.svelte +124 -0
  167. package/src/contract/capabilities.ts +53 -9
  168. package/src/contract/events.ts +49 -0
  169. package/src/contract/index.ts +1 -0
  170. package/src/contract/models.ts +270 -0
  171. package/src/contract/notifications.ts +73 -0
  172. package/src/contract/permissions.ts +55 -2
  173. package/src/contract/router.ts +284 -1
  174. package/src/contract/settings.ts +24 -4
  175. package/src/module.test.ts +217 -4
  176. package/src/server/index.ts +311 -4
  177. package/src/server/inventory.int.test.ts +4109 -19
  178. package/src/server/jobs.ts +444 -0
  179. package/src/server/migrations.test.ts +253 -2
  180. package/src/server/router.ts +486 -6
  181. package/src/server/schema.ts +171 -3
  182. package/src/server/services/assets.ts +214 -54
  183. package/src/server/services/attachments.ts +215 -0
  184. package/src/server/services/audience.ts +77 -0
  185. package/src/server/services/categories.ts +337 -0
  186. package/src/server/services/cursor.ts +104 -0
  187. package/src/server/services/custody.ts +471 -0
  188. package/src/server/services/db-errors.ts +42 -0
  189. package/src/server/services/index.ts +34 -1
  190. package/src/server/services/members.ts +54 -0
  191. package/src/server/services/notify.ts +52 -7
  192. package/src/server/services/offboarding.ts +150 -0
  193. package/src/server/services/repairs.ts +567 -0
  194. package/src/server/services/search.ts +166 -0
  195. package/src/server/services/stats.ts +88 -0
  196. package/src/server/services/status.test.ts +34 -0
  197. package/src/server/services/status.ts +143 -0
  198. package/src/client/settings/core-api.ts +0 -32
@@ -0,0 +1,142 @@
1
+ import { and, eq, isNull, sql } from 'drizzle-orm';
2
+ import { MODULE_ID } from '../../contract/models.js';
3
+ import { assets, categories } from '../schema.js';
4
+ /** One page of the full reindex. Big enough to be worth a round trip, small enough to hold. */
5
+ const SCAN_BATCH = 500;
6
+ /**
7
+ * How an asset is referred to from outside this module.
8
+ *
9
+ * One function, because a search hit and a resolved `inventory:asset:<id>` reference have to be the
10
+ * same thing — somebody who finds a laptop in the command palette and somebody who follows a link to
11
+ * it from a chat message are looking at the same row and must see the same words for it.
12
+ */
13
+ export const assetUrl = (assetId) => `/inventory?asset=${assetId}`;
14
+ export const ASSET_ICON = 'briefcase';
15
+ /**
16
+ * What a person types into the command palette, and what an asset has to answer it with.
17
+ *
18
+ * An asset tag read off a sticker is the first of these — `INV-0042` is what somebody has in their
19
+ * hand — so it leads the title, exactly as tracker leads with an issue key. The rest is everything
20
+ * printed on or near the thing: its name, its serial number, where it is kept, and what the
21
+ * workspace files it under.
22
+ *
23
+ * **The category is indexed by name, not by id.** "chair" finds every chair filed under Furniture
24
+ * only if the word Furniture is in the document; an id in `attributes` is a filter, not a search
25
+ * term. The cost is that renaming a category leaves its assets' documents naming the old word until
26
+ * each one is next written, which is a staleness worth stating and not worth a workspace-wide
27
+ * rewrite on every rename — `core.search.reindex` is the thing that fixes it, and it exists.
28
+ *
29
+ * `acl` is null: an asset is visible to anybody with `inventory.asset.view`, which is a workspace
30
+ * permission and not a per-row one, so there is no narrower audience to name.
31
+ */
32
+ export function assetSearchDocument(workspaceId, row) {
33
+ const body = [row.asset.description, row.asset.serialNumber, row.asset.location, row.categoryName]
34
+ .map((part) => part?.trim())
35
+ .filter((part) => Boolean(part))
36
+ .join('\n');
37
+ return {
38
+ workspaceId: workspaceId,
39
+ object: { module: MODULE_ID, type: 'asset', id: row.asset.id },
40
+ title: `${row.asset.code} ${row.asset.name}`,
41
+ body: body || null,
42
+ url: assetUrl(row.asset.id),
43
+ icon: ASSET_ICON,
44
+ acl: null,
45
+ updatedAt: row.asset.updatedAt.toISOString(),
46
+ attributes: {
47
+ code: row.asset.code,
48
+ status: row.asset.status,
49
+ categoryId: row.asset.categoryId,
50
+ custodianUserId: row.asset.custodianUserId,
51
+ },
52
+ };
53
+ }
54
+ /** The one select every read here makes: the asset, plus its category's current name. */
55
+ const withCategoryName = (tx) => tx
56
+ .select({ asset: assets, categoryName: categories.name })
57
+ .from(assets)
58
+ .leftJoin(categories, and(eq(categories.id, assets.categoryId), eq(categories.workspaceId, assets.workspaceId)));
59
+ /**
60
+ * Putting assets into the workspace-wide search index, and taking them out again.
61
+ *
62
+ * `NotifyService` has carried `index`/`unindex` since the module was written and nothing called
63
+ * either, because `objectTypes` had been taken off the manifest — a declared type with no indexer
64
+ * and no resolver renders a link to nothing, which reads to a person as a broken product rather
65
+ * than as a feature that has not shipped. Both halves arrive together here.
66
+ *
67
+ * Every method is best-effort by way of `NotifyService`: an asset must not fail to save because the
68
+ * core search service is briefly away. The register's own row is authoritative and a later
69
+ * `core.search.reindex` repairs whatever was missed, which is exactly what that job is for.
70
+ */
71
+ export class SearchService {
72
+ kernel;
73
+ notify;
74
+ constructor(kernel, notify) {
75
+ this.kernel = kernel;
76
+ this.notify = notify;
77
+ }
78
+ /**
79
+ * One asset as a document, or `null` for one the index should not hold.
80
+ *
81
+ * `null` for a row that is gone **and** for one that is archived, because the indexer treats null
82
+ * as "remove this": an archived asset is one the workspace has said it no longer tracks, so
83
+ * finding it in the command palette would offer a door to something deliberately put away.
84
+ * Restoring it writes the row again, which reindexes it.
85
+ */
86
+ async load(workspaceId, assetId) {
87
+ return this.kernel.database.withWorkspace(workspaceId, async (tx) => {
88
+ const [row] = await withCategoryName(tx)
89
+ .where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
90
+ .limit(1);
91
+ if (!row || row.asset.archivedAt)
92
+ return null;
93
+ return assetSearchDocument(workspaceId, row);
94
+ });
95
+ }
96
+ /**
97
+ * Every live asset of a workspace, in pages, for a full reindex.
98
+ *
99
+ * Keyset by id rather than `offset`: a scan of a big workspace runs for a while, and an offset
100
+ * walk over a table somebody is writing to repeats and drops rows. Ids are uuidv7 and unique, so
101
+ * one column is a page boundary two rows cannot share — the same reasoning `assets.list` gives.
102
+ *
103
+ * One transaction per page rather than one for the whole scan: holding a pooled connection open
104
+ * across a reindex of ten thousand rows is how a service runs out of connections doing
105
+ * maintenance.
106
+ */
107
+ async *scan(workspaceId) {
108
+ let cursor = null;
109
+ for (;;) {
110
+ const rows = await this.kernel.database.withWorkspace(workspaceId, (tx) => withCategoryName(tx)
111
+ .where(and(eq(assets.workspaceId, workspaceId), isNull(assets.archivedAt), cursor ? sql `${assets.id} > ${cursor}` : sql `true`))
112
+ .orderBy(assets.id)
113
+ .limit(SCAN_BATCH));
114
+ if (!rows.length)
115
+ return;
116
+ for (const row of rows)
117
+ yield assetSearchDocument(workspaceId, row);
118
+ cursor = rows.at(-1)?.asset.id ?? null;
119
+ }
120
+ }
121
+ /**
122
+ * Bring the index into step with one asset, whichever direction that means.
123
+ *
124
+ * Called after the transaction has committed, never inside it — the index is another service's
125
+ * table, and telling it about a row a rollback then took away cannot be retracted. It is the
126
+ * reason `router.ts` announces from outside the transaction and this is announced beside the
127
+ * event and the realtime change.
128
+ */
129
+ async reindex(workspaceId, assetId) {
130
+ const document = await this.load(workspaceId, assetId).catch((err) => {
131
+ this.kernel.log.warn({ err: err instanceof Error ? err.message : err, workspaceId, assetId }, 'inventory: could not read an asset to reindex it');
132
+ return undefined;
133
+ });
134
+ if (document === undefined)
135
+ return;
136
+ if (document)
137
+ await this.notify.index([document]);
138
+ else
139
+ await this.notify.unindex(workspaceId, 'asset', [assetId]);
140
+ }
141
+ }
142
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["../../../src/server/services/search.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ import type { Tx } from '@kernhq/kernel';
2
+ import { type InventoryStats } from '../../contract/models.js';
3
+ /**
4
+ * The register in numbers.
5
+ *
6
+ * It exists because the assets page had no honest number to print. `assets.list` is keyset-paged and
7
+ * never counts, so the count line said "50 assets" for a workspace with 214 of them and then "100
8
+ * assets" after *Load more* — a number that is simply wrong, on the line whose whole job is to be
9
+ * the number. A `count(*)` bolted onto every list page would be the expensive answer to that; one
10
+ * cheap procedure the page calls once is the right one, and it also gives the dashboard card
11
+ * something worth showing.
12
+ *
13
+ * **`total` counts what the list shows by default — live rows.** `archived` sits beside it rather
14
+ * than inside it, because a count line that silently included archived rows would disagree with the
15
+ * list underneath it.
16
+ */
17
+ export declare class StatsService {
18
+ /**
19
+ * @param repairsOn whether this workspace has the `repairs` capability. Resolved by the caller
20
+ * *before* the transaction opens, because it is a settings read over the broker.
21
+ */
22
+ summary(tx: Tx, workspaceId: string, repairsOn: boolean): Promise<InventoryStats>;
23
+ /**
24
+ * How many live items are at a repairer, counted from the **repair rows** rather than from
25
+ * `assets.status`.
26
+ *
27
+ * `byStatus.under_repair` is the same number, and deliberately arrives a different way: that one
28
+ * reads the cached column every list filters on, this one reads the fact the column is derived
29
+ * from. `deriveStatus` is what keeps them equal, and `inventory.int.test.ts` asserts they are —
30
+ * which is a real check on the derivation rather than two ways of writing the same query.
31
+ *
32
+ * **Joined to `assets` for the live rows, which it did not used to be.** The old version leaned on
33
+ * `AssetService.archive` refusing to retire an item that is away, so every open repair was assumed
34
+ * to belong to a live asset. That refusal is now withdrawn while the workspace has the `repairs`
35
+ * capability off — it named a procedure answering 404, and leaving it in place is what stranded
36
+ * the asset — so an archived row with an open repair is reachable, and `byStatus.under_repair`
37
+ * (live rows only) would have disagreed with this number the moment the capability came back on.
38
+ * Two counts of one thing have to be counted over one set.
39
+ */
40
+ private away;
41
+ }
42
+ //# sourceMappingURL=stats.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../../src/server/services/stats.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,gBAAgB,CAAA;AAExC,OAAO,EAAe,KAAK,cAAc,EAAE,MAAM,0BAA0B,CAAA;AAG3E;;;;;;;;;;;;;GAaG;AACH,qBAAa,YAAY;IACvB;;;OAGG;IACG,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC;IAsCvF;;;;;;;;;;;;;;;;OAgBG;YACW,IAAI;CAQnB"}
@@ -0,0 +1,80 @@
1
+ import { and, eq, isNull, sql } from 'drizzle-orm';
2
+ import { AssetStatus } from '../../contract/models.js';
3
+ import { assets, repairs } from '../schema.js';
4
+ /**
5
+ * The register in numbers.
6
+ *
7
+ * It exists because the assets page had no honest number to print. `assets.list` is keyset-paged and
8
+ * never counts, so the count line said "50 assets" for a workspace with 214 of them and then "100
9
+ * assets" after *Load more* — a number that is simply wrong, on the line whose whole job is to be
10
+ * the number. A `count(*)` bolted onto every list page would be the expensive answer to that; one
11
+ * cheap procedure the page calls once is the right one, and it also gives the dashboard card
12
+ * something worth showing.
13
+ *
14
+ * **`total` counts what the list shows by default — live rows.** `archived` sits beside it rather
15
+ * than inside it, because a count line that silently included archived rows would disagree with the
16
+ * list underneath it.
17
+ */
18
+ export class StatsService {
19
+ /**
20
+ * @param repairsOn whether this workspace has the `repairs` capability. Resolved by the caller
21
+ * *before* the transaction opens, because it is a settings read over the broker.
22
+ */
23
+ async summary(tx, workspaceId, repairsOn) {
24
+ const [totals] = await tx
25
+ .select({
26
+ total: sql `count(*) filter (where ${assets.archivedAt} is null)`.mapWith(Number),
27
+ archived: sql `count(*) filter (where ${assets.archivedAt} is not null)`.mapWith(Number),
28
+ unassigned: sql `count(*) filter (where ${assets.archivedAt} is null and ${assets.custodianUserId} is null)`.mapWith(Number),
29
+ })
30
+ .from(assets)
31
+ .where(eq(assets.workspaceId, workspaceId));
32
+ const grouped = await tx
33
+ .select({ status: assets.status, n: sql `count(*)`.mapWith(Number) })
34
+ .from(assets)
35
+ .where(and(eq(assets.workspaceId, workspaceId), isNull(assets.archivedAt)))
36
+ .groupBy(assets.status);
37
+ /**
38
+ * Zero-filled over the whole enum, so a screen can render the set without asking which statuses
39
+ * happen to exist in this workspace. A missing key and a zero look the same on a card and are
40
+ * not the same thing to a `Record`.
41
+ */
42
+ const byStatus = Object.fromEntries(AssetStatus.options.map((status) => [status, 0]));
43
+ for (const row of grouped)
44
+ if (row.status in byStatus)
45
+ byStatus[row.status] = row.n;
46
+ return {
47
+ total: totals?.total ?? 0,
48
+ archived: totals?.archived ?? 0,
49
+ byStatus,
50
+ outForRepair: repairsOn ? await this.away(tx, workspaceId) : null,
51
+ unassigned: totals?.unassigned ?? 0,
52
+ };
53
+ }
54
+ /**
55
+ * How many live items are at a repairer, counted from the **repair rows** rather than from
56
+ * `assets.status`.
57
+ *
58
+ * `byStatus.under_repair` is the same number, and deliberately arrives a different way: that one
59
+ * reads the cached column every list filters on, this one reads the fact the column is derived
60
+ * from. `deriveStatus` is what keeps them equal, and `inventory.int.test.ts` asserts they are —
61
+ * which is a real check on the derivation rather than two ways of writing the same query.
62
+ *
63
+ * **Joined to `assets` for the live rows, which it did not used to be.** The old version leaned on
64
+ * `AssetService.archive` refusing to retire an item that is away, so every open repair was assumed
65
+ * to belong to a live asset. That refusal is now withdrawn while the workspace has the `repairs`
66
+ * capability off — it named a procedure answering 404, and leaving it in place is what stranded
67
+ * the asset — so an archived row with an open repair is reachable, and `byStatus.under_repair`
68
+ * (live rows only) would have disagreed with this number the moment the capability came back on.
69
+ * Two counts of one thing have to be counted over one set.
70
+ */
71
+ async away(tx, workspaceId) {
72
+ const [row] = await tx
73
+ .select({ n: sql `count(*)`.mapWith(Number) })
74
+ .from(repairs)
75
+ .innerJoin(assets, and(eq(assets.id, repairs.assetId), eq(assets.workspaceId, repairs.workspaceId)))
76
+ .where(and(eq(repairs.workspaceId, workspaceId), isNull(repairs.returnedOn), isNull(assets.archivedAt)));
77
+ return row?.n ?? 0;
78
+ }
79
+ }
80
+ //# sourceMappingURL=stats.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stats.js","sourceRoot":"","sources":["../../../src/server/services/stats.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,102 @@
1
+ import { type Tx } from '@kernhq/kernel';
2
+ import type { AssetStatus } from '../../contract/models.js';
3
+ import { assets } from '../schema.js';
4
+ /**
5
+ * What an asset's status is, decided in one place from the facts that decide it.
6
+ *
7
+ * `assets.status` is stored rather than computed, because every list filter asks for it — but it is
8
+ * **derived**, and the moment two services each wrote their own version of the derivation they
9
+ * disagreed. That is not hypothetical: `CustodyService.stamp` wrote `userId ? 'assigned' :
10
+ * 'in_stock'` unconditionally, so handing over a laptop that was sitting at the repairer would have
11
+ * announced it as back in the office. Both services now read this function, so there is one answer
12
+ * and a test can hold it.
13
+ *
14
+ * ## The rule, written down because it is a decision rather than an inevitability
15
+ *
16
+ * **Custody and repair are independent facts, and repair wins the status column.** An item can be
17
+ * out for repair *while still assigned to somebody*: Dan's laptop goes to the workshop, Dan is still
18
+ * answerable for it, the custody period stays open and `custodian_user_id` stays set. Only `status`
19
+ * moves, because `status` is the column that answers *where is it*, and "with a repairer" is the
20
+ * answer somebody looking for it needs. Custody is answered by `custodian_user_id`, which is a
21
+ * different column and is not touched.
22
+ *
23
+ * Two consequences follow, and both are deliberate:
24
+ *
25
+ * - **No custody procedure refuses an item that is away for repair.** Refusing `assign` would mean
26
+ * refusing `return` too, and somebody leaving the company while their laptop is at the workshop
27
+ * must still be able to hand it back. All three verbs recompute the status through this function
28
+ * instead, so a return during a repair leaves the item `under_repair` with nobody holding it,
29
+ * which is exactly true.
30
+ * - **Completing a repair does not blindly go to `in_stock`.** It goes back to whatever custody
31
+ * says — `assigned` when somebody still holds it — because the repair never released them.
32
+ *
33
+ * `reserved`, `lost` and `retired` are not derivable from anything this module records: they are set
34
+ * by hand by the features that will own them, and nothing writes them today. When something does,
35
+ * it either belongs in this function with a fact of its own or it does not belong in this column.
36
+ *
37
+ * ## The third fact, which is not about the item at all
38
+ *
39
+ * **`under_repair` belongs to the `repairs` capability, so a workspace that has switched repairs
40
+ * off never has an asset in it.** That is not cosmetic tidying, it is the thing that stopped an
41
+ * asset being stranded: with the capability off, `repairs.complete` answers 404, so the row that
42
+ * decides `under_repair` can never be closed — and the item sat in a status nothing could move it
43
+ * out of, behind an archive that refused it for the same reason. Reading the switch here is what
44
+ * makes the capability reversible in both directions rather than one: switch repairs off and the
45
+ * next thing that touches the asset derives its status from custody alone; switch it back on and
46
+ * the open repair asserts itself again, because nothing was destroyed to get out of it.
47
+ *
48
+ * The switch is read *before* the transaction opens, like every other settings lookup in this
49
+ * module, and handed down as `repairsOn` — a `kernel.settings` call is a broker round trip and
50
+ * awaiting one while holding a pooled connection is the failure `AssetService.codeFormat`
51
+ * documents. `jobs.ts` reconciles the rows nobody happens to touch.
52
+ */
53
+ export interface StatusFacts {
54
+ /** `assets.custodian_user_id` — who is answerable for it, which a repair does not change. */
55
+ custodianUserId: string | null;
56
+ /** An **open repair this workspace is recording** — see `awayForRepair` below. */
57
+ awayForRepair: boolean;
58
+ }
59
+ export declare function deriveStatus(facts: StatusFacts): AssetStatus;
60
+ /**
61
+ * Is this item away for a repair the workspace still records?
62
+ *
63
+ * Two facts, and the capability is checked first so the query is skipped entirely for a workspace
64
+ * that does not record repairs. Every caller that decides a status goes through this rather than
65
+ * through `openRepairId` directly, so there is one answer to "is it away" and a test can hold it.
66
+ */
67
+ export declare function awayForRepair(tx: Tx, workspaceId: string, assetId: string, repairsOn: boolean): Promise<boolean>;
68
+ /**
69
+ * The open repair for one asset, if there is one — the query behind `awayForRepair`.
70
+ *
71
+ * At most one exists by construction: `inventory_repairs_one_open_uq` is a unique index on
72
+ * `(asset_id) where returned_on is null`. It lives here rather than in `RepairService` so that
73
+ * `CustodyService` can read the fact without depending on the whole repair service, and so that
74
+ * every caller decides a status the same way — read both facts, then call `deriveStatus`, never
75
+ * assume one of them.
76
+ */
77
+ /**
78
+ * The asset row, locked for the rest of the transaction — the serialisation point for `status`.
79
+ *
80
+ * **`status` is derived from two facts written by two different services, so the derivation needs a
81
+ * lock even though neither fact does.** `CustodyService` decides it from custody *and* the open
82
+ * repair; `RepairService` decides it from the repair *and* the custodian. Each used to read the
83
+ * other's fact without a lock, so a handover and a repair completing at the same instant
84
+ * interleaved into a status that matched neither: the second writer had read the first one's fact
85
+ * before it was written, derived from a snapshot that no longer existed, and overwrote the answer.
86
+ * That is a lost update in the plain sense — two transactions, one column, one of them silently
87
+ * discarded.
88
+ *
89
+ * Both paths now take this lock **before** reading either fact, so the second one waits, re-reads
90
+ * what the first committed, and derives from the state that actually exists. `for('update')`
91
+ * re-evaluates the row at the latest committed version once the lock is granted, which is exactly
92
+ * what makes the re-read honest rather than a repeat of the stale snapshot.
93
+ *
94
+ * **It is not the arbiter of "already held" or "already away".** Those stay with the exclusion
95
+ * constraint and the partial unique index, for the reason both services' docblocks give: locking
96
+ * the asset first would serialise two handovers into two *successful* handovers. This lock is taken
97
+ * after the row that decides the race has been written, so it orders the bookkeeping without
98
+ * deciding the contest.
99
+ */
100
+ export declare function lockAsset(tx: Tx, workspaceId: string, assetId: string): Promise<typeof assets.$inferSelect>;
101
+ export declare function openRepairId(tx: Tx, workspaceId: string, assetId: string): Promise<string | undefined>;
102
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../../src/server/services/status.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,EAAE,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAC3D,OAAO,EAAE,MAAM,EAAW,MAAM,cAAc,CAAA;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,MAAM,WAAW,WAAW;IAC1B,6FAA6F;IAC7F,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,kFAAkF;IAClF,aAAa,EAAE,OAAO,CAAA;CACvB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,WAAW,GAAG,WAAW,CAG5D;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,OAAO,GACjB,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED;;;;;;;;GAQG;AACH;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,SAAS,CAC7B,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,OAAO,MAAM,CAAC,YAAY,CAAC,CAQrC;AAED,wBAAsB,YAAY,CAChC,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAS7B"}
@@ -0,0 +1,71 @@
1
+ import { KernError } from '@kernhq/kernel';
2
+ import { and, eq, isNull } from 'drizzle-orm';
3
+ import { assets, repairs } from '../schema.js';
4
+ export function deriveStatus(facts) {
5
+ if (facts.awayForRepair)
6
+ return 'under_repair';
7
+ return facts.custodianUserId ? 'assigned' : 'in_stock';
8
+ }
9
+ /**
10
+ * Is this item away for a repair the workspace still records?
11
+ *
12
+ * Two facts, and the capability is checked first so the query is skipped entirely for a workspace
13
+ * that does not record repairs. Every caller that decides a status goes through this rather than
14
+ * through `openRepairId` directly, so there is one answer to "is it away" and a test can hold it.
15
+ */
16
+ export async function awayForRepair(tx, workspaceId, assetId, repairsOn) {
17
+ if (!repairsOn)
18
+ return false;
19
+ return (await openRepairId(tx, workspaceId, assetId)) !== undefined;
20
+ }
21
+ /**
22
+ * The open repair for one asset, if there is one — the query behind `awayForRepair`.
23
+ *
24
+ * At most one exists by construction: `inventory_repairs_one_open_uq` is a unique index on
25
+ * `(asset_id) where returned_on is null`. It lives here rather than in `RepairService` so that
26
+ * `CustodyService` can read the fact without depending on the whole repair service, and so that
27
+ * every caller decides a status the same way — read both facts, then call `deriveStatus`, never
28
+ * assume one of them.
29
+ */
30
+ /**
31
+ * The asset row, locked for the rest of the transaction — the serialisation point for `status`.
32
+ *
33
+ * **`status` is derived from two facts written by two different services, so the derivation needs a
34
+ * lock even though neither fact does.** `CustodyService` decides it from custody *and* the open
35
+ * repair; `RepairService` decides it from the repair *and* the custodian. Each used to read the
36
+ * other's fact without a lock, so a handover and a repair completing at the same instant
37
+ * interleaved into a status that matched neither: the second writer had read the first one's fact
38
+ * before it was written, derived from a snapshot that no longer existed, and overwrote the answer.
39
+ * That is a lost update in the plain sense — two transactions, one column, one of them silently
40
+ * discarded.
41
+ *
42
+ * Both paths now take this lock **before** reading either fact, so the second one waits, re-reads
43
+ * what the first committed, and derives from the state that actually exists. `for('update')`
44
+ * re-evaluates the row at the latest committed version once the lock is granted, which is exactly
45
+ * what makes the re-read honest rather than a repeat of the stale snapshot.
46
+ *
47
+ * **It is not the arbiter of "already held" or "already away".** Those stay with the exclusion
48
+ * constraint and the partial unique index, for the reason both services' docblocks give: locking
49
+ * the asset first would serialise two handovers into two *successful* handovers. This lock is taken
50
+ * after the row that decides the race has been written, so it orders the bookkeeping without
51
+ * deciding the contest.
52
+ */
53
+ export async function lockAsset(tx, workspaceId, assetId) {
54
+ const [row] = await tx
55
+ .select()
56
+ .from(assets)
57
+ .where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
58
+ .for('update');
59
+ if (!row)
60
+ throw KernError.notFound('Asset');
61
+ return row;
62
+ }
63
+ export async function openRepairId(tx, workspaceId, assetId) {
64
+ const [row] = await tx
65
+ .select({ id: repairs.id })
66
+ .from(repairs)
67
+ .where(and(eq(repairs.workspaceId, workspaceId), eq(repairs.assetId, assetId), isNull(repairs.returnedOn)))
68
+ .limit(1);
69
+ return row?.id;
70
+ }
71
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../../../src/server/services/status.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAW,MAAM,gBAAgB,CAAA;AACnD,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAE7C,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AA0D9C,MAAM,UAAU,YAAY,CAAC,KAAkB;IAC7C,IAAI,KAAK,CAAC,aAAa;QAAE,OAAO,cAAc,CAAA;IAC9C,OAAO,KAAK,CAAC,eAAe,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU,CAAA;AACxD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,EAAM,EACN,WAAmB,EACnB,OAAe,EACf,SAAkB;IAElB,IAAI,CAAC,SAAS;QAAE,OAAO,KAAK,CAAA;IAC5B,OAAO,CAAC,MAAM,YAAY,CAAC,EAAE,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC,KAAK,SAAS,CAAA;AACrE,CAAC;AAED;;;;;;;;GAQG;AACH;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,EAAM,EACN,WAAmB,EACnB,OAAe;IAEf,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,EAAE;SACnB,MAAM,EAAE;SACR,IAAI,CAAC,MAAM,CAAC;SACZ,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,WAAW,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC;SACvE,GAAG,CAAC,QAAQ,CAAC,CAAA;IAChB,IAAI,CAAC,GAAG;QAAE,MAAM,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAA;IAC3C,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,EAAM,EACN,WAAmB,EACnB,OAAe;IAEf,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,EAAE;SACnB,MAAM,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC;SAC1B,IAAI,CAAC,OAAO,CAAC;SACb,KAAK,CACJ,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,WAAW,EAAE,WAAW,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CACpG;SACA,KAAK,CAAC,CAAC,CAAC,CAAA;IACX,OAAO,GAAG,EAAE,EAAE,CAAA;AAChB,CAAC"}
@@ -0,0 +1,23 @@
1
+ -- Custody, the history read side, and archivable categories.
2
+ --
3
+ -- Generated from `src/server/schema.ts` with `pnpm db:generate` and then guarded, in that order.
4
+ -- Hand-writing the SQL is what puts an unguarded `ALTER TABLE … ADD PRIMARY KEY` in a file instead
5
+ -- of an inline key inside a `CREATE TABLE IF NOT EXISTS`; generating first and adding the guards
6
+ -- after keeps the file the schema's own output.
7
+ --
8
+ -- **Append-only from here.** 0.2.0 is published and `core` depends on it, so this file adds and
9
+ -- never rewrites: a nullable column and an index, both of which the 0.2.0 image can read straight
10
+ -- past. Nothing is dropped, renamed or narrowed, so rolling that image back needs no dump.
11
+ --
12
+ -- **Both statements are idempotent.** A module's migrations are the first thing the kernel runs, so
13
+ -- one that throws does not break its own feature — it takes down the whole host service, and `core`
14
+ -- hosts five modules. `migrations.test.ts` applies this folder twice against a database created
15
+ -- from nothing and is what proves the guards are real rather than intended.
16
+
17
+ ALTER TABLE "mod_inventory"."categories" ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;--> statement-breakpoint
18
+
19
+ -- `assets.history` pages by id, not by `created_at`: an id is uuidv7 so it already carries the
20
+ -- clock, and it is unique where a timestamp two rows written in one transaction share is not. The
21
+ -- index `0000_init.sql` created starts (workspace_id, asset_id, created_at) and cannot serve
22
+ -- `order by id desc`; this one can.
23
+ CREATE INDEX IF NOT EXISTS "inventory_asset_history_ws_asset_row_idx" ON "mod_inventory"."asset_history" USING btree ("workspace_id","asset_id","id");
@@ -0,0 +1,23 @@
1
+ -- One open repair per asset.
2
+ --
3
+ -- Generated from `src/server/schema.ts` with `pnpm db:generate` and then guarded, in that order —
4
+ -- generating first is what keeps the file the schema's own output rather than a hand-written guess
5
+ -- at it.
6
+ --
7
+ -- **Append-only.** 0.2.0 is published and `core` depends on it, so this file adds an index and
8
+ -- nothing else: no table is created, dropped, renamed or narrowed, and the 0.2.0 image reads the
9
+ -- schema afterwards exactly as it read it before. Rolling that image back needs no dump.
10
+ --
11
+ -- **The guard is not decoration.** A module's migrations are the first thing the kernel runs, so one
12
+ -- that throws does not break its own feature — it takes down the whole host service, and `core`
13
+ -- hosts five modules. drizzle emits a bare `CREATE UNIQUE INDEX` here; `if not exists` is added by
14
+ -- hand, and `migrations.test.ts` applies this folder twice against a database created from nothing
15
+ -- to prove the guard is real rather than intended.
16
+ --
17
+ -- Why an index and not a service check: two people pressing *Send for repair* on the same laptop in
18
+ -- the same instant both read "it is here" and both insert. Checking first and inserting after is the
19
+ -- race, not the fix. Custody settles the same problem with a GiST exclusion constraint; a repair has
20
+ -- no range to overlap, only a flag, so a partial unique index says exactly the same thing more
21
+ -- cheaply. `RepairService` translates the resulting 23505 into a sentence.
22
+
23
+ CREATE UNIQUE INDEX IF NOT EXISTS "inventory_repairs_one_open_uq" ON "mod_inventory"."repairs" USING btree ("asset_id") WHERE returned_on is null;
@@ -0,0 +1,51 @@
1
+ -- The platform surfaces: a workspace registry the scheduler can read, and the two "already told
2
+ -- somebody" markers the two sweeps need.
3
+ --
4
+ -- Generated from `src/server/schema.ts` with `pnpm db:generate` and then guarded, in that order.
5
+ -- Hand-writing the SQL is what puts an unguarded `ALTER TABLE … ADD PRIMARY KEY` in a file instead
6
+ -- of an inline key inside a `CREATE TABLE IF NOT EXISTS`; generating first keeps the file the
7
+ -- schema's own output. The `if not exists` on the table, the two columns and the index, and the
8
+ -- `drop policy if exists` under them, are what was added afterwards.
9
+ --
10
+ -- **Append-only.** 0.2.0 is published and `core` depends on it, so this file only adds: a new table,
11
+ -- two nullable columns and an index. Nothing is dropped, renamed or narrowed, so the 0.2.0 image
12
+ -- reads the schema afterwards exactly as it read it before and rolling back needs no dump.
13
+ --
14
+ -- **Every statement is idempotent.** A module's migrations are the first thing the kernel runs, so
15
+ -- one that throws does not break its own feature — it takes down the whole host service, and `core`
16
+ -- hosts five modules. `migrations.test.ts` applies this folder twice against a database created from
17
+ -- nothing and is what proves the guards are real rather than intended.
18
+ --
19
+ -- Why `workspaces` exists at all: a cron handler is woken by a clock, so it starts with no workspace
20
+ -- and nothing to derive one from. Core would answer, and asking it every tick makes an overnight
21
+ -- sweep fail whenever core is briefly away. `module-tracker` keeps the same table for the same
22
+ -- reason. It gets a policy like every other table carrying `workspace_id`; the enumeration itself
23
+ -- cannot honour that policy — `app.workspace_id` is unset there by definition — and reads as the
24
+ -- owner, which is what every module's scheduler here already does.
25
+
26
+ CREATE TABLE IF NOT EXISTS "mod_inventory"."workspaces" (
27
+ "workspace_id" uuid PRIMARY KEY NOT NULL,
28
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
29
+ );
30
+ --> statement-breakpoint
31
+
32
+ alter table "mod_inventory"."workspaces" enable row level security;--> statement-breakpoint
33
+ alter table "mod_inventory"."workspaces" force row level security;--> statement-breakpoint
34
+ drop policy if exists "workspaces_ws_isolation" on "mod_inventory"."workspaces";--> statement-breakpoint
35
+ create policy "workspaces_ws_isolation" on "mod_inventory"."workspaces"
36
+ using (workspace_id::text = current_setting('app.workspace_id', true))
37
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
38
+
39
+ -- A warranty falls inside the notice window on every one of the days before it expires, so a sweep
40
+ -- with no marker sends the same notice every morning for a month. Cleared when `warranty_until`
41
+ -- itself moves.
42
+ ALTER TABLE "mod_inventory"."assets" ADD COLUMN IF NOT EXISTS "warranty_notified_at" timestamp with time zone;--> statement-breakpoint
43
+
44
+ -- The same, for a repair that has been away too long: it is still too long tomorrow.
45
+ ALTER TABLE "mod_inventory"."repairs" ADD COLUMN IF NOT EXISTS "overdue_notified_at" timestamp with time zone;--> statement-breakpoint
46
+
47
+ -- What the overdue sweep reads: one workspace's still-open repairs, oldest first.
48
+ -- `inventory_repairs_ws_asset_idx` starts (workspace_id, asset_id, sent_on) and cannot answer this
49
+ -- without visiting every asset, and `inventory_repairs_one_open_uq` is partial on the right
50
+ -- predicate but keyed by `asset_id` alone, so it cannot be scoped to a workspace.
51
+ CREATE INDEX IF NOT EXISTS "inventory_repairs_ws_open_idx" ON "mod_inventory"."repairs" USING btree ("workspace_id","sent_on") WHERE returned_on is null;
@@ -0,0 +1,35 @@
1
+ -- A repair cannot come back before it was sent.
2
+ --
3
+ -- Generated from `src/server/schema.ts` with `pnpm db:generate` and then guarded, in that order —
4
+ -- generating first is what keeps the file the schema's own output rather than a hand-written guess
5
+ -- at it. The `drop constraint if exists` and the `not valid` are what was added afterwards.
6
+ --
7
+ -- **Append-only.** 0.2.0 is published and `core` depends on it, so this file adds a constraint and
8
+ -- nothing else: no table is created, dropped, renamed or narrowed, and the 0.2.0 image reads the
9
+ -- schema afterwards exactly as it read it before. Rolling that image back needs no dump.
10
+ --
11
+ -- **Idempotent.** `add constraint` has no `if not exists`, so it is preceded by an explicit drop —
12
+ -- the same shape `0001_rls.sql` uses for the custody exclusion constraint, and for the same reason:
13
+ -- a module's migrations are the first thing the kernel runs, so one that throws does not degrade its
14
+ -- own feature, it stops the host service booting, and `core` hosts five modules. A regenerated
15
+ -- `migrations/meta/_journal.json` is enough to cause a replay. `migrations.test.ts` applies this
16
+ -- folder twice against a database created from nothing to prove the guard is real.
17
+ --
18
+ -- **`not valid`, and that is the whole difference between a safe upgrade and an outage.** The
19
+ -- constraint is enforced on every insert and update from the moment it exists; what `not valid`
20
+ -- skips is the scan of rows that are already there. Those rows are exactly the ones this defect may
21
+ -- have written — `repairs.update` would move `sent_on` past a `returned_on` — and a validating
22
+ -- constraint that met one of them would throw *during migration*, on somebody's instance, during an
23
+ -- upgrade, taking core down with it. An instance that wants the scan can run
24
+ -- `alter table … validate constraint …` by hand once it has corrected its data; nothing needs it to.
25
+ --
26
+ -- Why the database as well as the service: `repairs.update` and `repairs.complete` each check the
27
+ -- pair before writing it, and two transactions can each pass that check and still commit a pair that
28
+ -- fails it — one moving `sent_on` while the other logs the item back. The service check is what
29
+ -- gives an ordinary mistake a sentence; this is what makes the rule true of the table.
30
+
31
+ ALTER TABLE "mod_inventory"."repairs"
32
+ DROP CONSTRAINT IF EXISTS "inventory_repairs_returned_after_sent";--> statement-breakpoint
33
+ ALTER TABLE "mod_inventory"."repairs"
34
+ ADD CONSTRAINT "inventory_repairs_returned_after_sent"
35
+ CHECK (returned_on is null or returned_on >= sent_on) NOT VALID;