@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -2
  3. package/dist/adapter.d.ts +26 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +254 -0
  6. package/dist/aggregate.d.ts +8 -0
  7. package/dist/aggregate.d.ts.map +1 -0
  8. package/dist/aggregate.js +138 -0
  9. package/dist/decode.d.ts +5 -0
  10. package/dist/decode.d.ts.map +1 -0
  11. package/dist/decode.js +30 -0
  12. package/dist/fills.d.ts +16 -0
  13. package/dist/fills.d.ts.map +1 -0
  14. package/dist/fills.js +37 -0
  15. package/dist/filter.d.ts +50 -0
  16. package/dist/filter.d.ts.map +1 -0
  17. package/dist/filter.js +247 -0
  18. package/dist/index.d.ts +15 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +11 -0
  21. package/dist/json-filter.d.ts +12 -0
  22. package/dist/json-filter.d.ts.map +1 -0
  23. package/dist/json-filter.js +65 -0
  24. package/dist/listen.d.ts +10 -0
  25. package/dist/listen.d.ts.map +1 -0
  26. package/dist/listen.js +92 -0
  27. package/dist/logic.d.ts +7 -0
  28. package/dist/logic.d.ts.map +1 -0
  29. package/dist/logic.js +18 -0
  30. package/dist/migrations.d.ts +23 -0
  31. package/dist/migrations.d.ts.map +1 -0
  32. package/dist/migrations.js +217 -0
  33. package/dist/names.d.ts +66 -0
  34. package/dist/names.d.ts.map +1 -0
  35. package/dist/names.js +175 -0
  36. package/dist/notify.d.ts +19 -0
  37. package/dist/notify.d.ts.map +1 -0
  38. package/dist/notify.js +75 -0
  39. package/dist/numbers.d.ts +13 -0
  40. package/dist/numbers.d.ts.map +1 -0
  41. package/dist/numbers.js +49 -0
  42. package/dist/parts.d.ts +36 -0
  43. package/dist/parts.d.ts.map +1 -0
  44. package/dist/parts.js +99 -0
  45. package/dist/plan-indexes.d.ts +12 -0
  46. package/dist/plan-indexes.d.ts.map +1 -0
  47. package/dist/plan-indexes.js +49 -0
  48. package/dist/plan-parts.d.ts +16 -0
  49. package/dist/plan-parts.d.ts.map +1 -0
  50. package/dist/plan-parts.js +9 -0
  51. package/dist/plan.d.ts +6 -0
  52. package/dist/plan.d.ts.map +1 -0
  53. package/dist/plan.js +278 -0
  54. package/dist/pool.d.ts +5 -0
  55. package/dist/pool.d.ts.map +1 -0
  56. package/dist/pool.js +41 -0
  57. package/dist/read.d.ts +47 -0
  58. package/dist/read.d.ts.map +1 -0
  59. package/dist/read.js +227 -0
  60. package/dist/scratch.d.ts +22 -0
  61. package/dist/scratch.d.ts.map +1 -0
  62. package/dist/scratch.js +77 -0
  63. package/dist/snapshot.d.ts +66 -0
  64. package/dist/snapshot.d.ts.map +1 -0
  65. package/dist/snapshot.js +272 -0
  66. package/dist/write.d.ts +17 -0
  67. package/dist/write.d.ts.map +1 -0
  68. package/dist/write.js +296 -0
  69. package/package.json +45 -3
  70. package/src/adapter.ts +349 -0
  71. package/src/aggregate.ts +168 -0
  72. package/src/decode.ts +46 -0
  73. package/src/fills.ts +52 -0
  74. package/src/filter.ts +306 -0
  75. package/src/index.ts +20 -0
  76. package/src/json-filter.ts +74 -0
  77. package/src/listen.ts +97 -0
  78. package/src/logic.ts +28 -0
  79. package/src/migrations.ts +275 -0
  80. package/src/names.ts +217 -0
  81. package/src/notify.ts +101 -0
  82. package/src/numbers.ts +64 -0
  83. package/src/parts.ts +143 -0
  84. package/src/plan-indexes.ts +100 -0
  85. package/src/plan-parts.ts +35 -0
  86. package/src/plan.ts +389 -0
  87. package/src/pool.ts +63 -0
  88. package/src/read.ts +287 -0
  89. package/src/scratch.ts +92 -0
  90. package/src/snapshot.ts +390 -0
  91. 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
+ }