@crombolo/data 0.9.1 → 0.11.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/package.json +2 -2
- package/src/audit.ts +291 -0
- package/src/bundle-tables/builder.ts +264 -0
- package/src/bundle-tables/capability.ts +377 -0
- package/src/bundle-tables/chain.ts +105 -0
- package/src/bundle-tables/ddl.ts +124 -0
- package/src/bundle-tables/guard.ts +116 -0
- package/src/bundle-tables/index.ts +7 -0
- package/src/bundle-tables/names.ts +58 -0
- package/src/bundle-tables/schema.ts +485 -0
- package/src/cluster.ts +1 -1
- package/src/database.ts +39 -9
- package/src/dialect.ts +32 -0
- package/src/index.ts +9 -0
- package/src/migrations.ts +147 -31
- package/src/pending.ts +1 -1
- package/src/repositories/bundles.ts +36 -4
- package/src/repositories/content-types.ts +41 -4
- package/src/repositories/documents.ts +13 -3
- package/src/repositories/form-entries.ts +1 -1
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +15 -1
- package/src/repositories/nodes.ts +32 -7
- package/src/repositories/transfer-runs.ts +20 -2
- package/src/schema/audit-log.ts +45 -0
- package/src/schema/bundle-components.ts +1 -1
- package/src/schema/bundle-package.ts +27 -0
- package/src/schema/bundles-rename.ts +1 -1
- package/src/schema/content.ts +2 -2
- package/src/schema/created-packages.ts +1 -1
- package/src/schema/form-permissions.ts +1 -1
- package/src/schema/form-workflows.ts +1 -1
- package/src/schema/forms.ts +1 -1
- package/src/schema/identity.ts +1 -1
- package/src/schema/plan.ts +10 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -0
- package/src/schema/transfer-run-package.ts +27 -0
- package/src/schema/upgrade-report.ts +1 -1
- package/src/schema-state.ts +1 -1
- package/src/search-text.ts +133 -0
- package/src/search.ts +397 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
31
|
+
"@crombolo/core": "0.11.0"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit log: who did what, when and from where. Entries are only ever
|
|
3
|
+
* added, read and aged out; nothing edits one. docs/audit-log.md.
|
|
4
|
+
*/
|
|
5
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
6
|
+
import type { Db } from './database.ts'
|
|
7
|
+
import { DbDate } from './dialect.ts'
|
|
8
|
+
import { STATE_AUDIT_LOG } from './schema/audit-log.ts'
|
|
9
|
+
|
|
10
|
+
export type AuditCategory =
|
|
11
|
+
| 'content'
|
|
12
|
+
| 'media'
|
|
13
|
+
| 'member'
|
|
14
|
+
| 'schema'
|
|
15
|
+
| 'settings'
|
|
16
|
+
| 'user'
|
|
17
|
+
| 'permission'
|
|
18
|
+
| 'sign-in'
|
|
19
|
+
| 'system'
|
|
20
|
+
|
|
21
|
+
export const AUDIT_CATEGORIES: readonly AuditCategory[] = [
|
|
22
|
+
'content',
|
|
23
|
+
'media',
|
|
24
|
+
'member',
|
|
25
|
+
'schema',
|
|
26
|
+
'settings',
|
|
27
|
+
'user',
|
|
28
|
+
'permission',
|
|
29
|
+
'sign-in',
|
|
30
|
+
'system',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** Where an entry came from: a request (with its address), the command line, or a background job. */
|
|
34
|
+
export type AuditSource = 'request' | 'cli' | 'job'
|
|
35
|
+
|
|
36
|
+
export interface AuditEntryInput {
|
|
37
|
+
category: AuditCategory
|
|
38
|
+
action: string
|
|
39
|
+
source: AuditSource
|
|
40
|
+
userKey?: string | null
|
|
41
|
+
/** The user's name when the entry was written; for a failed sign-in, the login tried. */
|
|
42
|
+
userName?: string | null
|
|
43
|
+
entityType?: string | null
|
|
44
|
+
entityKey?: string | null
|
|
45
|
+
entityName?: string | null
|
|
46
|
+
ipAddress?: string | null
|
|
47
|
+
forwardedFor?: string | null
|
|
48
|
+
detail?: string | null
|
|
49
|
+
occurredAt?: Date
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AuditEntry {
|
|
53
|
+
id: number
|
|
54
|
+
occurredAt: Date
|
|
55
|
+
category: AuditCategory
|
|
56
|
+
action: string
|
|
57
|
+
source: AuditSource
|
|
58
|
+
userKey: string | null
|
|
59
|
+
userName: string | null
|
|
60
|
+
entityType: string | null
|
|
61
|
+
entityKey: string | null
|
|
62
|
+
entityName: string | null
|
|
63
|
+
ipAddress: string | null
|
|
64
|
+
forwardedFor: string | null
|
|
65
|
+
detail: string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AuditFilter {
|
|
69
|
+
userKey?: string
|
|
70
|
+
entityKey?: string
|
|
71
|
+
category?: AuditCategory
|
|
72
|
+
action?: string
|
|
73
|
+
since?: Date
|
|
74
|
+
until?: Date
|
|
75
|
+
/** Matched anywhere in the item's name or key, or the user's name. */
|
|
76
|
+
text?: string
|
|
77
|
+
ascending?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What a key names, for an entry to carry once the thing itself has gone. */
|
|
81
|
+
export interface AuditSubject {
|
|
82
|
+
entityType: string
|
|
83
|
+
entityKey: string
|
|
84
|
+
entityName: string | null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const UUID = /^\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?$/i
|
|
88
|
+
|
|
89
|
+
const LIMITS = {
|
|
90
|
+
action: 64,
|
|
91
|
+
userName: 255,
|
|
92
|
+
entityType: 64,
|
|
93
|
+
entityKey: 255,
|
|
94
|
+
entityName: 255,
|
|
95
|
+
ipAddress: 64,
|
|
96
|
+
forwardedFor: 255,
|
|
97
|
+
detail: 4000,
|
|
98
|
+
} as const
|
|
99
|
+
|
|
100
|
+
function clip(value: string | null | undefined, limit: number): string | null {
|
|
101
|
+
if (value === null || value === undefined || value === '') return null
|
|
102
|
+
return value.length > limit ? value.slice(0, limit) : value
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function text(value: unknown): string | null {
|
|
106
|
+
return value === null || value === undefined ? null : String(value)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toEntry(row: Record<string, unknown>): AuditEntry {
|
|
110
|
+
return {
|
|
111
|
+
id: Number(row.id),
|
|
112
|
+
occurredAt: DbDate.fromDb(row.occurred_at) ?? new Date(0),
|
|
113
|
+
category: String(row.category) as AuditCategory,
|
|
114
|
+
action: String(row.action),
|
|
115
|
+
source: String(row.source) as AuditSource,
|
|
116
|
+
userKey: row.user_key ? normaliseUuid(String(row.user_key)) : null,
|
|
117
|
+
userName: text(row.user_name),
|
|
118
|
+
entityType: text(row.entity_type),
|
|
119
|
+
entityKey: text(row.entity_key),
|
|
120
|
+
entityName: text(row.entity_name),
|
|
121
|
+
ipAddress: text(row.ip_address),
|
|
122
|
+
forwardedFor: text(row.forwarded_for),
|
|
123
|
+
detail: text(row.detail),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class AuditLog {
|
|
128
|
+
#db: Db
|
|
129
|
+
|
|
130
|
+
constructor(db: Db) {
|
|
131
|
+
this.#db = db
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async record(entries: AuditEntryInput | readonly AuditEntryInput[]): Promise<void> {
|
|
135
|
+
const list = Array.isArray(entries) ? entries : [entries as AuditEntryInput]
|
|
136
|
+
for (const entry of list) {
|
|
137
|
+
const userKey =
|
|
138
|
+
entry.userKey && UUID.test(entry.userKey) ? normaliseUuid(entry.userKey) : null
|
|
139
|
+
const entityKey =
|
|
140
|
+
entry.entityKey && UUID.test(entry.entityKey)
|
|
141
|
+
? normaliseUuid(entry.entityKey)
|
|
142
|
+
: entry.entityKey
|
|
143
|
+
await this.#db.exec(
|
|
144
|
+
`INSERT INTO audit_entry (occurred_at, category, action, user_key, user_name, entity_type,
|
|
145
|
+
entity_key, entity_name, source, ip_address, forwarded_for, detail)
|
|
146
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
147
|
+
[
|
|
148
|
+
DbDate.toDb(entry.occurredAt ?? new Date()),
|
|
149
|
+
entry.category,
|
|
150
|
+
clip(entry.action, LIMITS.action),
|
|
151
|
+
userKey,
|
|
152
|
+
clip(entry.userName, LIMITS.userName),
|
|
153
|
+
clip(entry.entityType, LIMITS.entityType),
|
|
154
|
+
clip(entityKey, LIMITS.entityKey),
|
|
155
|
+
clip(entry.entityName, LIMITS.entityName),
|
|
156
|
+
entry.source,
|
|
157
|
+
clip(entry.ipAddress, LIMITS.ipAddress),
|
|
158
|
+
clip(entry.forwardedFor, LIMITS.forwardedFor),
|
|
159
|
+
clip(entry.detail, LIMITS.detail),
|
|
160
|
+
],
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async query(
|
|
166
|
+
filter: AuditFilter = {},
|
|
167
|
+
page: { skip: number; take: number } = { skip: 0, take: 100 },
|
|
168
|
+
): Promise<{ total: number; items: AuditEntry[] }> {
|
|
169
|
+
const where: string[] = []
|
|
170
|
+
const params: unknown[] = []
|
|
171
|
+
if (filter.userKey !== undefined) {
|
|
172
|
+
if (!UUID.test(filter.userKey)) return { total: 0, items: [] }
|
|
173
|
+
where.push('user_key = ?')
|
|
174
|
+
params.push(normaliseUuid(filter.userKey))
|
|
175
|
+
}
|
|
176
|
+
if (filter.entityKey !== undefined) {
|
|
177
|
+
where.push('entity_key = ?')
|
|
178
|
+
params.push(UUID.test(filter.entityKey) ? normaliseUuid(filter.entityKey) : filter.entityKey)
|
|
179
|
+
}
|
|
180
|
+
if (filter.category !== undefined) {
|
|
181
|
+
where.push('category = ?')
|
|
182
|
+
params.push(filter.category)
|
|
183
|
+
}
|
|
184
|
+
if (filter.action !== undefined) {
|
|
185
|
+
where.push('action = ?')
|
|
186
|
+
params.push(filter.action)
|
|
187
|
+
}
|
|
188
|
+
if (filter.since !== undefined) {
|
|
189
|
+
where.push('occurred_at >= ?')
|
|
190
|
+
params.push(DbDate.toDb(filter.since))
|
|
191
|
+
}
|
|
192
|
+
if (filter.until !== undefined) {
|
|
193
|
+
where.push('occurred_at < ?')
|
|
194
|
+
params.push(DbDate.toDb(filter.until))
|
|
195
|
+
}
|
|
196
|
+
const needle = filter.text?.trim().toLowerCase()
|
|
197
|
+
if (needle) {
|
|
198
|
+
const like = `%${needle.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
|
199
|
+
where.push(
|
|
200
|
+
`(LOWER(entity_name) LIKE ? ESCAPE '\\' OR LOWER(entity_key) LIKE ? ESCAPE '\\' OR LOWER(user_name) LIKE ? ESCAPE '\\')`,
|
|
201
|
+
)
|
|
202
|
+
params.push(like, like, like)
|
|
203
|
+
}
|
|
204
|
+
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
|
205
|
+
const counted = await this.#db.query<{ total: number }>(
|
|
206
|
+
`SELECT COUNT(*) AS total FROM audit_entry ${clause}`,
|
|
207
|
+
params,
|
|
208
|
+
)
|
|
209
|
+
const order = filter.ascending ? 'ASC' : 'DESC'
|
|
210
|
+
const rows = await this.#db.query(
|
|
211
|
+
`SELECT * FROM audit_entry ${clause} ORDER BY occurred_at ${order}, id ${order} LIMIT ? OFFSET ?`,
|
|
212
|
+
[...params, page.take, page.skip],
|
|
213
|
+
)
|
|
214
|
+
return { total: Number(counted[0]?.total ?? 0), items: rows.map(toEntry) }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Everyone the log names, by the name they had most recently. */
|
|
218
|
+
async users(): Promise<Array<{ key: string; name: string }>> {
|
|
219
|
+
const rows = await this.#db.query<{ user_key: string; user_name: string | null }>(
|
|
220
|
+
`SELECT e.user_key, e.user_name FROM audit_entry e
|
|
221
|
+
JOIN (SELECT user_key, MAX(id) AS id FROM audit_entry WHERE user_key IS NOT NULL GROUP BY user_key) latest
|
|
222
|
+
ON latest.id = e.id
|
|
223
|
+
ORDER BY e.user_name`,
|
|
224
|
+
)
|
|
225
|
+
return rows.map((row) => ({
|
|
226
|
+
key: normaliseUuid(String(row.user_key)),
|
|
227
|
+
name: row.user_name ? String(row.user_name) : '',
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Deletes entries older than `before`, and returns how many went. */
|
|
232
|
+
async prune(before: Date): Promise<number> {
|
|
233
|
+
const counted = await this.#db.query<{ total: number }>(
|
|
234
|
+
'SELECT COUNT(*) AS total FROM audit_entry WHERE occurred_at < ?',
|
|
235
|
+
[DbDate.toDb(before)],
|
|
236
|
+
)
|
|
237
|
+
const total = Number(counted[0]?.total ?? 0)
|
|
238
|
+
if (total > 0)
|
|
239
|
+
await this.#db.exec('DELETE FROM audit_entry WHERE occurred_at < ?', [DbDate.toDb(before)])
|
|
240
|
+
return total
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** When this database began keeping the log; history before it is in the content versions. */
|
|
244
|
+
async startedAt(): Promise<Date | undefined> {
|
|
245
|
+
const rows = await this.#db.query<{ applied_at: unknown }>(
|
|
246
|
+
'SELECT MIN(applied_at) AS applied_at FROM migration_history WHERE to_state = ?',
|
|
247
|
+
[STATE_AUDIT_LOG],
|
|
248
|
+
)
|
|
249
|
+
return DbDate.fromDb(rows[0]?.applied_at)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* What a key names. A version answers for the item it belongs to, so a
|
|
254
|
+
* rollback is filed under the page it rolled back.
|
|
255
|
+
*/
|
|
256
|
+
async describe(entityType: string, key: string): Promise<AuditSubject> {
|
|
257
|
+
const named = (entityName: unknown, type = entityType, entityKey = key): AuditSubject => ({
|
|
258
|
+
entityType: type,
|
|
259
|
+
entityKey,
|
|
260
|
+
entityName: entityName === null || entityName === undefined ? null : String(entityName),
|
|
261
|
+
})
|
|
262
|
+
if (entityType.endsWith('Version') && /^\d+$/.test(key)) {
|
|
263
|
+
const rows = await this.#db.query<{ unique_id: string; text: string | null }>(
|
|
264
|
+
'SELECT n.unique_id, n.text FROM content_version cv JOIN node n ON n.id = cv.node_id WHERE cv.id = ?',
|
|
265
|
+
[Number(key)],
|
|
266
|
+
)
|
|
267
|
+
const row = rows[0]
|
|
268
|
+
const owner = entityType.slice(0, -'Version'.length)
|
|
269
|
+
return row ? named(row.text, owner, normaliseUuid(String(row.unique_id))) : named(null)
|
|
270
|
+
}
|
|
271
|
+
if (entityType === 'Language') {
|
|
272
|
+
const rows = await this.#db.query<{ culture_name: string }>(
|
|
273
|
+
'SELECT culture_name FROM language WHERE iso_code = ?',
|
|
274
|
+
[key],
|
|
275
|
+
)
|
|
276
|
+
return named(rows[0]?.culture_name ?? null)
|
|
277
|
+
}
|
|
278
|
+
if (!UUID.test(key)) return named(null)
|
|
279
|
+
const uuid = normaliseUuid(key)
|
|
280
|
+
const sql =
|
|
281
|
+
entityType === 'User'
|
|
282
|
+
? 'SELECT user_name AS name FROM user_account WHERE key = ?'
|
|
283
|
+
: entityType === 'UserGroup'
|
|
284
|
+
? 'SELECT name FROM user_group WHERE key = ?'
|
|
285
|
+
: entityType === 'DictionaryItem'
|
|
286
|
+
? 'SELECT item_key AS name FROM dictionary_item WHERE key = ?'
|
|
287
|
+
: 'SELECT text AS name FROM node WHERE unique_id = ?'
|
|
288
|
+
const rows = await this.#db.query<{ name: string | null }>(sql, [uuid])
|
|
289
|
+
return named(rows[0]?.name ?? null, entityType, uuid)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarative builder a bundle writes its tables with. `docs/bundles.md`,
|
|
3
|
+
* Part 4.
|
|
4
|
+
*
|
|
5
|
+
* It is the only way a bundle writes schema: there is no SQL in it, and no
|
|
6
|
+
* escape hatch to some. A step's `up` returns the operations it wants, as plain
|
|
7
|
+
* data, which is what makes a step checkable before it runs — linted for
|
|
8
|
+
* expand and contract, hashed for its checksum, rendered for `upgrade --plan` —
|
|
9
|
+
* and what lets the final shape of every table be worked out from the
|
|
10
|
+
* declaration's types alone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ColumnType =
|
|
14
|
+
| 'uuid'
|
|
15
|
+
| 'varchar'
|
|
16
|
+
| 'text'
|
|
17
|
+
| 'integer'
|
|
18
|
+
| 'bigint'
|
|
19
|
+
| 'boolean'
|
|
20
|
+
| 'timestamp'
|
|
21
|
+
| 'json'
|
|
22
|
+
|
|
23
|
+
/** A scalar a default may be: what DDL can carry as a literal. */
|
|
24
|
+
export type DefaultValue = string | number | boolean
|
|
25
|
+
|
|
26
|
+
export interface ColumnSpec {
|
|
27
|
+
type: ColumnType
|
|
28
|
+
/** For `varchar`. */
|
|
29
|
+
length?: number
|
|
30
|
+
nullable: boolean
|
|
31
|
+
default?: DefaultValue
|
|
32
|
+
primaryKey: boolean
|
|
33
|
+
indexed: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a column holds, as the capability reads and writes it. */
|
|
37
|
+
export type JsonValue =
|
|
38
|
+
| string
|
|
39
|
+
| number
|
|
40
|
+
| boolean
|
|
41
|
+
| null
|
|
42
|
+
| JsonValue[]
|
|
43
|
+
| { [key: string]: JsonValue }
|
|
44
|
+
|
|
45
|
+
interface ValueOf {
|
|
46
|
+
uuid: string
|
|
47
|
+
varchar: string
|
|
48
|
+
text: string
|
|
49
|
+
integer: number
|
|
50
|
+
bigint: number
|
|
51
|
+
boolean: boolean
|
|
52
|
+
timestamp: Date
|
|
53
|
+
json: JsonValue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A column being described. Its type parameters carry what the declaration
|
|
58
|
+
* says, so a table's row type can be read off it.
|
|
59
|
+
*/
|
|
60
|
+
export interface Column<
|
|
61
|
+
T extends ColumnType = ColumnType,
|
|
62
|
+
N extends boolean = false,
|
|
63
|
+
D extends boolean = false,
|
|
64
|
+
> {
|
|
65
|
+
readonly spec: ColumnSpec
|
|
66
|
+
/** Allows null. */
|
|
67
|
+
nullable(): Column<T, true, D>
|
|
68
|
+
/** What a row gets when an insert leaves it out. */
|
|
69
|
+
default(
|
|
70
|
+
value: T extends 'json' ? never : ValueOf[T] extends Date ? never : ValueOf[T],
|
|
71
|
+
): Column<T, N, true>
|
|
72
|
+
primaryKey(): Column<T, N, D>
|
|
73
|
+
/** Gives the column an index of its own. */
|
|
74
|
+
indexed(): Column<T, N, D>
|
|
75
|
+
/** Type-level only: what the column holds. */
|
|
76
|
+
readonly __value?: ValueOf[T]
|
|
77
|
+
readonly __nullable?: N
|
|
78
|
+
readonly __defaulted?: D
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function column<T extends ColumnType>(type: T, spec: Partial<ColumnSpec> = {}): Column<T> {
|
|
82
|
+
const make = (s: ColumnSpec): Column<T, boolean, boolean> =>
|
|
83
|
+
Object.freeze({
|
|
84
|
+
spec: Object.freeze(s),
|
|
85
|
+
nullable: () => make({ ...s, nullable: true }),
|
|
86
|
+
default: (value: DefaultValue) => make({ ...s, default: value }),
|
|
87
|
+
primaryKey: () => make({ ...s, primaryKey: true }),
|
|
88
|
+
indexed: () => make({ ...s, indexed: true }),
|
|
89
|
+
}) as Column<T, boolean, boolean>
|
|
90
|
+
return make({ type, nullable: false, primaryKey: false, indexed: false, ...spec }) as Column<T>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Columns = Record<string, Column<ColumnType, boolean, boolean>>
|
|
94
|
+
|
|
95
|
+
export type Operation =
|
|
96
|
+
| { op: 'createTable'; table: string; columns: Record<string, ColumnSpec> }
|
|
97
|
+
| { op: 'addColumn'; table: string; column: string; spec: ColumnSpec }
|
|
98
|
+
| { op: 'addIndex'; table: string; columns: string[]; unique: boolean }
|
|
99
|
+
| { op: 'widenColumn'; table: string; column: string; spec: ColumnSpec }
|
|
100
|
+
| { op: 'dropTable'; table: string }
|
|
101
|
+
| { op: 'dropColumn'; table: string; column: string }
|
|
102
|
+
| { op: 'dropIndex'; table: string; columns: string[] }
|
|
103
|
+
|
|
104
|
+
/** An operation, carrying at the type level what it does to the schema. */
|
|
105
|
+
export type Op<Effect = never> = Operation & { readonly __effect?: Effect }
|
|
106
|
+
|
|
107
|
+
export interface CreateTable<T extends string, C extends Columns> {
|
|
108
|
+
kind: 'create'
|
|
109
|
+
table: T
|
|
110
|
+
columns: C
|
|
111
|
+
}
|
|
112
|
+
export interface AddColumn<
|
|
113
|
+
T extends string,
|
|
114
|
+
K extends string,
|
|
115
|
+
C extends Column<ColumnType, boolean, boolean>,
|
|
116
|
+
> {
|
|
117
|
+
kind: 'add'
|
|
118
|
+
table: T
|
|
119
|
+
column: K
|
|
120
|
+
spec: C
|
|
121
|
+
}
|
|
122
|
+
export interface DropTable<T extends string> {
|
|
123
|
+
kind: 'dropTable'
|
|
124
|
+
table: T
|
|
125
|
+
}
|
|
126
|
+
export interface DropColumn<T extends string, K extends string> {
|
|
127
|
+
kind: 'dropColumn'
|
|
128
|
+
table: T
|
|
129
|
+
column: K
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What a step's `up` is handed. */
|
|
133
|
+
export interface SchemaBuilder {
|
|
134
|
+
uuid(): Column<'uuid'>
|
|
135
|
+
varchar(length: number): Column<'varchar'>
|
|
136
|
+
text(): Column<'text'>
|
|
137
|
+
integer(): Column<'integer'>
|
|
138
|
+
bigint(): Column<'bigint'>
|
|
139
|
+
boolean(): Column<'boolean'>
|
|
140
|
+
timestamp(): Column<'timestamp'>
|
|
141
|
+
json(): Column<'json'>
|
|
142
|
+
|
|
143
|
+
createTable<T extends string, C extends Columns>(table: T, columns: C): Op<CreateTable<T, C>>
|
|
144
|
+
/** A new column must be nullable or have a default, so it adds under live rows. */
|
|
145
|
+
addColumn<T extends string, K extends string, C extends Column<ColumnType, boolean, boolean>>(
|
|
146
|
+
table: T,
|
|
147
|
+
column: K,
|
|
148
|
+
spec: C,
|
|
149
|
+
): Op<AddColumn<T, K, C>>
|
|
150
|
+
addIndex(table: string, columns: readonly string[], options?: { unique?: boolean }): Op
|
|
151
|
+
/** Longer text, or a wider integer; never narrower, never another kind. */
|
|
152
|
+
widenColumn(table: string, column: string, spec: Column<ColumnType, boolean, boolean>): Op
|
|
153
|
+
dropTable<T extends string>(table: T): Op<DropTable<T>>
|
|
154
|
+
dropColumn<T extends string, K extends string>(table: T, column: K): Op<DropColumn<T, K>>
|
|
155
|
+
dropIndex(table: string, columns: readonly string[]): Op
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const specs = (columns: Columns): Record<string, ColumnSpec> => {
|
|
159
|
+
const out: Record<string, ColumnSpec> = {}
|
|
160
|
+
for (const [name, col] of Object.entries(columns)) out[name] = col.spec
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The builder: every method returns plain data. */
|
|
165
|
+
export const schemaBuilder: SchemaBuilder = Object.freeze<SchemaBuilder>({
|
|
166
|
+
uuid: () => column('uuid'),
|
|
167
|
+
varchar: (length: number) => column('varchar', { length }),
|
|
168
|
+
text: () => column('text'),
|
|
169
|
+
integer: () => column('integer'),
|
|
170
|
+
bigint: () => column('bigint'),
|
|
171
|
+
boolean: () => column('boolean'),
|
|
172
|
+
timestamp: () => column('timestamp'),
|
|
173
|
+
json: () => column('json'),
|
|
174
|
+
createTable: (table, columns) => ({ op: 'createTable', table, columns: specs(columns) }) as never,
|
|
175
|
+
addColumn: (table, name, spec) =>
|
|
176
|
+
({ op: 'addColumn', table, column: name, spec: spec.spec }) as never,
|
|
177
|
+
addIndex: (table, columns, options = {}) => ({
|
|
178
|
+
op: 'addIndex',
|
|
179
|
+
table,
|
|
180
|
+
columns: [...columns],
|
|
181
|
+
unique: options.unique ?? false,
|
|
182
|
+
}),
|
|
183
|
+
widenColumn: (table, name, spec) => ({ op: 'widenColumn', table, column: name, spec: spec.spec }),
|
|
184
|
+
dropTable: (table) => ({ op: 'dropTable', table }) as never,
|
|
185
|
+
dropColumn: (table, name) => ({ op: 'dropColumn', table, column: name }) as never,
|
|
186
|
+
dropIndex: (table, columns) => ({ op: 'dropIndex', table, columns: [...columns] }),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ------------------------------------------------------------ the row types
|
|
190
|
+
|
|
191
|
+
type ColumnValue<C> =
|
|
192
|
+
C extends Column<infer T, infer N, boolean>
|
|
193
|
+
? N extends true
|
|
194
|
+
? ValueOf[T] | null
|
|
195
|
+
: ValueOf[T]
|
|
196
|
+
: never
|
|
197
|
+
|
|
198
|
+
/** A table's row, from its columns. */
|
|
199
|
+
export type RowOf<C extends Columns> = { [K in keyof C]: ColumnValue<C[K]> }
|
|
200
|
+
|
|
201
|
+
type Optional<C extends Columns> = {
|
|
202
|
+
[K in keyof C]: C[K] extends Column<ColumnType, infer N, infer D>
|
|
203
|
+
? N extends true
|
|
204
|
+
? K
|
|
205
|
+
: D extends true
|
|
206
|
+
? K
|
|
207
|
+
: never
|
|
208
|
+
: never
|
|
209
|
+
}[keyof C]
|
|
210
|
+
|
|
211
|
+
/** What an insert must and may supply: a nullable or defaulted column may be left out. */
|
|
212
|
+
export type InsertOf<C extends Columns> = Omit<RowOf<C>, Optional<C>> &
|
|
213
|
+
Partial<Pick<RowOf<C>, Optional<C>>>
|
|
214
|
+
|
|
215
|
+
/** Every table's columns, after a run of operations. */
|
|
216
|
+
export type TablesShape = Record<string, Columns>
|
|
217
|
+
|
|
218
|
+
type Effects<R> = R extends readonly unknown[] ? R[number] : R
|
|
219
|
+
type EffectOf<O> = O extends Op<infer E> ? E : never
|
|
220
|
+
|
|
221
|
+
type Apply<S extends TablesShape, E> =
|
|
222
|
+
E extends CreateTable<infer T, infer C>
|
|
223
|
+
? Omit<S, T> & { [K in T]: C }
|
|
224
|
+
: E extends AddColumn<infer T, infer K, infer C>
|
|
225
|
+
? T extends keyof S
|
|
226
|
+
? Omit<S, T> & { [P in T]: S[T] & { [Q in K]: C } }
|
|
227
|
+
: S
|
|
228
|
+
: E extends DropTable<infer T>
|
|
229
|
+
? Omit<S, T>
|
|
230
|
+
: E extends DropColumn<infer T, infer K>
|
|
231
|
+
? T extends keyof S
|
|
232
|
+
? Omit<S, T> & { [P in T]: Omit<S[T], K> }
|
|
233
|
+
: S
|
|
234
|
+
: S
|
|
235
|
+
|
|
236
|
+
type ApplyAll<S extends TablesShape, E> = [E] extends [never]
|
|
237
|
+
? S
|
|
238
|
+
: // A union of effects within one step is applied one member at a time.
|
|
239
|
+
UnionToTuple<E> extends infer L
|
|
240
|
+
? L extends readonly [infer H, ...infer Rest]
|
|
241
|
+
? ApplyAll<Flatten<Apply<S, H>>, Rest[number]>
|
|
242
|
+
: S
|
|
243
|
+
: S
|
|
244
|
+
|
|
245
|
+
type Flatten<T> = { [K in keyof T]: T[K] } & {}
|
|
246
|
+
|
|
247
|
+
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
|
|
248
|
+
k: infer I,
|
|
249
|
+
) => void
|
|
250
|
+
? I
|
|
251
|
+
: never
|
|
252
|
+
type LastOf<U> =
|
|
253
|
+
UnionToIntersection<U extends unknown ? () => U : never> extends () => infer R ? R : never
|
|
254
|
+
type UnionToTuple<U, L = LastOf<U>> = [U] extends [never] ? [] : [...UnionToTuple<Exclude<U, L>>, L]
|
|
255
|
+
|
|
256
|
+
/** The shape after every step, in order: what `context.host.tables` is typed from. */
|
|
257
|
+
export type ShapeOf<
|
|
258
|
+
Steps extends readonly unknown[],
|
|
259
|
+
S extends TablesShape = Record<never, never>,
|
|
260
|
+
> = Steps extends readonly [infer First, ...infer Rest]
|
|
261
|
+
? First extends { up: (s: SchemaBuilder) => infer R }
|
|
262
|
+
? ShapeOf<Rest, Flatten<ApplyAll<S, EffectOf<Effects<R>>>>>
|
|
263
|
+
: ShapeOf<Rest, S>
|
|
264
|
+
: S
|