@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
package/src/filter.ts ADDED
@@ -0,0 +1,306 @@
1
+ // A normalized `where` as SQL (D-73): PostgreSQL's three-valued logic is the
2
+ // one `memory()` mimics, and a relation condition is an `exists` subquery.
3
+ import { sql } from 'kysely'
4
+ import type { Condition, Filter, SchemaField } from 'ultimabase'
5
+ import { compares, jsonPath } from './json-filter.ts'
6
+ import type { Sql } from './logic.ts'
7
+ import { all, any, pattern } from './logic.ts'
8
+ import type { Catalog, Kind, Table } from './names.ts'
9
+
10
+ export type { Sql } from './logic.ts'
11
+ export { all, any } from './logic.ts'
12
+
13
+ type Relation = Extract<SchemaField, { readonly kind: 'one' | 'many' }>
14
+
15
+ /** The rows a relation reaches: `from`, then what ties them to the row. */
16
+ export type Reach = { readonly from: Sql; readonly on: Sql }
17
+
18
+ export const uuid: RegExp =
19
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
20
+
21
+ /**
22
+ * A value as a parameter of its column's type. A date goes in UTC: `pg`
23
+ * writes it in local time to the minute, and a local offset of an old date
24
+ * has seconds, as Paris's +00:09:21 before 1911.
25
+ */
26
+ export function value(kind: Kind, item: unknown): Sql {
27
+ if (kind === 'json' && item !== null) {
28
+ return sql`cast(${JSON.stringify(item)} as jsonb)`
29
+ }
30
+ if (kind.endsWith('[]')) {
31
+ return sql`cast(${item} as ${sql.raw(kind)})`
32
+ }
33
+ return item instanceof Date ? sql`${item.toISOString()}` : sql`${item}`
34
+ }
35
+
36
+ export function isFilters(
37
+ item: Condition | readonly Filter[],
38
+ ): item is readonly Filter[] {
39
+ return Array.isArray(item)
40
+ }
41
+
42
+ export class Filters {
43
+ readonly catalog: Catalog
44
+ /** The tables the compiled conditions reach, besides their own. */
45
+ readonly tables: Set<string> = new Set()
46
+ /** A write's change index → the CTE of its delete, for `removedBy`. */
47
+ readonly removed: ReadonlyMap<number, string>
48
+ private next = 0
49
+
50
+ constructor(
51
+ catalog: Catalog,
52
+ removed: ReadonlyMap<number, string> = new Map(),
53
+ ) {
54
+ this.catalog = catalog
55
+ this.removed = removed
56
+ }
57
+
58
+ /** A table alias no other part of the statement uses. */
59
+ alias(): string {
60
+ this.next += 1
61
+ return `t${this.next}`
62
+ }
63
+
64
+ column(table: Table, alias: string, name: string): Sql {
65
+ return sql.id(alias, this.catalog.column(table, name))
66
+ }
67
+
68
+ where(model: string, alias: string, filter: Filter | undefined): Sql {
69
+ if (filter === undefined) return sql`true`
70
+ const table = this.catalog.table(model)
71
+ const parts: Sql[] = []
72
+ for (const [key, condition] of Object.entries(filter)) {
73
+ if (condition === undefined) continue
74
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
75
+ const list = (filter[key] ?? []).map((part) =>
76
+ this.where(model, alias, part),
77
+ )
78
+ if (key === 'AND') parts.push(all(list))
79
+ else if (key === 'OR') parts.push(any(list))
80
+ else parts.push(all(list.map((part) => sql`(not ${part})`)))
81
+ } else if (isFilters(condition)) {
82
+ throw new Error(`${model}.${key} takes a condition, not a list.`)
83
+ } else if (condition.search !== undefined) {
84
+ parts.push(
85
+ sql`${sql.id(alias, '_search')} @@ ${this.query(condition.search)}`,
86
+ )
87
+ } else {
88
+ const field = table.fields.get(key)
89
+ parts.push(
90
+ field === undefined || field.kind === 'scalar'
91
+ ? this.scalar(table, alias, key, condition)
92
+ : this.relation(table, alias, field, condition),
93
+ )
94
+ }
95
+ }
96
+ return all(parts)
97
+ }
98
+
99
+ /** `text` as `websearch_to_tsquery` reads it, in the catalog's language (D-148). */
100
+ query(text: string): Sql {
101
+ return sql`websearch_to_tsquery(cast(${this.catalog.language} as regconfig), ${text})`
102
+ }
103
+
104
+ /** The rows of `field`'s target, aliased `to`, that `alias`'s row reaches. */
105
+ reach(table: Table, alias: string, field: Relation, to: string): Reach {
106
+ const target = this.catalog.table(field.target)
107
+ this.tables.add(target.name)
108
+ const from = sql`${sql.id(target.name)} as ${sql.id(to)}`
109
+ if (field.kind === 'one' && field.stored) {
110
+ return {
111
+ from,
112
+ on: sql`${sql.id(to, 'id')} = ${this.column(table, alias, field.name)}`,
113
+ }
114
+ }
115
+ if (field.kind === 'many' && field.join) {
116
+ // A view reads the join of its relation (D-151).
117
+ const name = field.from ?? field.name
118
+ const join = this.catalog.join(table.model.name, name)
119
+ this.tables.add(join.table)
120
+ const link = this.alias()
121
+ return {
122
+ from: sql`${from} join ${sql.id(join.table)} as ${sql.id(link)} on ${sql.id(link, join.other)} = ${sql.id(to, 'id')}`,
123
+ on: sql`${sql.id(link, join.self)} = ${sql.id(alias, 'id')}`,
124
+ }
125
+ }
126
+ return {
127
+ from,
128
+ on: sql`${this.column(target, to, field.inverse)} = ${sql.id(alias, 'id')}`,
129
+ }
130
+ }
131
+
132
+ private exists(
133
+ table: Table,
134
+ alias: string,
135
+ field: Relation,
136
+ filter: Filter,
137
+ test: (inner: Sql) => Sql = (inner) => inner,
138
+ ): Sql {
139
+ const to = this.alias()
140
+ const { from, on } = this.reach(table, alias, field, to)
141
+ const inner = this.where(field.target, to, filter)
142
+ return sql`exists (select from ${from} where ${on} and ${test(inner)})`
143
+ }
144
+
145
+ private relation(
146
+ table: Table,
147
+ alias: string,
148
+ field: Relation,
149
+ condition: Condition,
150
+ ): Sql {
151
+ const parts: Sql[] = []
152
+ const exists = (filter: Filter) => this.exists(table, alias, field, filter)
153
+ if (condition.is !== undefined) {
154
+ parts.push(
155
+ condition.is === null ? sql`(not ${exists({})})` : exists(condition.is),
156
+ )
157
+ }
158
+ if (condition.isNot !== undefined) {
159
+ parts.push(
160
+ condition.isNot === null
161
+ ? exists({})
162
+ : sql`(not ${exists(condition.isNot)})`,
163
+ )
164
+ }
165
+ if (condition.some !== undefined) parts.push(exists(condition.some))
166
+ if (condition.every !== undefined) {
167
+ const failing = this.exists(
168
+ table,
169
+ alias,
170
+ field,
171
+ condition.every,
172
+ (inner) => sql`(${inner}) is false`,
173
+ )
174
+ parts.push(sql`(not ${failing})`)
175
+ }
176
+ if (condition.none !== undefined) {
177
+ parts.push(sql`(not ${exists(condition.none)})`)
178
+ }
179
+ if (condition.removedBy !== undefined) {
180
+ // The key names a row an earlier delete of the write removed.
181
+ const removed = this.removed.get(condition.removedBy)
182
+ if (removed === undefined) throw new Error('No such delete.')
183
+ const key = this.column(table, alias, field.name)
184
+ parts.push(sql`${key} in (select "id" from ${sql.id(removed)})`)
185
+ }
186
+ return all(parts)
187
+ }
188
+
189
+ private scalar(
190
+ table: Table,
191
+ alias: string,
192
+ name: string,
193
+ condition: Condition,
194
+ ): Sql {
195
+ const column = this.column(table, alias, name)
196
+ return this.compare(column, this.catalog.kind(table, name), condition)
197
+ }
198
+
199
+ /** A value of `kind`, a column or an aggregate (D-149), meets `condition`. */
200
+ compare(column: Sql, kind: Kind, condition: Condition): Sql {
201
+ if (condition.path !== undefined) return jsonPath(column, condition)
202
+ // An id matches and compares as its string, as in `memory()`.
203
+ const cast = kind === 'id' ? sql`cast(${column} as text)` : column
204
+ // A string field's operators ignore case with `ilike` (D-140). Its
205
+ // `collate "C"` folds ASCII only: the database's locale folds the rest.
206
+ // `contains` and `endsWith` read the column so in both modes, as a
207
+ // trigram index does; a B-tree serves the rest (D-141).
208
+ const insensitive = condition.mode === 'insensitive'
209
+ const collated = sql`(${cast} collate "default")`
210
+ const text = insensitive ? collated : cast
211
+ const like = sql.raw(insensitive ? 'ilike' : 'like')
212
+ const parts: Sql[] = []
213
+ for (const [operator, operand] of Object.entries(condition)) {
214
+ if (operand === undefined || operator === 'mode') continue
215
+ switch (operator) {
216
+ case 'equals':
217
+ case 'not': {
218
+ const equal =
219
+ operand === null
220
+ ? sql`${column} is null`
221
+ : insensitive
222
+ ? sql`${text} ilike ${pattern(operand, '', '')}`
223
+ : this.equal(column, kind, operand)
224
+ parts.push(operator === 'equals' ? equal : sql`(not ${equal})`)
225
+ break
226
+ }
227
+ case 'in':
228
+ case 'notIn': {
229
+ const found = insensitive
230
+ ? this.like(text, operand)
231
+ : this.member(column, kind, operand)
232
+ parts.push(operator === 'in' ? found : sql`(not ${found})`)
233
+ break
234
+ }
235
+ case 'has':
236
+ parts.push(sql`${column} @> ${value(kind, [operand])}`)
237
+ break
238
+ case 'hasSome':
239
+ parts.push(sql`${column} && ${value(kind, operand)}`)
240
+ break
241
+ case 'hasEvery':
242
+ parts.push(sql`${column} @> ${value(kind, operand)}`)
243
+ break
244
+ case 'isEmpty':
245
+ parts.push(
246
+ operand === true
247
+ ? sql`cardinality(${column}) = 0`
248
+ : sql`cardinality(${column}) > 0`,
249
+ )
250
+ break
251
+ case 'contains':
252
+ parts.push(sql`${collated} ${like} ${pattern(operand, '%', '%')}`)
253
+ break
254
+ case 'startsWith':
255
+ parts.push(sql`${text} ${like} ${pattern(operand, '', '%')}`)
256
+ break
257
+ case 'endsWith':
258
+ parts.push(sql`${collated} ${like} ${pattern(operand, '%', '')}`)
259
+ break
260
+ default: {
261
+ const right = value(kind, operand)
262
+ const compare = compares[operator]
263
+ if (compare === undefined) {
264
+ throw new Error(`Unknown operator ${operator}.`)
265
+ }
266
+ parts.push(sql`${text} ${sql.raw(compare)} ${right}`)
267
+ }
268
+ }
269
+ }
270
+ return all(parts)
271
+ }
272
+
273
+ /** A string that is not an id equals no id, as in `memory()` (D-98). */
274
+ private equal(column: Sql, kind: Kind, operand: unknown): Sql {
275
+ if (kind === 'id' && !(typeof operand === 'string' && uuid.test(operand))) {
276
+ return sql`false`
277
+ }
278
+ return sql`${column} = ${value(kind, operand)}`
279
+ }
280
+
281
+ /** `in`: no item is false; an item no known value equals is dropped. */
282
+ private member(column: Sql, kind: Kind, operand: unknown): Sql {
283
+ const list: readonly unknown[] = Array.isArray(operand) ? operand : []
284
+ if (list.length === 0) return sql`false`
285
+ const items = list.filter(
286
+ (item) =>
287
+ item !== null &&
288
+ (kind !== 'id' || (typeof item === 'string' && uuid.test(item))),
289
+ )
290
+ if (items.length === 0) {
291
+ return sql`(case when ${column} is null then null else false end)`
292
+ }
293
+ const values = items.map((item) => value(kind, item))
294
+ return sql`${column} in (${sql.join(values)})`
295
+ }
296
+
297
+ /** `in` ignoring case: one `ilike` per item (D-140). */
298
+ private like(text: Sql, operand: unknown): Sql {
299
+ const list: readonly unknown[] = Array.isArray(operand) ? operand : []
300
+ if (list.length === 0) return sql`false`
301
+ const likes = list.map(
302
+ (item) => sql`${text} ilike ${pattern(item, '', '')}`,
303
+ )
304
+ return sql`(${sql.join(likes, sql` or `)})`
305
+ }
306
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ // `@ultimabase/postgres` (D-47, D-98): the PostgreSQL adapter.
2
+ import type { Postgres } from './adapter.ts'
3
+ import { createAdapter } from './adapter.ts'
4
+
5
+ export type { Postgres }
6
+
7
+ /**
8
+ * Stores the app's models in the PostgreSQL database at `url`. An unset one,
9
+ * `process.env.DATABASE_URL` perhaps, is refused when the app starts. A query
10
+ * waits up to 10 seconds for one of its `connections`, 10 by default.
11
+ * `_search` reads its words in `searchLanguage`, a text search configuration
12
+ * of the server, `'simple'` by default.
13
+ */
14
+ export function postgres(options: {
15
+ readonly url: string | undefined
16
+ readonly connections?: number
17
+ readonly searchLanguage?: string
18
+ }): Postgres {
19
+ return createAdapter(options)
20
+ }
@@ -0,0 +1,74 @@
1
+ // A json value's path filter as SQL (D-149), as `memory()` runs it.
2
+ import { sql } from 'kysely'
3
+ import type { Condition } from 'ultimabase'
4
+ import type { Sql } from './logic.ts'
5
+ import { all, pattern } from './logic.ts'
6
+
7
+ const jsonb = (item: unknown): Sql =>
8
+ sql`cast(${JSON.stringify(item)} as jsonb)`
9
+ export const compares: { readonly [operator: string]: string } = {
10
+ lt: '<',
11
+ lte: '<=',
12
+ gt: '>',
13
+ gte: '>=',
14
+ }
15
+
16
+ /**
17
+ * A json value's path filter (D-149): `#>` reaches the value, which an
18
+ * operator tests when it has the operator's JSON type. A missing value stays
19
+ * unknown, as in `memory()`.
20
+ */
21
+ export function jsonPath(column: Sql, condition: Condition): Sql {
22
+ const path = sql`cast(${condition.path ?? []} as text[])`
23
+ const at = sql`(${column} #> ${path})`
24
+ const text = sql`(${column} #>> ${path})`
25
+ const insensitive = condition.mode === 'insensitive'
26
+ const folded = insensitive ? sql`(${text} collate "default")` : text
27
+ const like = sql.raw(insensitive ? 'ilike' : 'like')
28
+ const typed = (type: string, test: Sql): Sql =>
29
+ sql`(case when ${at} is null then null when jsonb_typeof(${at}) = ${type} then coalesce(${test}, false) else false end)`
30
+ const parts: Sql[] = []
31
+ for (const [operator, operand] of Object.entries(condition)) {
32
+ if (operand === undefined || operator === 'path' || operator === 'mode') {
33
+ continue
34
+ }
35
+ const strings = (before: string, after: string): Sql =>
36
+ typed('string', sql`${folded} ${like} ${pattern(operand, before, after)}`)
37
+ switch (operator) {
38
+ case 'equals':
39
+ parts.push(sql`${at} = ${jsonb(operand)}`)
40
+ break
41
+ case 'not':
42
+ parts.push(sql`${at} <> ${jsonb(operand)}`)
43
+ break
44
+ case 'stringContains':
45
+ parts.push(strings('%', '%'))
46
+ break
47
+ case 'stringStartsWith':
48
+ parts.push(strings('', '%'))
49
+ break
50
+ case 'stringEndsWith':
51
+ parts.push(strings('%', ''))
52
+ break
53
+ case 'arrayContains':
54
+ parts.push(typed('array', sql`${at} @> ${jsonb(operand)}`))
55
+ break
56
+ case 'arrayStartsWith':
57
+ parts.push(typed('array', sql`${at} -> 0 = ${jsonb(operand)}`))
58
+ break
59
+ case 'arrayEndsWith':
60
+ parts.push(typed('array', sql`${at} -> -1 = ${jsonb(operand)}`))
61
+ break
62
+ default: {
63
+ const compare = sql.raw(compares[operator] ?? '=')
64
+ // Strings by code point, as everywhere (D-94).
65
+ parts.push(
66
+ typeof operand === 'number'
67
+ ? typed('number', sql`${at} ${compare} ${jsonb(operand)}`)
68
+ : typed('string', sql`(${text} collate "C") ${compare} ${operand}`),
69
+ )
70
+ }
71
+ }
72
+ }
73
+ return all(parts)
74
+ }
package/src/listen.ts ADDED
@@ -0,0 +1,97 @@
1
+ // The connection that hears the feed's notifications (D-154): it gives the
2
+ // feed what a tick brought, and once a lost connection is back, `missed`.
3
+ import { createHash } from 'node:crypto'
4
+ import type { ClientConfig } from 'pg'
5
+ import { Client } from 'pg'
6
+ import type { Changed, Feed, Logger } from 'ultimabase'
7
+ import { changedOf } from './notify.ts'
8
+
9
+ /** The feed's channel of `schema`, within the 63 bytes of a name. */
10
+ export function channelOf(schema: string): string {
11
+ const channel = `ultimabase:${schema}`
12
+ if (Buffer.byteLength(channel) <= 63) return channel
13
+ const hash = createHash('sha256').update(schema).digest('hex')
14
+ return `ultimabase:${hash.slice(0, 52)}`
15
+ }
16
+
17
+ export type Listener = { readonly stop: () => Promise<void> }
18
+
19
+ /** Milliseconds between two attempts to listen again, doubling. */
20
+ const first = 100
21
+ const most = 5000
22
+
23
+ /** Listens on `channel` until stopped; a first connection that fails throws. */
24
+ export async function listening(
25
+ config: ClientConfig,
26
+ channel: string,
27
+ feed: Feed,
28
+ logger: Logger,
29
+ ): Promise<Listener> {
30
+ let client: Client | undefined
31
+ let stopped = false
32
+ let timer: NodeJS.Timeout | undefined
33
+ let heard: Changed[] = []
34
+ const flush = (): void => {
35
+ const changes = heard
36
+ heard = []
37
+ if (changes.length > 0) feed.changed(changes)
38
+ }
39
+ const connect = async (): Promise<Client> => {
40
+ const own = new Client({ ...config, application_name: 'ultimabase-feed' })
41
+ own.on('notification', ({ channel: to, payload }) => {
42
+ const change = to === channel ? changedOf(payload) : undefined
43
+ if (change === undefined) return
44
+ if (heard.length === 0) setImmediate(flush)
45
+ heard.push(change)
46
+ })
47
+ own.on('error', () => lost(own))
48
+ own.on('end', () => lost(own))
49
+ try {
50
+ await own.connect()
51
+ await own.query(`listen "${channel.replaceAll('"', '""')}"`)
52
+ } catch (error) {
53
+ own.removeAllListeners('end')
54
+ await own.end().catch(() => undefined)
55
+ throw error
56
+ }
57
+ return own
58
+ }
59
+ const retry = (wait: number): void => {
60
+ timer = setTimeout(() => void again(wait), wait)
61
+ }
62
+ const again = async (wait: number): Promise<void> => {
63
+ let own: Client
64
+ try {
65
+ own = await connect()
66
+ } catch {
67
+ retry(Math.min(wait * 2, most))
68
+ return
69
+ }
70
+ if (stopped) {
71
+ await own.end().catch(() => undefined)
72
+ return
73
+ }
74
+ client = own
75
+ logger.info('The change feed listens again.')
76
+ // What was committed while no one listened is unknown.
77
+ feed.missed()
78
+ }
79
+ const lost = (own: Client): void => {
80
+ if (stopped || client !== own) return
81
+ client = undefined
82
+ own.removeAllListeners('end')
83
+ own.end().catch(() => undefined)
84
+ logger.warn('The change feed lost its connection: it listens again soon.')
85
+ retry(first)
86
+ }
87
+ client = await connect()
88
+ return {
89
+ stop: async () => {
90
+ stopped = true
91
+ clearTimeout(timer)
92
+ const own = client
93
+ client = undefined
94
+ await own?.end()
95
+ },
96
+ }
97
+ }
package/src/logic.ts ADDED
@@ -0,0 +1,28 @@
1
+ // The SQL a filter is made of (D-73): conditions joined with PostgreSQL's
2
+ // three-valued logic, and a `like` pattern that matches its text only.
3
+ import type { RawBuilder } from 'kysely'
4
+ import { sql } from 'kysely'
5
+
6
+ export type Sql = RawBuilder<unknown>
7
+
8
+ export function all(parts: readonly Sql[]): Sql {
9
+ if (parts.length === 0) return sql`true`
10
+ return parts.length === 1
11
+ ? (parts[0] ?? sql`true`)
12
+ : sql`(${sql.join(parts, sql` and `)})`
13
+ }
14
+
15
+ export function any(parts: readonly Sql[]): Sql {
16
+ if (parts.length === 0) return sql`false`
17
+ return sql`(${sql.join(parts, sql` or `)})`
18
+ }
19
+
20
+ /** `%`, `_` and `\` are characters, not patterns (D-94). */
21
+ export function pattern(
22
+ operand: unknown,
23
+ before: string,
24
+ after: string,
25
+ ): string {
26
+ const text = typeof operand === 'string' ? operand : ''
27
+ return `${before}${text.replaceAll(/[\\%_]/g, '\\$&')}${after}`
28
+ }