@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
@@ -1,13 +1,15 @@
1
1
  import { moduleSchema } from '@kernhq/kernel'
2
2
  import { sql } from 'drizzle-orm'
3
3
  import {
4
+ bigint,
4
5
  boolean,
5
6
  char,
7
+ check,
6
8
  date,
7
9
  index,
8
10
  integer,
9
11
  jsonb,
10
- pgEnum,
12
+ primaryKey,
11
13
  text,
12
14
  timestamp,
13
15
  uniqueIndex,
@@ -34,19 +36,51 @@ const created = () => ts('created_at').notNull().defaultNow()
34
36
  const updated = () => ts('updated_at').notNull().defaultNow()
35
37
 
36
38
  /**
37
- * Asset lifecycle. Stored rather than derived because every list filter asks for it; every
38
- * transition is written inside the same transaction as the row it derives from.
39
+ * Workspaces this module is switched on in, so scheduled work can find them.
40
+ *
41
+ * A cron handler starts with no workspace: it is woken by a clock, not by a request, so there is
42
+ * nothing to derive a tenant from and no `withWorkspace` it could already be inside. Core knows the
43
+ * answer and would give it (`core.workspaces.list`), but asking it every tick makes the sweeps fail
44
+ * whenever core is briefly away — for a job whose whole point is to run unattended overnight. So the
45
+ * module keeps the one fact it needs: an id, written when the module is switched on for a workspace
46
+ * and when a workspace is created. `module-tracker` keeps the same table for the same reason.
47
+ *
48
+ * **It carries `workspace_id`, so it is a tenant table and has a policy like every other** — and the
49
+ * one reader that cannot honour that policy is the enumeration itself, because `app.workspace_id` is
50
+ * unset in a job by definition. That was left resting on "the connection is the schema's owner, and
51
+ * an owner bypasses RLS", which is **false here**: every table in this schema carries `force row
52
+ * level security`, and forcing it subjects the owner to the policies too. So `select workspace_id
53
+ * from workspaces` with no workspace bound answered zero rows — not an error, not a warning, simply
54
+ * nothing to sweep, every night, for ever.
55
+ *
56
+ * `0006_workspace_registry_read.sql` is the fix and it is scoped as narrowly as the problem: one
57
+ * extra `for select` policy, on this table only, admitting a session that has **no** workspace bound.
58
+ * A request-bound session still sees exactly its own row, and this table holds nothing but workspace
59
+ * ids anyway — it is the module's own bookkeeping, not tenant data anybody reads. Added in `0004`,
60
+ * because 0.2.0 is published.
39
61
  */
40
- export const assetStatus = pgEnum('asset_status', ['in_stock', 'assigned', 'under_repair', 'retired'])
62
+ export const workspaces = schema.table('workspaces', {
63
+ workspaceId: uuid('workspace_id').primaryKey(),
64
+ createdAt: created(),
65
+ })
41
66
 
42
67
  export const counters = schema.table(
43
68
  'counters',
44
69
  /** Per-workspace sequence sources (`asset_code`). Narrow on purpose: one row per key. */
45
70
  {
46
- workspaceId: ws().primaryKey(),
47
- key: text('key').primaryKey(),
71
+ workspaceId: ws(),
72
+ key: text('key').notNull(),
48
73
  value: integer('value').notNull(),
49
74
  },
75
+ /**
76
+ * Composite, declared here rather than as two `.primaryKey()` columns.
77
+ *
78
+ * Column-level `.primaryKey()` twice reads like a compound key and is not one: drizzle emits
79
+ * `PRIMARY KEY` on both columns and Postgres refuses the table outright — "multiple primary keys
80
+ * for table are not allowed", SQLSTATE 42P16. The module's migration is the first thing the
81
+ * kernel runs, so the failure is not a broken table but a service that will not boot.
82
+ */
83
+ (t) => [primaryKey({ columns: [t.workspaceId, t.key] })],
50
84
  )
51
85
 
52
86
  export const categories = schema.table(
@@ -56,10 +90,25 @@ export const categories = schema.table(
56
90
  workspaceId: ws(),
57
91
  name: text('name').notNull(),
58
92
  order: integer('order').notNull().default(0),
93
+ /**
94
+ * Archived rather than deleted, and the reason is `assets.category_id`.
95
+ *
96
+ * There is no foreign key — a module keeps its joins inside its own schema and its ids plain —
97
+ * so deleting a row here would leave every asset filed under it pointing at nothing. The
98
+ * category column on the row would go blank and the timeline entry that says "category changed
99
+ * to Laptops" would lose the word "Laptops": data destroyed by a settings screen, silently, and
100
+ * with no way back. Archiving takes the category out of every picker and every filter and
101
+ * leaves each asset able to say what it is.
102
+ *
103
+ * Added in `0002`, because 0.2.0 is published.
104
+ */
105
+ archivedAt: ts('archived_at'),
59
106
  createdAt: created(),
60
107
  updatedAt: updated(),
61
108
  },
62
109
  (t) => [
110
+ // Unique across archived rows too: two categories called "Laptops", one of them archived, is
111
+ // two rows a picker cannot tell apart the moment somebody restores the second.
63
112
  uniqueIndex('inventory_categories_ws_name_uq').on(t.workspaceId, t.name),
64
113
  index('inventory_categories_ws_idx').on(t.workspaceId, t.order),
65
114
  ],
@@ -74,7 +123,10 @@ export const assets = schema.table(
74
123
  name: text('name').notNull(),
75
124
  description: text('description').notNull().default(''),
76
125
  categoryId: uuid('category_id'),
77
- status: assetStatus('status').notNull().default('in_stock'),
126
+ /** `AssetStatus` in the contract. Text, not a pg enum: every other Kern module stores a
127
+ * status this way, and an enum named `asset_status` in `public` is a type this module
128
+ * leaves behind when it is removed. */
129
+ status: text('status').notNull().default('in_stock'),
78
130
  /** Denormalized from `custody_periods`, which stays authoritative for history. */
79
131
  custodianUserId: uuid('custodian_user_id'),
80
132
  custodySince: ts('custody_since'),
@@ -85,7 +137,21 @@ export const assets = schema.table(
85
137
  priceMinor: integer('price_minor'),
86
138
  currency: char('currency', { length: 3 }),
87
139
  warrantyUntil: date('warranty_until'),
140
+ /**
141
+ * When the warranty sweep last told somebody this one is about to run out.
142
+ *
143
+ * The idempotency row `kern-service` asks scheduled work for, as a column. A warranty falls
144
+ * inside the notice window for every one of the thirty days before it expires, so a sweep with
145
+ * no marker sends the same notice thirty times — and `groupKey` collapses that in the
146
+ * notification centre while still sending thirty emails. Cleared by `assets.update` whenever
147
+ * `warranty_until` itself moves, so extending a warranty earns a fresh notice at the new date.
148
+ *
149
+ * Nullable and added in `0004`, so the 0.2.0 image reads the table straight past it.
150
+ */
151
+ warrantyNotifiedAt: ts('warranty_notified_at'),
88
152
  photoFileId: uuid('photo_file_id'),
153
+ /** Values for this workspace's own `field_defs`, keyed by their `key`. */
154
+ custom: jsonb('custom').$type<Record<string, unknown>>().notNull().default({}),
89
155
  createdAt: created(),
90
156
  updatedAt: updated(),
91
157
  archivedAt: ts('archived_at'),
@@ -95,6 +161,10 @@ export const assets = schema.table(
95
161
  index('inventory_assets_ws_created_idx').on(t.workspaceId, t.createdAt),
96
162
  index('inventory_assets_ws_status_idx').on(t.workspaceId, t.status),
97
163
  index('inventory_assets_ws_category_idx').on(t.workspaceId, t.categoryId),
164
+ // "What is Ada holding?" — asked by the person, by the offboarding hook, and by a widget.
165
+ index('inventory_assets_ws_custodian_idx')
166
+ .on(t.workspaceId, t.custodianUserId)
167
+ .where(sql`custodian_user_id is not null`),
98
168
  // The "what leaves warranty this month" scan, before a job makes it a widget's cheap query.
99
169
  index('inventory_assets_ws_warranty_idx')
100
170
  .on(t.workspaceId, t.warrantyUntil)
@@ -166,12 +236,52 @@ export const assetHistory = schema.table(
166
236
  workspaceId: ws(),
167
237
  assetId: uuid('asset_id').notNull(),
168
238
  actorId: uuid('actor_id'),
169
- action: text('action').notNull(), // created | updated | transferred | returned | repair_logged | repair_completed | attachment_added | retired | restored
239
+ action: text('action').notNull(), // created | updated | assigned | transferred | returned | repair_logged | repair_completed | attachment_added | retired | restored
170
240
  changes: jsonb('changes').$type<{ field: string; from: unknown; to: unknown }[]>(),
171
241
  data: jsonb('data'),
172
242
  occurredAt: created(),
243
+ /**
244
+ * The order these entries actually happened in — a sequence, not a clock and not the id.
245
+ *
246
+ * **A uuidv7 is only ordered to the millisecond.** The kernel's `uuidv7()` puts the clock in
247
+ * bytes 0-5 and fills bytes 6-15 from `randomUUID()` with no intra-millisecond counter, so two
248
+ * entries written in the same millisecond sort by ten random bytes. That is *stable* for a
249
+ * given set of rows, which is why keyset paging never dropped or repeated one and why the
250
+ * defect hid for so long — what it produced instead was a timeline rendering "Bruno removed the
251
+ * file" above "Bruno added the file". A register's whole value is that it says what happened in
252
+ * the order it happened.
253
+ *
254
+ * `occurred_at` cannot stand in for it either: it defaults to `now()`, which is the
255
+ * *transaction* timestamp, so a create and its first history row share it exactly.
256
+ *
257
+ * A sequence is exact rather than probabilistic — `nextval` is strictly increasing whatever the
258
+ * clock does — and it is the one ordering key a module owns outright. Filled by the column
259
+ * default, so nothing in the service has to remember it and the previous image's inserts get
260
+ * one too. Added in `0007`.
261
+ */
262
+ seq: bigint('seq', { mode: 'number' }).notNull().default(sql`nextval('mod_inventory.asset_history_seq')`),
173
263
  },
174
- (t) => [index('inventory_asset_history_asset_idx').on(t.workspaceId, t.assetId, t.occurredAt)],
264
+ (t) => [
265
+ index('inventory_asset_history_asset_idx').on(t.workspaceId, t.assetId, t.occurredAt),
266
+ /**
267
+ * Superseded by the index below, and left here on purpose.
268
+ *
269
+ * The timeline used to be keyset-paged by **id**, on the reasoning `assets.list` still gives for
270
+ * `sort: 'recent'` — an id is uuidv7, so it carries the clock, and it is unique where
271
+ * `occurred_at` is not. Unique it is; ordered it is only to the millisecond. `seq` replaced it.
272
+ *
273
+ * Dropping this is a schema change and a migration here only adds, so it goes one release from
274
+ * now rather than in the one that stopped reading it. Added in `0002`.
275
+ */
276
+ index('inventory_asset_history_ws_asset_row_idx').on(t.workspaceId, t.assetId, t.id),
277
+ /**
278
+ * What `assets.history` pages on: one asset's entries, newest first, bounded by `seq`.
279
+ *
280
+ * Unique, because the sequence makes it so and saying so lets Postgres stop at the first match
281
+ * for a cursor. Added in `0007`.
282
+ */
283
+ uniqueIndex('inventory_asset_history_ws_asset_seq_uq').on(t.workspaceId, t.assetId, t.seq),
284
+ ],
175
285
  )
176
286
 
177
287
  export const repairs = schema.table(
@@ -188,11 +298,66 @@ export const repairs = schema.table(
188
298
  sentOn: date('sent_on').notNull(),
189
299
  /** Null while the item is still away — also how `under_repair` is derived. */
190
300
  returnedOn: date('returned_on'),
301
+ /**
302
+ * When the overdue sweep last said this one has been away too long.
303
+ *
304
+ * Same job as `assets.warranty_notified_at` and for the same reason: a repair that passed the
305
+ * threshold yesterday has passed it again today, so a sweep with no marker chases the same
306
+ * vendor every morning until the item comes back. Cleared by `repairs.update` when `sent_on`
307
+ * moves, because that is the date the threshold is measured from.
308
+ *
309
+ * Nullable and added in `0004`, so the 0.2.0 image reads the table straight past it.
310
+ */
311
+ overdueNotifiedAt: ts('overdue_notified_at'),
191
312
  createdBy: uuid('created_by'),
192
313
  createdAt: created(),
193
314
  updatedAt: updated(),
194
315
  },
195
- (t) => [index('inventory_repairs_ws_asset_idx').on(t.workspaceId, t.assetId, t.sentOn)],
316
+ (t) => [
317
+ index('inventory_repairs_ws_asset_idx').on(t.workspaceId, t.assetId, t.sentOn),
318
+ /**
319
+ * What the overdue sweep reads: one workspace's still-open repairs, oldest first.
320
+ *
321
+ * The index above starts (workspace_id, asset_id, sent_on) and cannot answer "every open repair
322
+ * in this workspace sent before a date" without visiting every asset; `inventory_repairs_one_
323
+ * open_uq` is partial on the right predicate but keyed by `asset_id`, so it cannot be scoped to
324
+ * a workspace either. Added in `0004`.
325
+ */
326
+ index('inventory_repairs_ws_open_idx').on(t.workspaceId, t.sentOn).where(sql`returned_on is null`),
327
+ /**
328
+ * One open repair per asset, decided by the database rather than by the service.
329
+ *
330
+ * Custody leans on a GiST exclusion constraint for the same reason and this is the cheaper
331
+ * version of it: a repair has no range to overlap, only a flag, so a partial unique index says
332
+ * exactly the same thing. Two people pressing *Send for repair* on the same laptop in the same
333
+ * instant both read "it is here", both insert, and Postgres refuses one of them — where a
334
+ * `select … for update` would serialise them into two open repairs, which is a worse answer
335
+ * wearing the clothes of a safer one. `RepairService` turns the 23505 into a sentence.
336
+ *
337
+ * Not scoped by `workspace_id`: an asset id belongs to exactly one workspace, and adding the
338
+ * column would let the same asset be open in two of them if an id ever leaked across.
339
+ *
340
+ * Added in `0003`, because 0.2.0 is published.
341
+ */
342
+ uniqueIndex('inventory_repairs_one_open_uq').on(t.assetId).where(sql`returned_on is null`),
343
+ /**
344
+ * A repair cannot come back before it was sent.
345
+ *
346
+ * Two dates a person types, and nothing but arithmetic decides whether the pair means anything:
347
+ * stored the wrong way round, every "how long was it away" answer is negative and the overdue
348
+ * sweep measures from a date in the future. `repairs.complete` refused it from the start;
349
+ * `repairs.update` did not, and could move `sent_on` past the `returned_on` of a repair that had
350
+ * already come back. The service checks first so a person gets a sentence, and this is what
351
+ * makes the rule true of the table rather than of the code paths somebody remembered.
352
+ *
353
+ * Added in `0005` as `not valid`: the constraint is enforced on every insert and update from
354
+ * that moment, and existing rows are not scanned. That is deliberate — a module's migrations
355
+ * are the first thing the kernel runs, so a validating constraint that met one bad row left
356
+ * over from this defect would not degrade repairs, it would stop the whole host service
357
+ * booting. `RepairService` translates the resulting 23514 into a sentence.
358
+ */
359
+ check('inventory_repairs_returned_after_sent', sql`returned_on is null or returned_on >= sent_on`),
360
+ ],
196
361
  )
197
362
 
198
363
  /** Bytes live in core object storage via `uploadFile`; this only records that an asset has one. */
@@ -218,6 +383,15 @@ export const attachments = schema.table(
218
383
 
219
384
  /** Every tenant table, so the RLS migration can be checked against one list rather than memory. */
220
385
  export const TENANT_TABLES = [
386
+ // `counters` is a tenant table like any other: it carries `workspace_id`, so one workspace's
387
+ // asset-code sequence is readable to another without a policy. It was left out of this list —
388
+ // and therefore out of `0001_rls.sql` — because it holds no asset data, which is not the rule
389
+ // the file states at the top. Tracker's structurally identical `issue_counters` is covered.
390
+ 'counters',
391
+ // Holds nothing but tenant ids, and is still a tenant table: the rule this list encodes is "has a
392
+ // `workspace_id` column", not "holds asset data". `module-tracker`'s equivalent is deliberately
393
+ // unpolicied and that is the one shape of exception this module does not make.
394
+ 'workspaces',
221
395
  'categories',
222
396
  'assets',
223
397
  'field_defs',