@ultimabase/postgres 0.0.0-stage → 0.1.1

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 +19 -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
package/src/write.ts ADDED
@@ -0,0 +1,384 @@
1
+ // A write as the fewest statements (D-99): its checks, then each change as a
2
+ // data-modifying CTE that runs only when they pass, then the read, which sees
3
+ // the changes through CTEs named as the written tables, or, with `before`,
4
+ // takes the rows its model's delete removed from that delete's RETURNING
5
+ // (D-102, D-104), or, with no such delete, those its update changed (D-133). A read of the one row a change writes takes it from that
6
+ // change's RETURNING (D-103). The unique
7
+ // constraint a refused change names is the kernel's `UniqueViolation` (D-98).
8
+ import type { RawBuilder } from 'kysely'
9
+ import { sql } from 'kysely'
10
+ import type { Change, Check, Filter, Read, Row, Write } from 'ultimabase'
11
+ import { RelationViolation, UniqueViolation } from 'ultimabase'
12
+ import type { Sql } from './filter.ts'
13
+ import { Filters, uuid, value } from './filter.ts'
14
+ import type { Catalog, Table } from './names.ts'
15
+ import { computed } from './numbers.ts'
16
+ import type { Part } from './parts.ts'
17
+ import { fieldsOf, grouped, inserted, sizeOf } from './parts.ts'
18
+ import { notifying, pairs as linked, rows as noted } from './notify.ts'
19
+ import { compileRows } from './read.ts'
20
+
21
+ /** A statement of a write: how many checks its result carries, `c0`… */
22
+ export type Statement = {
23
+ readonly sql: RawBuilder<{ readonly [column: string]: unknown }>
24
+ readonly checks: number
25
+ }
26
+
27
+ function assignments(
28
+ catalog: Catalog,
29
+ table: Table,
30
+ values: { readonly [field: string]: unknown },
31
+ ): (readonly [string, Sql])[] {
32
+ return Object.entries(values)
33
+ .filter(([, item]) => item !== undefined)
34
+ .map(([name, item]) => [
35
+ catalog.column(table, name),
36
+ value(catalog.kind(table, name), item),
37
+ ])
38
+ }
39
+
40
+ const and = (gate: Sql | undefined): Sql =>
41
+ gate === undefined ? sql`` : sql` and ${gate}`
42
+
43
+ /** The id a filter selects by `{ id: { equals } }` alone, if any. */
44
+ function idOf(where: Filter | undefined): string | undefined {
45
+ if (where === undefined || Object.keys(where).length !== 1) return undefined
46
+ const id = where['id']
47
+ if (typeof id !== 'object' || id === null || Object.keys(id).length !== 1) {
48
+ return undefined
49
+ }
50
+ return 'equals' in id && typeof id.equals === 'string' ? id.equals : undefined
51
+ }
52
+
53
+ /** The CTE of a change, `undefined` when it changes nothing. */
54
+ function part(
55
+ catalog: Catalog,
56
+ change: Exclude<Change, { readonly kind: 'insert' }>,
57
+ removed: ReadonlyMap<number, string>,
58
+ ): Part | undefined {
59
+ if (change.kind !== 'update' && change.kind !== 'delete') {
60
+ return links(catalog, change)
61
+ }
62
+ const table = catalog.table(change.model)
63
+ const name = sql.id(table.name)
64
+ const base = { table: table.name, key: ['id'] }
65
+ const filters = new Filters(catalog, removed)
66
+ const named = filters.alias()
67
+ const alias = sql.id(named)
68
+ const where = filters.where(change.model, named, change.where)
69
+ const reads = new Set([table.name, ...filters.tables])
70
+ if (change.kind === 'update') {
71
+ const fields = [
72
+ ...Object.keys(change.set),
73
+ ...Object.keys(change.compute ?? {}),
74
+ ]
75
+ const set = sql.join([
76
+ ...assignments(catalog, table, change.set).map(
77
+ ([column, item]) => sql`${sql.id(column)} = ${item}`,
78
+ ),
79
+ ...computed(catalog, table, change.compute),
80
+ ])
81
+ const row = idOf(change.where)
82
+ return {
83
+ ...base,
84
+ ...(row === undefined ? {} : { row }),
85
+ build: (gate) =>
86
+ sql`update ${name} as ${alias} set ${set} where ${where}${and(gate)} returning ${alias}.*`,
87
+ inserts: false,
88
+ removes: true,
89
+ adds: true,
90
+ reads,
91
+ noted: noted('update', change.model, fields),
92
+ }
93
+ }
94
+ return {
95
+ ...base,
96
+ build: (gate) =>
97
+ sql`delete from ${name} as ${alias} where ${where}${and(gate)} returning ${alias}.*`,
98
+ inserts: false,
99
+ removes: true,
100
+ adds: false,
101
+ reads,
102
+ noted: noted('delete', change.model),
103
+ }
104
+ }
105
+
106
+ /** A link or unlink of nothing, or of ids no row has, changes nothing. */
107
+ function links(
108
+ catalog: Catalog,
109
+ change: Extract<Change, { readonly kind: 'link' | 'unlink' }>,
110
+ ): Part | undefined {
111
+ const join = catalog.join(change.model, change.field)
112
+ const name = sql.id(join.table)
113
+ const self = sql.id(join.self)
114
+ const other = sql.id(join.other)
115
+ const key = [join.self, join.other].toSorted()
116
+ const id = sql`cast(${change.id} as uuid)`
117
+ // The same pair, whichever side names it.
118
+ const pairOf = (target: string): string =>
119
+ key[0] === join.self ? `${change.id}:${target}` : `${target}:${change.id}`
120
+ const pairs = (targets: readonly string[]) => ({
121
+ table: join.table,
122
+ key,
123
+ reads: new Set<string>(),
124
+ pairs: new Set(targets.map(pairOf)),
125
+ noted: linked(
126
+ change.kind,
127
+ change.model,
128
+ change.field,
129
+ join.self,
130
+ join.other,
131
+ ),
132
+ })
133
+ if (change.kind === 'unlink') {
134
+ const targets = change.targets.filter((target) => uuid.test(target))
135
+ if (targets.length === 0) return undefined
136
+ const list = sql.join(targets.map((target) => sql`cast(${target} as uuid)`))
137
+ return {
138
+ ...pairs(targets),
139
+ build: (gate) =>
140
+ sql`delete from ${name} where ${self} = ${id} and ${other} in (${list})${and(gate)} returning *`,
141
+ inserts: false,
142
+ removes: true,
143
+ adds: false,
144
+ }
145
+ }
146
+ if (change.targets.length === 0) return undefined
147
+ const rows = sql.join(
148
+ change.targets.map((target) => sql`(${id}, cast(${target} as uuid))`),
149
+ )
150
+ return {
151
+ ...pairs(change.targets),
152
+ build: (gate) => {
153
+ const source =
154
+ gate === undefined
155
+ ? sql`values ${rows}`
156
+ : sql`select * from (values ${rows}) as "v" where ${gate}`
157
+ return sql`insert into ${name} (${self}, ${other}) ${source} on conflict do nothing returning *`
158
+ },
159
+ inserts: true,
160
+ removes: false,
161
+ adds: true,
162
+ }
163
+ }
164
+
165
+ function checking(catalog: Catalog, checks: readonly Check[]): Sql {
166
+ const filters = new Filters(catalog)
167
+ const columns = checks.map((check, at) => {
168
+ const table = sql.id(catalog.table(check.model).name)
169
+ const alias = filters.alias()
170
+ const where = filters.where(check.model, alias, check.where)
171
+ // A locked row is counted as it is after any wait for its lock.
172
+ const rows = sql`${table} as ${sql.id(alias)} where ${where}`
173
+ const from = check.lock ? sql`(select from ${rows} for update) as t` : rows
174
+ return sql`(select count(*) from ${from}) = ${check.count} as ${sql.id(`c${at}`)}`
175
+ })
176
+ return sql`select ${sql.join(columns)}`
177
+ }
178
+
179
+ const cteName = (at: number): string => `#w${at}`
180
+ const cte = (at: number): Sql => sql.id(cteName(at))
181
+
182
+ /** Each written table as the read sees it: the changed rows in place. */
183
+ function views(parts: ReadonlyMap<number, Part>): Sql[] {
184
+ const tables = new Map<string, [number, Part][]>()
185
+ for (const [at, each] of parts) {
186
+ tables.set(each.table, [...(tables.get(each.table) ?? []), [at, each]])
187
+ }
188
+ return [...tables].map(([table, written]) => {
189
+ const name = sql.id(table)
190
+ const key = written[0]?.[1].key ?? []
191
+ const keys = sql.join(key.map((column) => sql.id(column)))
192
+ const removed = written
193
+ .filter(([, each]) => each.removes)
194
+ .map(([at]) => sql`select ${keys} from ${cte(at)}`)
195
+ const kept =
196
+ removed.length === 0
197
+ ? sql`select * from ${name}`
198
+ : sql`select * from ${name} where (${keys}) not in (${sql.join(removed, sql` union all `)})`
199
+ const added = written
200
+ .filter(([, each]) => each.adds)
201
+ .map(([at]) => sql`select * from ${cte(at)}`)
202
+ const body = sql.join([kept, ...added], sql` union all `)
203
+ return sql`${name} as not materialized (${body})`
204
+ })
205
+ }
206
+
207
+ /**
208
+ * The change of a statement that writes the one row `read` selects, when no
209
+ * other change of it writes that table: the read takes the row from its
210
+ * RETURNING rather than from the table's view (D-103).
211
+ */
212
+ function sourceOf(
213
+ catalog: Catalog,
214
+ read: Read,
215
+ parts: ReadonlyMap<number, Part>,
216
+ ): number | undefined {
217
+ const id = idOf(read.where)
218
+ const table = catalog.table(read.model).name
219
+ const written = [...parts].filter(([, each]) => each.table === table)
220
+ const [only] = written
221
+ return id !== undefined && written.length === 1 && only?.[1].row === id
222
+ ? only[0]
223
+ : undefined
224
+ }
225
+
226
+ /** The statements of a write, in the order they run. */
227
+ export function compileWrite(
228
+ catalog: Catalog,
229
+ write: Write,
230
+ /** Where the feed's notifications go, when it has a taker (D-154). */
231
+ channel?: string,
232
+ ): Statement[] {
233
+ const checks = write.checks ?? []
234
+ // Each statement stays within PostgreSQL's 65,535 parameters, its checks
235
+ // and its read's aside.
236
+ const room =
237
+ 65_535 -
238
+ sizeOf(checking(catalog, checks)) -
239
+ (write.read === undefined
240
+ ? 0
241
+ : sizeOf(compileRows(catalog, write.read, undefined).rows))
242
+ const parts: Part[] = []
243
+ const removed = new Map<number, string>()
244
+ // Consecutive rows of a table, setting the same fields, go in one insert.
245
+ let batch: { readonly model: string; readonly rows: Row[] } | undefined
246
+ const flush = (): void => {
247
+ if (batch !== undefined) {
248
+ parts.push(inserted(catalog, batch.model, batch.rows))
249
+ }
250
+ batch = undefined
251
+ }
252
+ for (const [index, change] of write.changes.entries()) {
253
+ if (change.kind === 'insert') {
254
+ const fields = fieldsOf(change.row)
255
+ const joins =
256
+ batch?.model === change.model &&
257
+ fieldsOf(batch.rows[0] ?? {})
258
+ .toSorted()
259
+ .join() === fields.toSorted().join() &&
260
+ (batch.rows.length + 1) * fields.length <= room
261
+ if (!joins) {
262
+ flush()
263
+ batch = { model: change.model, rows: [] }
264
+ }
265
+ batch?.rows.push(change.row)
266
+ continue
267
+ }
268
+ flush()
269
+ const each = part(catalog, change, removed)
270
+ if (each === undefined) continue
271
+ if (change.kind === 'delete') removed.set(index, cteName(parts.length))
272
+ parts.push(each)
273
+ }
274
+ flush()
275
+ if (parts.length === 0 && checks.length === 0 && write.read === undefined) {
276
+ return []
277
+ }
278
+ const columns = checks.map((_, at) => sql.id(`c${at}`))
279
+ const gate =
280
+ checks.length === 0
281
+ ? undefined
282
+ : sql`(select ${sql.join(columns, sql` and `)} from "#checks")`
283
+ const groups = grouped(parts, room)
284
+ const before = write.before === true
285
+ const table = write.read && catalog.table(write.read.model).name
286
+ // With `before`, the read runs beside its model's delete (D-104), or,
287
+ // when the write deletes nothing of it, its update (D-133).
288
+ const of = (adds: boolean): number =>
289
+ parts.findIndex(
290
+ (each) =>
291
+ before && each.table === table && each.removes && each.adds === adds,
292
+ )
293
+ const deleted = of(false)
294
+ const removal = deleted === -1 ? of(true) : deleted
295
+ let next = 0
296
+ return groups.map((group, index): Statement => {
297
+ const first = index === 0 && checks.length > 0
298
+ const numbered = new Map(group.map((each) => [next++, each]))
299
+ const ctes = [...numbered].map(
300
+ ([at, each]) =>
301
+ sql`${cte(at)} as (${each.build(first ? gate : undefined)})`,
302
+ )
303
+ const reads = before ? numbered.has(removal) : index === groups.length - 1
304
+ const read = reads ? write.read : undefined
305
+ const seen = read !== undefined && !before
306
+ const source = seen ? sourceOf(catalog, read, numbered) : removal
307
+ const compiled =
308
+ read === undefined
309
+ ? undefined
310
+ : compileRows(
311
+ catalog,
312
+ read,
313
+ source === undefined ? undefined : cte(source),
314
+ )
315
+ const fed = channel !== undefined && numbered.size > 0
316
+ const selected = [
317
+ ...(first ? columns : []),
318
+ ...(compiled === undefined ? [] : [sql`${compiled.rows} as "rows"`]),
319
+ // Referenced, the CTE runs.
320
+ ...(fed ? [sql`(select count(*) from "#feed") as "fed"`] : []),
321
+ ]
322
+ // The root's table needs no view when only its RETURNING is read.
323
+ const root = read === undefined ? '' : catalog.table(read.model).name
324
+ const viewed = [...numbered].filter(
325
+ ([, each]) =>
326
+ source === undefined ||
327
+ each.table !== root ||
328
+ compiled?.tables.has(root) === true,
329
+ )
330
+ const all = [
331
+ ...(first ? [sql`"#checks" as (${checking(catalog, checks)})`] : []),
332
+ ...ctes,
333
+ ...(seen ? views(new Map(viewed)) : []),
334
+ ...(fed
335
+ ? [
336
+ notifying(
337
+ channel,
338
+ [...numbered].map(([at, each]) => [cte(at), each.noted] as const),
339
+ ),
340
+ ]
341
+ : []),
342
+ ]
343
+ const list = selected.length === 0 ? sql`1 as "one"` : sql.join(selected)
344
+ const body = sql`select ${list}${first ? sql` from "#checks"` : sql``}`
345
+ return {
346
+ sql: all.length === 0 ? sql`${body}` : sql`with ${sql.join(all)} ${body}`,
347
+ checks: first ? checks.length : 0,
348
+ }
349
+ })
350
+ }
351
+
352
+ type Refusal = {
353
+ readonly code: string
354
+ readonly constraint: string
355
+ readonly message: string
356
+ }
357
+
358
+ function isRefusal(error: unknown): error is Refusal {
359
+ return (
360
+ error instanceof Error &&
361
+ 'code' in error &&
362
+ typeof error.code === 'string' &&
363
+ 'constraint' in error &&
364
+ typeof error.constraint === 'string'
365
+ )
366
+ }
367
+
368
+ /** A constraint's refusal as the kernel's error; any other as is. */
369
+ export function translate(catalog: Catalog, error: unknown): unknown {
370
+ if (!isRefusal(error)) return error
371
+ if (error.code === '23505') {
372
+ const unique = catalog.uniques.get(error.constraint)
373
+ if (unique !== undefined) {
374
+ return new UniqueViolation(unique.model, unique.fields)
375
+ }
376
+ }
377
+ const key = catalog.foreignKeys.get(error.constraint)
378
+ if (error.code === '23503' && key !== undefined) {
379
+ // Only the message tells the deleting side from the linking one.
380
+ const restricted = error.message.startsWith('update or delete')
381
+ return new RelationViolation(key.model, key.field, key.target, restricted)
382
+ }
383
+ return error
384
+ }