@kernhq/module-inventory 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +52 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +33 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +22 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +146 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +107 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +22 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +26 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +42 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +18 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +29 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +4 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +9 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
- package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
- package/dist/server/router.js +83 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +25 -11
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +28 -10
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +73 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +261 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/index.d.ts +10 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +15 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/notify.d.ts +63 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +105 -0
- package/dist/server/services/notify.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/_journal.json +2 -2
- package/package.json +5 -4
- package/src/client/api.ts +1 -1
- package/src/client/components/AssetFormDialog.svelte +130 -47
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/messages.test.ts +169 -0
- package/src/client/messages.ts +399 -0
- package/src/client/mock.test.ts +161 -0
- package/src/client/mock.ts +267 -45
- package/src/client/module.ts +22 -2
- package/src/client/pages/AssetsPage.svelte +358 -137
- package/src/client/permissions.ts +1 -1
- package/src/client/price.test.ts +106 -0
- package/src/client/price.ts +135 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +15 -2
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/settings/core-api.ts +32 -0
- package/src/client/widgets/OverviewWidget.svelte +16 -3
- package/src/contract/capabilities.ts +55 -0
- package/src/contract/events.ts +34 -0
- package/src/contract/index.ts +15 -0
- package/src/contract/models.ts +123 -0
- package/src/contract/permissions.ts +26 -0
- package/src/contract/router.ts +46 -0
- package/src/contract/settings.ts +30 -0
- package/src/module.test.ts +140 -7
- package/src/server/index.ts +16 -13
- package/src/server/inventory.int.test.ts +819 -0
- package/src/server/migrations.test.ts +138 -0
- package/src/server/router.ts +118 -0
- package/src/server/schema.ts +27 -10
- package/src/server/services/assets.ts +368 -0
- package/src/server/services/index.ts +23 -0
- package/src/server/services/notify.ts +151 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import pg from 'pg'
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
6
|
+
import { TENANT_TABLES } from './schema.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The migration folder, applied to a database created from nothing — and then applied again.
|
|
10
|
+
*
|
|
11
|
+
* The integration suite already proves the folder *applies*, because `kernel.start()` runs it. It
|
|
12
|
+
* does not prove the folder is **replayable**, and that is the property that took `core` down: the
|
|
13
|
+
* kernel migrates every module at boot, so one migration that throws does not degrade its own
|
|
14
|
+
* feature — `core` hosts five modules and never binds its port. A regenerated
|
|
15
|
+
* `migrations/meta/_journal.json` is enough to cause a replay, because every entry gets a `when`
|
|
16
|
+
* newer than the rows already in `mod_inventory.__migrations`.
|
|
17
|
+
*
|
|
18
|
+
* Three things here are deliberate, and each of them is a way this test could have been vacuously
|
|
19
|
+
* green instead:
|
|
20
|
+
*
|
|
21
|
+
* 1. **A scratch database, created here.** Running against a database somebody has already migrated
|
|
22
|
+
* proves nothing — it is the same shape of mistake as asserting that `migrateModule` succeeds
|
|
23
|
+
* twice against a schema that already exists.
|
|
24
|
+
* 2. **Every statement is executed separately and every failure is collected**, rather than throwing
|
|
25
|
+
* on the first. Guarding one class of statement and re-running tells you only about the next one;
|
|
26
|
+
* collecting them says how much is actually unguarded. In `module-hr` that difference was 203
|
|
27
|
+
* statements hiding behind the policies somebody had just fixed.
|
|
28
|
+
* 3. **Policies are asserted as `(tablename, policyname)` pairs, not as a count per table.** "One
|
|
29
|
+
* policy per table" is the wrong invariant — a table may legitimately carry several — and a
|
|
30
|
+
* duplicate pair is exactly what a replay produces.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
34
|
+
const MIGRATIONS = join(HERE, '../../migrations')
|
|
35
|
+
|
|
36
|
+
const BASE_URL = process.env.DATABASE_URL ?? 'postgres://kern:kern@localhost:5432/kern'
|
|
37
|
+
const DB_NAME = `kern_inventory_replay_${Date.now().toString(36)}`
|
|
38
|
+
|
|
39
|
+
let admin: pg.Client
|
|
40
|
+
let db: pg.Client
|
|
41
|
+
|
|
42
|
+
/** The folder in the order the kernel applies it — by filename, which is why they are numbered. */
|
|
43
|
+
function migrationFiles(): string[] {
|
|
44
|
+
return readdirSync(MIGRATIONS)
|
|
45
|
+
.filter((f) => f.endsWith('.sql'))
|
|
46
|
+
.sort()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Apply one file statement by statement, returning every failure rather than the first.
|
|
51
|
+
*
|
|
52
|
+
* `--> statement-breakpoint` is drizzle's separator. Splitting on it is also why nothing in this
|
|
53
|
+
* folder may use a dollar-quoted body: a breakpoint inside `do $$ … end $$` cuts the function in
|
|
54
|
+
* half, and the error is `unterminated dollar-quoted string`, which does not sound like what it is.
|
|
55
|
+
*/
|
|
56
|
+
async function apply(file: string): Promise<Array<{ statement: string; error: string }>> {
|
|
57
|
+
const sql = readFileSync(join(MIGRATIONS, file), 'utf8')
|
|
58
|
+
const failures: Array<{ statement: string; error: string }> = []
|
|
59
|
+
for (const raw of sql.split('--> statement-breakpoint')) {
|
|
60
|
+
const statement = raw.trim()
|
|
61
|
+
if (!statement || statement.split('\n').every((l) => l.trim().startsWith('--'))) continue
|
|
62
|
+
try {
|
|
63
|
+
await db.query(statement)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
failures.push({
|
|
66
|
+
statement: statement.slice(0, 120).replace(/\s+/g, ' '),
|
|
67
|
+
error: err instanceof Error ? err.message : String(err),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return failures
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
admin = new pg.Client({ connectionString: BASE_URL })
|
|
76
|
+
await admin.connect()
|
|
77
|
+
await admin.query(`create database "${DB_NAME}"`)
|
|
78
|
+
const url = new URL(BASE_URL)
|
|
79
|
+
url.pathname = `/${DB_NAME}`
|
|
80
|
+
db = new pg.Client({ connectionString: url.toString() })
|
|
81
|
+
await db.connect()
|
|
82
|
+
}, 120_000)
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
await db?.end().catch(() => undefined)
|
|
86
|
+
await admin?.query(`drop database if exists "${DB_NAME}" with (force)`).catch(() => undefined)
|
|
87
|
+
await admin?.end().catch(() => undefined)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('the migration folder', () => {
|
|
91
|
+
it('applies to a database created from nothing', async () => {
|
|
92
|
+
for (const file of migrationFiles()) {
|
|
93
|
+
expect(await apply(file), `${file}, first pass`).toEqual([])
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('applies a second time, because a replay must not take down the host service', async () => {
|
|
98
|
+
for (const file of migrationFiles()) {
|
|
99
|
+
expect(await apply(file), `${file}, replay`).toEqual([])
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('leaves exactly one of every policy after the replay', async () => {
|
|
104
|
+
const { rows } = await db.query<{ tablename: string; policyname: string }>(
|
|
105
|
+
`select tablename, policyname from pg_policies where schemaname = 'mod_inventory'`,
|
|
106
|
+
)
|
|
107
|
+
const seen = rows.map((r) => `${r.tablename}.${r.policyname}`)
|
|
108
|
+
expect([...new Set(seen)].sort(), 'a duplicate pair is what a replay produces').toEqual(seen.sort())
|
|
109
|
+
// And the policies are the ones the module declares it has.
|
|
110
|
+
for (const table of TENANT_TABLES)
|
|
111
|
+
expect(seen, `mod_inventory.${table} has its policy`).toContain(`${table}.${table}_ws_isolation`)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('forces row-level security on every table that carries a policy', async () => {
|
|
115
|
+
// Restricted to tables that have one: a module may legitimately keep an unsecured table, and
|
|
116
|
+
// asserting over every table in the schema would fail on drizzle's own `__migrations`.
|
|
117
|
+
const { rows } = await db.query<{ relname: string; forced: boolean }>(
|
|
118
|
+
`select c.relname, c.relforcerowsecurity as forced
|
|
119
|
+
from pg_class c
|
|
120
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
121
|
+
where n.nspname = 'mod_inventory'
|
|
122
|
+
and c.relkind = 'r'
|
|
123
|
+
and exists (select 1 from pg_policies p
|
|
124
|
+
where p.schemaname = 'mod_inventory' and p.tablename = c.relname)`,
|
|
125
|
+
)
|
|
126
|
+
expect(rows.length, 'no policies found at all — the RLS migration did not run').toBeGreaterThan(0)
|
|
127
|
+
for (const row of rows) expect(row.forced, `mod_inventory.${row.relname} forces RLS`).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('keeps the exclusion constraint that makes two open custody periods impossible', async () => {
|
|
131
|
+
const { rows } = await db.query<{ n: string }>(
|
|
132
|
+
`select count(*) as n from pg_constraint where conname = 'inventory_custody_no_overlap'`,
|
|
133
|
+
)
|
|
134
|
+
// Exactly one: `add constraint` is not idempotent on its own, so a replay without the
|
|
135
|
+
// preceding `drop constraint if exists` would either throw or leave two.
|
|
136
|
+
expect(Number(rows[0]?.n)).toBe(1)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { WorkspaceId } from '@kernhq/contracts'
|
|
2
|
+
import {
|
|
3
|
+
defineModule,
|
|
4
|
+
defineServerModule,
|
|
5
|
+
type Kernel,
|
|
6
|
+
packageVersion,
|
|
7
|
+
type RequestContext,
|
|
8
|
+
requires,
|
|
9
|
+
type Tx,
|
|
10
|
+
workspaceScoped,
|
|
11
|
+
} from '@kernhq/kernel'
|
|
12
|
+
import { implement } from '@orpc/server'
|
|
13
|
+
import { inventoryContract, inventoryEvents, MODULE_ID } from '../contract/index.js'
|
|
14
|
+
import { toAsset, type Written } from './services/assets.js'
|
|
15
|
+
import { inventoryServices } from './services/index.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* oRPC router for `/api/inventory`.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately thin: it opens the workspace-bound transaction and hands straight over to a service,
|
|
21
|
+
* which is where the logic lives. Kept apart from `index.ts` so `module.test.ts` can walk it as data
|
|
22
|
+
* without booting a kernel.
|
|
23
|
+
*
|
|
24
|
+
* Two middlewares on every procedure, and the test fails if either is missing: `workspaceScoped`
|
|
25
|
+
* (a real membership, and the module switched on for that workspace) and `requires` (the permission
|
|
26
|
+
* this particular call needs). A capability adds a third, between them.
|
|
27
|
+
*/
|
|
28
|
+
export { defineModule, defineServerModule, packageVersion }
|
|
29
|
+
|
|
30
|
+
const os = implement(inventoryContract).$context<RequestContext>()
|
|
31
|
+
|
|
32
|
+
export function inventoryRouter(kernel: Kernel) {
|
|
33
|
+
const svc = inventoryServices(kernel)
|
|
34
|
+
const scoped = os.use(workspaceScoped(MODULE_ID))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Everything a write announces, once the transaction that wrote it has actually committed.
|
|
38
|
+
*
|
|
39
|
+
* Three things, and all three are after the commit on purpose: the core activity feed's copy of
|
|
40
|
+
* the history row, the event for whatever reacts later, and the realtime change that redraws a
|
|
41
|
+
* screen somebody is looking at now. A mutation that skips them leaves the product believing the
|
|
42
|
+
* old answer; a mutation that fires them *inside* the transaction tells the rest of the instance
|
|
43
|
+
* about a row a rollback then took away, which is worse — it cannot be retracted.
|
|
44
|
+
*/
|
|
45
|
+
const announce = async (
|
|
46
|
+
workspaceId: WorkspaceId,
|
|
47
|
+
written: Written,
|
|
48
|
+
event: (typeof inventoryEvents)[keyof typeof inventoryEvents],
|
|
49
|
+
op: 'created' | 'updated' | 'deleted',
|
|
50
|
+
actorId: string | null,
|
|
51
|
+
) => {
|
|
52
|
+
const assetId = written.row.id
|
|
53
|
+
if (written.activity) await svc.notify.activity(written.activity)
|
|
54
|
+
await kernel.emit(event, { assetId, workspaceId }, { workspaceId, actorId: actorId ?? undefined })
|
|
55
|
+
await svc.notify.change(workspaceId, 'asset', assetId, op)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every handler is this: a workspace-bound transaction, tagged with who is asking, and a service. */
|
|
59
|
+
const run = <T>(context: RequestContext, workspaceId: string, fn: (tx: Tx) => Promise<T>): Promise<T> =>
|
|
60
|
+
kernel.database.withWorkspace(workspaceId, fn, { userId: context.principal.userId })
|
|
61
|
+
|
|
62
|
+
return os.router({
|
|
63
|
+
assets: {
|
|
64
|
+
list: scoped.assets.list
|
|
65
|
+
.use(requires('inventory.asset.view'))
|
|
66
|
+
.handler(({ input, context }) => run(context, input.workspaceId, (tx) => svc.assets.list(tx, input))),
|
|
67
|
+
|
|
68
|
+
get: scoped.assets.get
|
|
69
|
+
.use(requires('inventory.asset.view'))
|
|
70
|
+
.handler(({ input, context }) =>
|
|
71
|
+
run(context, input.workspaceId, async (tx) =>
|
|
72
|
+
toAsset(await svc.assets.get(tx, input.workspaceId, input.assetId)),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
|
|
76
|
+
create: scoped.assets.create
|
|
77
|
+
.use(requires('inventory.asset.manage'))
|
|
78
|
+
.handler(async ({ input, context }) => {
|
|
79
|
+
const actorId = context.principal.userId
|
|
80
|
+
// Module settings are read *before* the transaction opens. They come from core over the
|
|
81
|
+
// broker, and awaiting that inside the transaction holds a pooled connection and the
|
|
82
|
+
// counter row lock across a network call to another service.
|
|
83
|
+
const format = await svc.assets.codeFormat(input.workspaceId)
|
|
84
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
85
|
+
svc.assets.create(tx, input.workspaceId, actorId, input, format),
|
|
86
|
+
)
|
|
87
|
+
await announce(input.workspaceId, written, inventoryEvents.assetCreated, 'created', actorId)
|
|
88
|
+
return toAsset(written.row)
|
|
89
|
+
}),
|
|
90
|
+
|
|
91
|
+
update: scoped.assets.update
|
|
92
|
+
.use(requires('inventory.asset.manage'))
|
|
93
|
+
.handler(async ({ input, context }) => {
|
|
94
|
+
const actorId = context.principal.userId
|
|
95
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
96
|
+
svc.assets.update(tx, input.workspaceId, actorId, input.assetId, input),
|
|
97
|
+
)
|
|
98
|
+
await announce(input.workspaceId, written, inventoryEvents.assetUpdated, 'updated', actorId)
|
|
99
|
+
return toAsset(written.row)
|
|
100
|
+
}),
|
|
101
|
+
|
|
102
|
+
archive: scoped.assets.archive
|
|
103
|
+
.use(requires('inventory.asset.manage'))
|
|
104
|
+
.handler(async ({ input, context }) => {
|
|
105
|
+
const actorId = context.principal.userId
|
|
106
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
107
|
+
svc.assets.archive(tx, input.workspaceId, actorId, input.assetId, input.archived),
|
|
108
|
+
)
|
|
109
|
+
// This procedure is the restore path too, and it used to announce `archived` either way —
|
|
110
|
+
// telling every subscriber that a restored item had just been retired, while the module's
|
|
111
|
+
// own history row beside it said `restored`.
|
|
112
|
+
const event = input.archived ? inventoryEvents.assetArchived : inventoryEvents.assetRestored
|
|
113
|
+
await announce(input.workspaceId, written, event, 'updated', actorId)
|
|
114
|
+
return toAsset(written.row)
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
}
|
package/src/server/schema.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
index,
|
|
8
8
|
integer,
|
|
9
9
|
jsonb,
|
|
10
|
-
|
|
10
|
+
primaryKey,
|
|
11
11
|
text,
|
|
12
12
|
timestamp,
|
|
13
13
|
uniqueIndex,
|
|
@@ -33,20 +33,23 @@ const ts = (name: string) => timestamp(name, { withTimezone: true, mode: 'date'
|
|
|
33
33
|
const created = () => ts('created_at').notNull().defaultNow()
|
|
34
34
|
const updated = () => ts('updated_at').notNull().defaultNow()
|
|
35
35
|
|
|
36
|
-
/**
|
|
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
|
-
*/
|
|
40
|
-
export const assetStatus = pgEnum('asset_status', ['in_stock', 'assigned', 'under_repair', 'retired'])
|
|
41
|
-
|
|
42
36
|
export const counters = schema.table(
|
|
43
37
|
'counters',
|
|
44
38
|
/** Per-workspace sequence sources (`asset_code`). Narrow on purpose: one row per key. */
|
|
45
39
|
{
|
|
46
|
-
workspaceId: ws()
|
|
47
|
-
key: text('key').
|
|
40
|
+
workspaceId: ws(),
|
|
41
|
+
key: text('key').notNull(),
|
|
48
42
|
value: integer('value').notNull(),
|
|
49
43
|
},
|
|
44
|
+
/**
|
|
45
|
+
* Composite, declared here rather than as two `.primaryKey()` columns.
|
|
46
|
+
*
|
|
47
|
+
* Column-level `.primaryKey()` twice reads like a compound key and is not one: drizzle emits
|
|
48
|
+
* `PRIMARY KEY` on both columns and Postgres refuses the table outright — "multiple primary keys
|
|
49
|
+
* for table are not allowed", SQLSTATE 42P16. The module's migration is the first thing the
|
|
50
|
+
* kernel runs, so the failure is not a broken table but a service that will not boot.
|
|
51
|
+
*/
|
|
52
|
+
(t) => [primaryKey({ columns: [t.workspaceId, t.key] })],
|
|
50
53
|
)
|
|
51
54
|
|
|
52
55
|
export const categories = schema.table(
|
|
@@ -74,7 +77,10 @@ export const assets = schema.table(
|
|
|
74
77
|
name: text('name').notNull(),
|
|
75
78
|
description: text('description').notNull().default(''),
|
|
76
79
|
categoryId: uuid('category_id'),
|
|
77
|
-
|
|
80
|
+
/** `AssetStatus` in the contract. Text, not a pg enum: every other Kern module stores a
|
|
81
|
+
* status this way, and an enum named `asset_status` in `public` is a type this module
|
|
82
|
+
* leaves behind when it is removed. */
|
|
83
|
+
status: text('status').notNull().default('in_stock'),
|
|
78
84
|
/** Denormalized from `custody_periods`, which stays authoritative for history. */
|
|
79
85
|
custodianUserId: uuid('custodian_user_id'),
|
|
80
86
|
custodySince: ts('custody_since'),
|
|
@@ -86,6 +92,8 @@ export const assets = schema.table(
|
|
|
86
92
|
currency: char('currency', { length: 3 }),
|
|
87
93
|
warrantyUntil: date('warranty_until'),
|
|
88
94
|
photoFileId: uuid('photo_file_id'),
|
|
95
|
+
/** Values for this workspace's own `field_defs`, keyed by their `key`. */
|
|
96
|
+
custom: jsonb('custom').$type<Record<string, unknown>>().notNull().default({}),
|
|
89
97
|
createdAt: created(),
|
|
90
98
|
updatedAt: updated(),
|
|
91
99
|
archivedAt: ts('archived_at'),
|
|
@@ -95,6 +103,10 @@ export const assets = schema.table(
|
|
|
95
103
|
index('inventory_assets_ws_created_idx').on(t.workspaceId, t.createdAt),
|
|
96
104
|
index('inventory_assets_ws_status_idx').on(t.workspaceId, t.status),
|
|
97
105
|
index('inventory_assets_ws_category_idx').on(t.workspaceId, t.categoryId),
|
|
106
|
+
// "What is Ada holding?" — asked by the person, by the offboarding hook, and by a widget.
|
|
107
|
+
index('inventory_assets_ws_custodian_idx')
|
|
108
|
+
.on(t.workspaceId, t.custodianUserId)
|
|
109
|
+
.where(sql`custodian_user_id is not null`),
|
|
98
110
|
// The "what leaves warranty this month" scan, before a job makes it a widget's cheap query.
|
|
99
111
|
index('inventory_assets_ws_warranty_idx')
|
|
100
112
|
.on(t.workspaceId, t.warrantyUntil)
|
|
@@ -218,6 +230,11 @@ export const attachments = schema.table(
|
|
|
218
230
|
|
|
219
231
|
/** Every tenant table, so the RLS migration can be checked against one list rather than memory. */
|
|
220
232
|
export const TENANT_TABLES = [
|
|
233
|
+
// `counters` is a tenant table like any other: it carries `workspace_id`, so one workspace's
|
|
234
|
+
// asset-code sequence is readable to another without a policy. It was left out of this list —
|
|
235
|
+
// and therefore out of `0001_rls.sql` — because it holds no asset data, which is not the rule
|
|
236
|
+
// the file states at the top. Tracker's structurally identical `issue_counters` is covered.
|
|
237
|
+
'counters',
|
|
221
238
|
'categories',
|
|
222
239
|
'assets',
|
|
223
240
|
'field_defs',
|