@ultimabase/postgres 0.0.0-stage → 0.1.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/CHANGELOG.md +12 -0
- package/README.md +4 -2
- package/dist/adapter.d.ts +26 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +254 -0
- package/dist/aggregate.d.ts +8 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +138 -0
- package/dist/decode.d.ts +5 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/decode.js +30 -0
- package/dist/fills.d.ts +16 -0
- package/dist/fills.d.ts.map +1 -0
- package/dist/fills.js +37 -0
- package/dist/filter.d.ts +50 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +247 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/json-filter.d.ts +12 -0
- package/dist/json-filter.d.ts.map +1 -0
- package/dist/json-filter.js +65 -0
- package/dist/listen.d.ts +10 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +92 -0
- package/dist/logic.d.ts +7 -0
- package/dist/logic.d.ts.map +1 -0
- package/dist/logic.js +18 -0
- package/dist/migrations.d.ts +23 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +217 -0
- package/dist/names.d.ts +66 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +175 -0
- package/dist/notify.d.ts +19 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +75 -0
- package/dist/numbers.d.ts +13 -0
- package/dist/numbers.d.ts.map +1 -0
- package/dist/numbers.js +49 -0
- package/dist/parts.d.ts +36 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +99 -0
- package/dist/plan-indexes.d.ts +12 -0
- package/dist/plan-indexes.d.ts.map +1 -0
- package/dist/plan-indexes.js +49 -0
- package/dist/plan-parts.d.ts +16 -0
- package/dist/plan-parts.d.ts.map +1 -0
- package/dist/plan-parts.js +9 -0
- package/dist/plan.d.ts +6 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +278 -0
- package/dist/pool.d.ts +5 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/pool.js +41 -0
- package/dist/read.d.ts +47 -0
- package/dist/read.d.ts.map +1 -0
- package/dist/read.js +227 -0
- package/dist/scratch.d.ts +22 -0
- package/dist/scratch.d.ts.map +1 -0
- package/dist/scratch.js +77 -0
- package/dist/snapshot.d.ts +66 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +272 -0
- package/dist/write.d.ts +17 -0
- package/dist/write.d.ts.map +1 -0
- package/dist/write.js +296 -0
- package/package.json +45 -3
- package/src/adapter.ts +349 -0
- package/src/aggregate.ts +168 -0
- package/src/decode.ts +46 -0
- package/src/fills.ts +52 -0
- package/src/filter.ts +306 -0
- package/src/index.ts +20 -0
- package/src/json-filter.ts +74 -0
- package/src/listen.ts +97 -0
- package/src/logic.ts +28 -0
- package/src/migrations.ts +275 -0
- package/src/names.ts +217 -0
- package/src/notify.ts +101 -0
- package/src/numbers.ts +64 -0
- package/src/parts.ts +143 -0
- package/src/plan-indexes.ts +100 -0
- package/src/plan-parts.ts +35 -0
- package/src/plan.ts +389 -0
- package/src/pool.ts +63 -0
- package/src/read.ts +287 -0
- package/src/scratch.ts +92 -0
- package/src/snapshot.ts +390 -0
- package/src/write.ts +384 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// How a PostgreSQL database follows the models (D-105): the snapshot its
|
|
2
|
+
// tables match in `ultimabase_schema`, the files `migrate deploy` applied in
|
|
3
|
+
// `ultimabase_migrations`, and every change under one advisory lock.
|
|
4
|
+
import type { Kysely, QueryExecutorProvider, Transaction } from 'kysely'
|
|
5
|
+
import { sql } from 'kysely'
|
|
6
|
+
import { parse } from 'pg-connection-string'
|
|
7
|
+
import type { Migration, Run, Stored } from 'ultimabase'
|
|
8
|
+
import { isLocalHost, MigrationFailed, Refused } from 'ultimabase'
|
|
9
|
+
import { id } from './plan-parts.ts'
|
|
10
|
+
import { parseSnapshot } from './snapshot.ts'
|
|
11
|
+
|
|
12
|
+
/** The tables UltimaBase keeps its own records in. */
|
|
13
|
+
export const tables: readonly string[] = [
|
|
14
|
+
'ultimabase_schema',
|
|
15
|
+
'ultimabase_migrations',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const bookkeeping = [
|
|
19
|
+
sql`create table if not exists ultimabase_schema (id integer primary key check (id = 1), snapshot jsonb not null, written_by text not null, written_at timestamptz not null)`,
|
|
20
|
+
sql`create table if not exists ultimabase_migrations (name text primary key, checksum text not null, schema_hash text, applied_at timestamptz not null)`,
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long a statement waits for a table another session holds: past it,
|
|
25
|
+
* the statement fails rather than queue every query on that table behind it.
|
|
26
|
+
*/
|
|
27
|
+
const lockTimeout = '5s'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs `run` in a transaction that holds the schema's lock, which it waits
|
|
31
|
+
* for as long as another sync or deploy holds it.
|
|
32
|
+
*/
|
|
33
|
+
async function locked(
|
|
34
|
+
db: Kysely<object>,
|
|
35
|
+
run: (trx: Transaction<object>) => Promise<void>,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
await db.transaction().execute(async (trx) => {
|
|
38
|
+
await sql`select pg_advisory_xact_lock(hashtext(current_schema()))`.execute(
|
|
39
|
+
trx,
|
|
40
|
+
)
|
|
41
|
+
// A file may set its own, with `set local lock_timeout`.
|
|
42
|
+
await sql.raw(`set local lock_timeout = '${lockTimeout}'`).execute(trx)
|
|
43
|
+
for (const statement of bookkeeping) await statement.execute(trx)
|
|
44
|
+
await run(trx)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function store(
|
|
49
|
+
trx: Transaction<object>,
|
|
50
|
+
to: unknown,
|
|
51
|
+
by: Stored['by'],
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
await sql`insert into ultimabase_schema (id, snapshot, written_by, written_at)
|
|
54
|
+
values (1, ${JSON.stringify(to)}::jsonb, ${by}, now())
|
|
55
|
+
on conflict (id) do update set snapshot = excluded.snapshot,
|
|
56
|
+
written_by = excluded.written_by, written_at = excluded.written_at`.execute(trx)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isApplied = (value: unknown): value is Stored['applied'] =>
|
|
60
|
+
Array.isArray(value) &&
|
|
61
|
+
value.every(
|
|
62
|
+
(item: unknown) =>
|
|
63
|
+
typeof item === 'object' &&
|
|
64
|
+
item !== null &&
|
|
65
|
+
'name' in item &&
|
|
66
|
+
typeof item.name === 'string' &&
|
|
67
|
+
'checksum' in item &&
|
|
68
|
+
typeof item.checksum === 'string' &&
|
|
69
|
+
(!('schema' in item) || typeof item.schema === 'string'),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The stored snapshot and the applied files, in one query. A deploy that
|
|
74
|
+
* stopped before it stored its snapshot leaves files and no snapshot.
|
|
75
|
+
*/
|
|
76
|
+
export async function stored(db: Kysely<object>): Promise<Stored | undefined> {
|
|
77
|
+
try {
|
|
78
|
+
const { rows } = await sql<{
|
|
79
|
+
snapshot: unknown
|
|
80
|
+
written_by: unknown
|
|
81
|
+
applied: unknown
|
|
82
|
+
}>`select
|
|
83
|
+
(select snapshot from ultimabase_schema) as snapshot,
|
|
84
|
+
(select written_by from ultimabase_schema) as written_by,
|
|
85
|
+
(select coalesce(json_agg(json_strip_nulls(json_build_object('name', name, 'checksum', checksum, 'schema', schema_hash)) order by name), '[]')
|
|
86
|
+
from ultimabase_migrations) as applied`.execute(db)
|
|
87
|
+
const [row] = rows
|
|
88
|
+
const { snapshot, written_by: by, applied } = row ?? {}
|
|
89
|
+
if (!isApplied(applied)) throw new Error('ultimabase_migrations is broken.')
|
|
90
|
+
if (snapshot === null && applied.length === 0) return undefined
|
|
91
|
+
return {
|
|
92
|
+
snapshot: snapshot ?? undefined,
|
|
93
|
+
by: by === 'sync' ? 'sync' : 'deploy',
|
|
94
|
+
applied,
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof Error && 'code' in error && error.code === '42P01') {
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Refuses tables that no snapshot describes (D-105). */
|
|
105
|
+
async function refuseTables(
|
|
106
|
+
trx: Transaction<object>,
|
|
107
|
+
to: unknown,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const names = Object.keys(parseSnapshot(to).tables)
|
|
110
|
+
const { rows } = await sql<{ table_name: string }>`
|
|
111
|
+
select table_name from information_schema.tables
|
|
112
|
+
where table_schema = current_schema() and table_name = any(${names})
|
|
113
|
+
order by table_name`.execute(trx)
|
|
114
|
+
if (rows.length === 0) return
|
|
115
|
+
const list = rows.map((row) => row.table_name).join(', ')
|
|
116
|
+
throw new Refused(
|
|
117
|
+
`The database already holds ${list}, and no UltimaBase snapshot says what they are: sync only builds an empty database.`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What to do when the database refuses with `code`, after its reason. */
|
|
122
|
+
function hint(code: unknown, transaction: boolean): string {
|
|
123
|
+
if (code === '55P03') {
|
|
124
|
+
return ': another query held a table it changes past lock_timeout'
|
|
125
|
+
}
|
|
126
|
+
if (code !== '25001') return ''
|
|
127
|
+
return transaction
|
|
128
|
+
? ': put it in its own file, whose second line is `-- ultimabase no transaction`'
|
|
129
|
+
: ': a file without a transaction holds one statement: put each in its own file'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Runs `text`, and names `at` when the database refuses it. */
|
|
133
|
+
async function executed(
|
|
134
|
+
db: Kysely<object>,
|
|
135
|
+
text: string,
|
|
136
|
+
at: string,
|
|
137
|
+
transaction = true,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
try {
|
|
140
|
+
await sql.raw(text).execute(db)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
143
|
+
const code: unknown =
|
|
144
|
+
error instanceof Error ? Reflect.get(error, 'code') : undefined
|
|
145
|
+
throw new MigrationFailed(
|
|
146
|
+
at,
|
|
147
|
+
`${reason}${hint(code, transaction)}`,
|
|
148
|
+
code === '55P03',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function sync(
|
|
154
|
+
db: Kysely<object>,
|
|
155
|
+
run: Extract<Run, { kind: 'sync' }>,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
await locked(db, async (trx) => {
|
|
158
|
+
const { rows } = await sql<{ done: boolean; same: boolean | null }>`
|
|
159
|
+
select snapshot = ${JSON.stringify(run.to)}::jsonb as done,
|
|
160
|
+
snapshot = ${run.from === undefined ? null : JSON.stringify(run.from)}::jsonb as same
|
|
161
|
+
from ultimabase_schema`.execute(trx)
|
|
162
|
+
const [row] = rows
|
|
163
|
+
// Another process synced the same models first.
|
|
164
|
+
if (row?.done === true) return
|
|
165
|
+
if (row === undefined ? run.from !== undefined : row.same !== true) {
|
|
166
|
+
throw new Refused(
|
|
167
|
+
'The database changed while UltimaBase synced it: start again.',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (row === undefined) await refuseTables(trx, run.to)
|
|
171
|
+
for (const statement of run.statements) {
|
|
172
|
+
await executed(trx, statement.sql, statement.sql)
|
|
173
|
+
}
|
|
174
|
+
await store(trx, run.to, 'sync')
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Whether another deploy applied `file` first. */
|
|
179
|
+
async function isDone(db: Kysely<object>, file: Migration): Promise<boolean> {
|
|
180
|
+
const { rows } = await sql`
|
|
181
|
+
select 1 from ultimabase_migrations where name = ${file.name}`.execute(db)
|
|
182
|
+
return rows.length > 0
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const done = (db: Kysely<object>, file: Migration) =>
|
|
186
|
+
sql`insert into ultimabase_migrations (name, checksum, schema_hash, applied_at)
|
|
187
|
+
values (${file.name}, ${file.checksum}, ${file.schema ?? null}, now())`.execute(
|
|
188
|
+
db,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Runs a file outside a transaction, under the schema's lock taken for the
|
|
193
|
+
* session. It waits for locks as long as it needs: `create index
|
|
194
|
+
* concurrently` waits for running transactions without blocking any, and
|
|
195
|
+
* one that fails leaves an invalid index.
|
|
196
|
+
*/
|
|
197
|
+
async function outside(db: Kysely<object>, file: Migration): Promise<void> {
|
|
198
|
+
await db.connection().execute(async (session) => {
|
|
199
|
+
await sql`select pg_advisory_lock(hashtext(current_schema()))`.execute(
|
|
200
|
+
session,
|
|
201
|
+
)
|
|
202
|
+
try {
|
|
203
|
+
for (const statement of bookkeeping) await statement.execute(session)
|
|
204
|
+
if (await isDone(session, file)) return
|
|
205
|
+
await executed(session, file.sql, file.name, false)
|
|
206
|
+
await done(session, file)
|
|
207
|
+
} finally {
|
|
208
|
+
await sql`select pg_advisory_unlock(hashtext(current_schema()))`.execute(
|
|
209
|
+
session,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function deploy(
|
|
216
|
+
db: Kysely<object>,
|
|
217
|
+
run: Extract<Run, { kind: 'deploy' }>,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
for (const file of run.files) {
|
|
220
|
+
if (!file.transaction) {
|
|
221
|
+
await outside(db, file)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
await locked(db, async (trx) => {
|
|
225
|
+
if (await isDone(trx, file)) return
|
|
226
|
+
await executed(trx, file.sql, file.name)
|
|
227
|
+
await done(trx, file)
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
await locked(db, async (trx) => store(trx, run.to, 'deploy'))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function apply(db: Kysely<object>, run: Run): Promise<void> {
|
|
234
|
+
await (run.kind === 'sync' ? sync(db, run) : deploy(db, run))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The current schema's tables but an extension's, in name order. */
|
|
238
|
+
export async function tablesOf(db: QueryExecutorProvider): Promise<string[]> {
|
|
239
|
+
const { rows } = await sql<{ table_name: string }>`
|
|
240
|
+
select relname as table_name from pg_class
|
|
241
|
+
where relnamespace = current_schema()::regnamespace
|
|
242
|
+
and relkind in ('r', 'p') and not relispartition
|
|
243
|
+
and not exists (select from pg_depend where classid = 'pg_class'::regclass
|
|
244
|
+
and objid = pg_class.oid and deptype = 'e')
|
|
245
|
+
order by relname`.execute(db)
|
|
246
|
+
return rows.map((row) => row.table_name)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Drops every table of the current schema but an extension's, such as
|
|
251
|
+
* PostGIS's `spatial_ref_sys`; returns the models' tables.
|
|
252
|
+
*/
|
|
253
|
+
export async function reset(db: Kysely<object>): Promise<readonly string[]> {
|
|
254
|
+
const dropped: string[] = []
|
|
255
|
+
await locked(db, async (trx) => {
|
|
256
|
+
const names = await tablesOf(trx)
|
|
257
|
+
const drop = `drop table ${names.map(id).join(', ')} cascade`
|
|
258
|
+
await executed(trx, drop, drop)
|
|
259
|
+
dropped.push(...names.filter((name) => !tables.includes(name)))
|
|
260
|
+
})
|
|
261
|
+
return dropped
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The host `url` connects to when it is not this machine, read as `pg`
|
|
266
|
+
* reads it: no host, or a socket's directory, is this machine.
|
|
267
|
+
*/
|
|
268
|
+
export function remoteOf(url: string): string | undefined {
|
|
269
|
+
const host = parse(url).host || process.env['PGHOST'] || ''
|
|
270
|
+
// pg reads an IPv6 host without the brackets a URL's hostname keeps.
|
|
271
|
+
const hostname = host.includes(':') ? `[${host}]` : host
|
|
272
|
+
return host === '' || host.startsWith('/') || isLocalHost(hostname)
|
|
273
|
+
? undefined
|
|
274
|
+
: host
|
|
275
|
+
}
|
package/src/names.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Storage names (D-63, D-98): snake_case tables and columns that `dbName`
|
|
2
|
+
// overrides, the join tables of many-to-many relations, and constraint
|
|
3
|
+
// names that fit PostgreSQL's 63 bytes.
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import type { ScalarType, Schema, SchemaField, SchemaModel } from 'ultimabase'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a column holds: `id` and a `one` key hold uuids, a list the array of
|
|
9
|
+
* its elements' type (D-127), an integer field `integer` (D-132).
|
|
10
|
+
*/
|
|
11
|
+
export type Kind =
|
|
12
|
+
ScalarType | 'id' | 'integer' | 'text[]' | 'double precision[]' | 'integer[]'
|
|
13
|
+
|
|
14
|
+
export type Unique = {
|
|
15
|
+
readonly name: string
|
|
16
|
+
readonly fields: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Table = {
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly model: SchemaModel
|
|
22
|
+
readonly fields: ReadonlyMap<string, SchemaField>
|
|
23
|
+
/** The primary key, unique fields, unique indexes, one-to-one keys. */
|
|
24
|
+
readonly uniques: readonly Unique[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A many-to-many side: the relation its join table is named after, the join
|
|
29
|
+
* table, the column of this side's ids and of the other's, and the tables
|
|
30
|
+
* those ids belong to.
|
|
31
|
+
*/
|
|
32
|
+
export type Join = {
|
|
33
|
+
readonly relation: string
|
|
34
|
+
readonly table: string
|
|
35
|
+
readonly self: string
|
|
36
|
+
readonly other: string
|
|
37
|
+
readonly owner: string
|
|
38
|
+
readonly target: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const system: ReadonlyMap<string, readonly [string, Kind]> = new Map([
|
|
42
|
+
['id', ['id', 'id']],
|
|
43
|
+
['createdAt', ['created_at', 'dateTime']],
|
|
44
|
+
['updatedAt', ['updated_at', 'dateTime']],
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
export function snake(name: string): string {
|
|
48
|
+
return name
|
|
49
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
50
|
+
.replaceAll(/([A-Z])([A-Z][a-z])/g, '$1_$2')
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A name PostgreSQL keeps whole: a long one ends with its hash. */
|
|
55
|
+
export function constraint(name: string): string {
|
|
56
|
+
if (Buffer.byteLength(name) <= 63) return name
|
|
57
|
+
let head = name
|
|
58
|
+
while (Buffer.byteLength(head) > 54) head = head.slice(0, -1)
|
|
59
|
+
const hash = createHash('sha256').update(name).digest('hex').slice(0, 8)
|
|
60
|
+
return `${head}_${hash}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The relation field a foreign key belongs to, and the model it links. */
|
|
64
|
+
export type Relation = {
|
|
65
|
+
readonly model: string
|
|
66
|
+
readonly field: string
|
|
67
|
+
readonly target: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class Catalog {
|
|
71
|
+
readonly tables: ReadonlyMap<string, Table>
|
|
72
|
+
readonly joins: ReadonlyMap<string, Join>
|
|
73
|
+
/** Constraint name → the model and fields a refusal reports. */
|
|
74
|
+
readonly uniques: ReadonlyMap<string, { model: string; fields: string[] }>
|
|
75
|
+
/** Foreign key name → the relation a refusal reports (D-99). */
|
|
76
|
+
readonly foreignKeys: ReadonlyMap<string, Relation>
|
|
77
|
+
/** The text search configuration of `_search` (D-148). */
|
|
78
|
+
readonly language: string
|
|
79
|
+
|
|
80
|
+
constructor(schema: Schema, language = 'simple') {
|
|
81
|
+
this.language = language
|
|
82
|
+
const tables = new Map<string, Table>()
|
|
83
|
+
for (const model of schema.models) {
|
|
84
|
+
const name = model.dbName ?? snake(model.name)
|
|
85
|
+
const fields = new Map(model.fields.map((field) => [field.name, field]))
|
|
86
|
+
tables.set(model.name, { name, model, fields, uniques: [] })
|
|
87
|
+
}
|
|
88
|
+
this.tables = tables
|
|
89
|
+
const uniques = new Map<string, { model: string; fields: string[] }>()
|
|
90
|
+
for (const table of tables.values()) {
|
|
91
|
+
const sets: (readonly string[])[] = [['id']]
|
|
92
|
+
for (const field of table.model.fields) {
|
|
93
|
+
const stored =
|
|
94
|
+
field.kind === 'scalar' || (field.kind === 'one' && field.stored)
|
|
95
|
+
if (stored && field.unique) sets.push([field.name])
|
|
96
|
+
}
|
|
97
|
+
for (const index of table.model.indexes) {
|
|
98
|
+
if (index.unique) sets.push(index.fields)
|
|
99
|
+
}
|
|
100
|
+
const named = sets.map((fields, at) => ({
|
|
101
|
+
name: constraint(
|
|
102
|
+
at === 0
|
|
103
|
+
? `${table.name}_pkey`
|
|
104
|
+
: `${table.name}_${fields.map((field) => this.column(table, field)).join('_')}_key`,
|
|
105
|
+
),
|
|
106
|
+
fields,
|
|
107
|
+
}))
|
|
108
|
+
tables.set(table.model.name, { ...table, uniques: named })
|
|
109
|
+
for (const { name, fields } of named) {
|
|
110
|
+
uniques.set(name, { model: table.model.name, fields: [...fields] })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.uniques = uniques
|
|
114
|
+
this.joins = this.joinsOf()
|
|
115
|
+
this.foreignKeys = this.foreignKeysOf()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
table(model: string): Table {
|
|
119
|
+
const table = this.tables.get(model)
|
|
120
|
+
if (table === undefined) throw new Error(`Unknown model ${model}.`)
|
|
121
|
+
return table
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The column of a system field, a scalar or a stored `one`. */
|
|
125
|
+
column(table: Table, name: string): string {
|
|
126
|
+
const known = system.get(name)
|
|
127
|
+
if (known !== undefined) return known[0]
|
|
128
|
+
const field = table.fields.get(name)
|
|
129
|
+
if (field?.kind === 'scalar') return field.dbName ?? snake(name)
|
|
130
|
+
if (field?.kind === 'one' && field.stored) {
|
|
131
|
+
return field.dbName ?? `${snake(name)}_id`
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`${table.model.name}.${name} has no column.`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
kind(table: Table, name: string): Kind {
|
|
137
|
+
const known = system.get(name)
|
|
138
|
+
if (known !== undefined) return known[1]
|
|
139
|
+
const field = table.fields.get(name)
|
|
140
|
+
if (field?.kind !== 'scalar') return 'id'
|
|
141
|
+
if (field.integer === true) return field.list ? 'integer[]' : 'integer'
|
|
142
|
+
if (!field.list) return field.type
|
|
143
|
+
return field.type === 'number' ? 'double precision[]' : 'text[]'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
join(model: string, field: string): Join {
|
|
147
|
+
const join = this.joins.get(`${model}.${field}`)
|
|
148
|
+
if (join === undefined) throw new Error(`${model}.${field} has no join.`)
|
|
149
|
+
return join
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Named as the snapshot names them: a `one` key, a join's column. */
|
|
153
|
+
private foreignKeysOf(): Map<string, Relation> {
|
|
154
|
+
const keys = new Map<string, Relation>()
|
|
155
|
+
for (const table of this.tables.values()) {
|
|
156
|
+
const model = table.model.name
|
|
157
|
+
for (const field of table.model.fields) {
|
|
158
|
+
let key: string
|
|
159
|
+
if (field.kind === 'one' && field.stored) {
|
|
160
|
+
key = `${table.name}_${this.column(table, field.name)}_fkey`
|
|
161
|
+
} else if (
|
|
162
|
+
field.kind === 'many' &&
|
|
163
|
+
field.join &&
|
|
164
|
+
field.from === undefined
|
|
165
|
+
) {
|
|
166
|
+
const join = this.join(model, field.name)
|
|
167
|
+
key = `${join.table}_${join.other}_fkey`
|
|
168
|
+
} else continue
|
|
169
|
+
const target = field.target
|
|
170
|
+
keys.set(constraint(key), { model, field: field.name, target })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return keys
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Both sides of each many-to-many, named after the side whose table sorts
|
|
178
|
+
* first, so a model renamed with its `dbName` keeps its join (D-98).
|
|
179
|
+
*/
|
|
180
|
+
private joinsOf(): Map<string, Join> {
|
|
181
|
+
const joins = new Map<string, Join>()
|
|
182
|
+
for (const table of this.tables.values()) {
|
|
183
|
+
for (const field of table.model.fields) {
|
|
184
|
+
// A view has no join of its own (D-151).
|
|
185
|
+
if (field.kind !== 'many' || !field.join || field.from !== undefined)
|
|
186
|
+
continue
|
|
187
|
+
const target = this.table(field.target)
|
|
188
|
+
const self = table === target
|
|
189
|
+
const first = self
|
|
190
|
+
? field.name < field.inverse
|
|
191
|
+
: table.name < target.name
|
|
192
|
+
if (!first) continue
|
|
193
|
+
const name = `${table.name}_${snake(field.name)}`
|
|
194
|
+
const mine = self ? `${snake(field.inverse)}_id` : `${table.name}_id`
|
|
195
|
+
const theirs = self ? `${snake(field.name)}_id` : `${target.name}_id`
|
|
196
|
+
const relation = `${table.model.name}.${field.name}`
|
|
197
|
+
joins.set(relation, {
|
|
198
|
+
relation,
|
|
199
|
+
table: name,
|
|
200
|
+
self: mine,
|
|
201
|
+
other: theirs,
|
|
202
|
+
owner: table.name,
|
|
203
|
+
target: target.name,
|
|
204
|
+
})
|
|
205
|
+
joins.set(`${target.model.name}.${field.inverse}`, {
|
|
206
|
+
relation,
|
|
207
|
+
table: name,
|
|
208
|
+
self: theirs,
|
|
209
|
+
other: mine,
|
|
210
|
+
owner: target.name,
|
|
211
|
+
target: table.name,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return joins
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// What a write changed, told by its own statements (D-154): each part's rows
|
|
2
|
+
// go to `pg_notify` by chunks that fit its 8000 bytes, or as `null` past
|
|
3
|
+
// `feedIds`. The notifications leave with the commit; a rollback drops them.
|
|
4
|
+
import { sql } from 'kysely'
|
|
5
|
+
import type { Changed } from 'ultimabase'
|
|
6
|
+
import { feedFields, feedIds } from 'ultimabase'
|
|
7
|
+
import type { Sql } from './filter.ts'
|
|
8
|
+
|
|
9
|
+
/** What the feed says of a part: its head, then an item per row. */
|
|
10
|
+
export type Noted = {
|
|
11
|
+
/** The JSON array's start: kind, model, then the field or fields. */
|
|
12
|
+
readonly head: string
|
|
13
|
+
readonly item: Sql
|
|
14
|
+
/** How many items a notification holds. */
|
|
15
|
+
readonly per: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A part of rows, told by their ids: 150 of 40 bytes fit beside a head. */
|
|
19
|
+
export function rows(
|
|
20
|
+
kind: 'insert' | 'update' | 'delete',
|
|
21
|
+
model: string,
|
|
22
|
+
fields?: readonly string[],
|
|
23
|
+
): Noted {
|
|
24
|
+
const listed =
|
|
25
|
+
fields === undefined || JSON.stringify(fields).length > feedFields
|
|
26
|
+
? null
|
|
27
|
+
: fields
|
|
28
|
+
const head = JSON.stringify([kind, model, listed]).slice(0, -1)
|
|
29
|
+
return { head: `${head},`, item: sql.id('id'), per: 150 }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A part of a join table, told by its pairs, the model's id first. */
|
|
33
|
+
export function pairs(
|
|
34
|
+
kind: 'link' | 'unlink',
|
|
35
|
+
model: string,
|
|
36
|
+
field: string,
|
|
37
|
+
self: string,
|
|
38
|
+
other: string,
|
|
39
|
+
): Noted {
|
|
40
|
+
const head = JSON.stringify([kind, model, field]).slice(0, -1)
|
|
41
|
+
const item = sql`json_build_array(${sql.id(self)}, ${sql.id(other)})`
|
|
42
|
+
return { head: `${head},`, item, per: 75 }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The CTE that tells `channel` what each of `parts`, by its CTE, wrote. */
|
|
46
|
+
export function notifying(
|
|
47
|
+
channel: string,
|
|
48
|
+
parts: readonly (readonly [Sql, Noted])[],
|
|
49
|
+
): Sql {
|
|
50
|
+
const to = sql.lit(channel)
|
|
51
|
+
const most = sql.lit(feedIds)
|
|
52
|
+
const each = parts.map(([from, noted]) => {
|
|
53
|
+
const head = sql.lit(noted.head)
|
|
54
|
+
const count = sql`(select count(*) from ${from})`
|
|
55
|
+
const chunked = sql`select ${noted.item} as "item", (row_number() over () - 1) / ${sql.lit(noted.per)} as "k" from ${from}`
|
|
56
|
+
return sql`select pg_notify(${to}, ${head} || json_agg("s"."item")::text || ']') from (${chunked}) as "s" where ${count} <= ${most} group by "s"."k" union all select pg_notify(${to}, ${head} || 'null]') where ${count} > ${most}`
|
|
57
|
+
})
|
|
58
|
+
return sql`"#feed" as materialized (${sql.join(each, sql` union all `)})`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const kinds = new Set(['insert', 'update', 'delete', 'link', 'unlink'])
|
|
62
|
+
|
|
63
|
+
const strings = (value: unknown): value is string[] =>
|
|
64
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
65
|
+
|
|
66
|
+
const pairList = (value: unknown): value is [string, string][] =>
|
|
67
|
+
Array.isArray(value) &&
|
|
68
|
+
value.every((item) => strings(item) && item.length === 2)
|
|
69
|
+
|
|
70
|
+
/** A notification's payload as the change it tells, if it is one. */
|
|
71
|
+
export function changedOf(payload: string | undefined): Changed | undefined {
|
|
72
|
+
let parsed: unknown
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(payload ?? '')
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(parsed) || parsed.length !== 4) return undefined
|
|
79
|
+
const kind: unknown = parsed[0]
|
|
80
|
+
const model: unknown = parsed[1]
|
|
81
|
+
const target: unknown = parsed[2]
|
|
82
|
+
const listed: unknown = parsed[3]
|
|
83
|
+
if (typeof kind !== 'string' || !kinds.has(kind)) return undefined
|
|
84
|
+
if (typeof model !== 'string') return undefined
|
|
85
|
+
if (kind === 'link' || kind === 'unlink') {
|
|
86
|
+
if (typeof target !== 'string') return undefined
|
|
87
|
+
if (listed !== null && !pairList(listed)) return undefined
|
|
88
|
+
return { kind, model, field: target, ...(listed ? { pairs: listed } : {}) }
|
|
89
|
+
}
|
|
90
|
+
if (kind !== 'insert' && kind !== 'update' && kind !== 'delete') {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
if (target !== null && !strings(target)) return undefined
|
|
94
|
+
if (listed !== null && !strings(listed)) return undefined
|
|
95
|
+
return {
|
|
96
|
+
kind,
|
|
97
|
+
model,
|
|
98
|
+
...(target ? { fields: target } : {}),
|
|
99
|
+
...(listed ? { ids: listed } : {}),
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/numbers.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Number and list updates (D-132, D-149): computed by the UPDATE on the row
|
|
2
|
+
// as it finds it, and a number outside its column's type refused as the
|
|
3
|
+
// kernel's error.
|
|
4
|
+
import type { Arithmetic, ListUpdate, Write } from 'ultimabase'
|
|
5
|
+
import { OutOfRange } from 'ultimabase'
|
|
6
|
+
import type { Sql } from './filter.ts'
|
|
7
|
+
import { value } from './filter.ts'
|
|
8
|
+
import type { Catalog, Kind, Table } from './names.ts'
|
|
9
|
+
import { sql } from 'kysely'
|
|
10
|
+
|
|
11
|
+
// An integer divided by an integer truncates toward zero, as D-132 says.
|
|
12
|
+
const operators = { increment: '+', decrement: '-', multiply: '*', divide: '/' }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A list update on `column`: `pushUnique` appends each element the list
|
|
16
|
+
* lacks once, in its first position's order; `remove` keeps the others in
|
|
17
|
+
* theirs.
|
|
18
|
+
*/
|
|
19
|
+
function listed(
|
|
20
|
+
column: Sql,
|
|
21
|
+
{ operation, elements }: ListUpdate,
|
|
22
|
+
kind: Kind,
|
|
23
|
+
): Sql {
|
|
24
|
+
const given = value(kind, elements)
|
|
25
|
+
if (operation === 'push') return sql`${column} || ${given}`
|
|
26
|
+
if (operation === 'pushUnique') {
|
|
27
|
+
return sql`${column} || array(select e from unnest(${given}) with ordinality as o(e, i) where e <> all(${column}) group by e order by min(i))`
|
|
28
|
+
}
|
|
29
|
+
return sql`array(select e from unnest(${column}) with ordinality as o(e, i) where e <> all(${given}) order by i)`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An update's computed assignments, `null` counting as 0. */
|
|
33
|
+
export function computed(
|
|
34
|
+
catalog: Catalog,
|
|
35
|
+
table: Table,
|
|
36
|
+
compute: { readonly [field: string]: Arithmetic | ListUpdate } = {},
|
|
37
|
+
): Sql[] {
|
|
38
|
+
return Object.entries(compute).map(([field, operation]) => {
|
|
39
|
+
const column = sql.id(catalog.column(table, field))
|
|
40
|
+
const kind = catalog.kind(table, field)
|
|
41
|
+
if ('elements' in operation) {
|
|
42
|
+
return sql`${column} = ${listed(column, operation, kind)}`
|
|
43
|
+
}
|
|
44
|
+
const operand = value(kind, operation.by)
|
|
45
|
+
return sql`${column} = coalesce(${column}, 0) ${sql.raw(operators[operation.operation])} ${operand}`
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PostgreSQL's numeric overflow, 22003, names no column: the error names
|
|
51
|
+
* the write's first computed number.
|
|
52
|
+
*/
|
|
53
|
+
export function overflowed(given: Write, error: unknown): unknown {
|
|
54
|
+
const code =
|
|
55
|
+
error instanceof Error && 'code' in error ? error.code : undefined
|
|
56
|
+
if (code !== '22003') return error
|
|
57
|
+
for (const change of given.changes) {
|
|
58
|
+
if (change.kind !== 'update') continue
|
|
59
|
+
const computes = Object.entries(change.compute ?? {})
|
|
60
|
+
const [field] = computes.find(([, operation]) => 'by' in operation) ?? []
|
|
61
|
+
if (field !== undefined) return new OutOfRange(change.model, field)
|
|
62
|
+
}
|
|
63
|
+
return error
|
|
64
|
+
}
|