@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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/audit.ts +291 -0
  3. package/src/bundle-tables/builder.ts +264 -0
  4. package/src/bundle-tables/capability.ts +377 -0
  5. package/src/bundle-tables/chain.ts +105 -0
  6. package/src/bundle-tables/ddl.ts +124 -0
  7. package/src/bundle-tables/guard.ts +116 -0
  8. package/src/bundle-tables/index.ts +7 -0
  9. package/src/bundle-tables/names.ts +58 -0
  10. package/src/bundle-tables/schema.ts +485 -0
  11. package/src/cluster.ts +1 -1
  12. package/src/database.ts +39 -9
  13. package/src/dialect.ts +32 -0
  14. package/src/index.ts +9 -0
  15. package/src/migrations.ts +147 -31
  16. package/src/pending.ts +1 -1
  17. package/src/repositories/bundles.ts +36 -4
  18. package/src/repositories/content-types.ts +41 -4
  19. package/src/repositories/documents.ts +13 -3
  20. package/src/repositories/form-entries.ts +1 -1
  21. package/src/repositories/form-workflows.ts +1 -1
  22. package/src/repositories/members.ts +15 -1
  23. package/src/repositories/nodes.ts +32 -7
  24. package/src/repositories/transfer-runs.ts +20 -2
  25. package/src/schema/audit-log.ts +45 -0
  26. package/src/schema/bundle-components.ts +1 -1
  27. package/src/schema/bundle-package.ts +27 -0
  28. package/src/schema/bundles-rename.ts +1 -1
  29. package/src/schema/content.ts +2 -2
  30. package/src/schema/created-packages.ts +1 -1
  31. package/src/schema/form-permissions.ts +1 -1
  32. package/src/schema/form-workflows.ts +1 -1
  33. package/src/schema/forms.ts +1 -1
  34. package/src/schema/identity.ts +1 -1
  35. package/src/schema/plan.ts +10 -0
  36. package/src/schema/property-searchable.ts +29 -0
  37. package/src/schema/search-index.ts +92 -0
  38. package/src/schema/transfer-run-package.ts +27 -0
  39. package/src/schema/upgrade-report.ts +1 -1
  40. package/src/schema-state.ts +1 -1
  41. package/src/search-text.ts +133 -0
  42. package/src/search.ts +397 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/data",
3
- "version": "0.9.1",
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.9.1"
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