@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
package/dist/filter.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
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 { compares, jsonPath } from './json-filter.js';
|
|
5
|
+
import { all, any, pattern } from './logic.js';
|
|
6
|
+
export { all, any } from './logic.js';
|
|
7
|
+
export const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
8
|
+
/**
|
|
9
|
+
* A value as a parameter of its column's type. A date goes in UTC: `pg`
|
|
10
|
+
* writes it in local time to the minute, and a local offset of an old date
|
|
11
|
+
* has seconds, as Paris's +00:09:21 before 1911.
|
|
12
|
+
*/
|
|
13
|
+
export function value(kind, item) {
|
|
14
|
+
if (kind === 'json' && item !== null) {
|
|
15
|
+
return sql `cast(${JSON.stringify(item)} as jsonb)`;
|
|
16
|
+
}
|
|
17
|
+
if (kind.endsWith('[]')) {
|
|
18
|
+
return sql `cast(${item} as ${sql.raw(kind)})`;
|
|
19
|
+
}
|
|
20
|
+
return item instanceof Date ? sql `${item.toISOString()}` : sql `${item}`;
|
|
21
|
+
}
|
|
22
|
+
export function isFilters(item) {
|
|
23
|
+
return Array.isArray(item);
|
|
24
|
+
}
|
|
25
|
+
export class Filters {
|
|
26
|
+
catalog;
|
|
27
|
+
/** The tables the compiled conditions reach, besides their own. */
|
|
28
|
+
tables = new Set();
|
|
29
|
+
/** A write's change index → the CTE of its delete, for `removedBy`. */
|
|
30
|
+
removed;
|
|
31
|
+
next = 0;
|
|
32
|
+
constructor(catalog, removed = new Map()) {
|
|
33
|
+
this.catalog = catalog;
|
|
34
|
+
this.removed = removed;
|
|
35
|
+
}
|
|
36
|
+
/** A table alias no other part of the statement uses. */
|
|
37
|
+
alias() {
|
|
38
|
+
this.next += 1;
|
|
39
|
+
return `t${this.next}`;
|
|
40
|
+
}
|
|
41
|
+
column(table, alias, name) {
|
|
42
|
+
return sql.id(alias, this.catalog.column(table, name));
|
|
43
|
+
}
|
|
44
|
+
where(model, alias, filter) {
|
|
45
|
+
if (filter === undefined)
|
|
46
|
+
return sql `true`;
|
|
47
|
+
const table = this.catalog.table(model);
|
|
48
|
+
const parts = [];
|
|
49
|
+
for (const [key, condition] of Object.entries(filter)) {
|
|
50
|
+
if (condition === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
53
|
+
const list = (filter[key] ?? []).map((part) => this.where(model, alias, part));
|
|
54
|
+
if (key === 'AND')
|
|
55
|
+
parts.push(all(list));
|
|
56
|
+
else if (key === 'OR')
|
|
57
|
+
parts.push(any(list));
|
|
58
|
+
else
|
|
59
|
+
parts.push(all(list.map((part) => sql `(not ${part})`)));
|
|
60
|
+
}
|
|
61
|
+
else if (isFilters(condition)) {
|
|
62
|
+
throw new Error(`${model}.${key} takes a condition, not a list.`);
|
|
63
|
+
}
|
|
64
|
+
else if (condition.search !== undefined) {
|
|
65
|
+
parts.push(sql `${sql.id(alias, '_search')} @@ ${this.query(condition.search)}`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const field = table.fields.get(key);
|
|
69
|
+
parts.push(field === undefined || field.kind === 'scalar'
|
|
70
|
+
? this.scalar(table, alias, key, condition)
|
|
71
|
+
: this.relation(table, alias, field, condition));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return all(parts);
|
|
75
|
+
}
|
|
76
|
+
/** `text` as `websearch_to_tsquery` reads it, in the catalog's language (D-148). */
|
|
77
|
+
query(text) {
|
|
78
|
+
return sql `websearch_to_tsquery(cast(${this.catalog.language} as regconfig), ${text})`;
|
|
79
|
+
}
|
|
80
|
+
/** The rows of `field`'s target, aliased `to`, that `alias`'s row reaches. */
|
|
81
|
+
reach(table, alias, field, to) {
|
|
82
|
+
const target = this.catalog.table(field.target);
|
|
83
|
+
this.tables.add(target.name);
|
|
84
|
+
const from = sql `${sql.id(target.name)} as ${sql.id(to)}`;
|
|
85
|
+
if (field.kind === 'one' && field.stored) {
|
|
86
|
+
return {
|
|
87
|
+
from,
|
|
88
|
+
on: sql `${sql.id(to, 'id')} = ${this.column(table, alias, field.name)}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (field.kind === 'many' && field.join) {
|
|
92
|
+
// A view reads the join of its relation (D-151).
|
|
93
|
+
const name = field.from ?? field.name;
|
|
94
|
+
const join = this.catalog.join(table.model.name, name);
|
|
95
|
+
this.tables.add(join.table);
|
|
96
|
+
const link = this.alias();
|
|
97
|
+
return {
|
|
98
|
+
from: sql `${from} join ${sql.id(join.table)} as ${sql.id(link)} on ${sql.id(link, join.other)} = ${sql.id(to, 'id')}`,
|
|
99
|
+
on: sql `${sql.id(link, join.self)} = ${sql.id(alias, 'id')}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
from,
|
|
104
|
+
on: sql `${this.column(target, to, field.inverse)} = ${sql.id(alias, 'id')}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
exists(table, alias, field, filter, test = (inner) => inner) {
|
|
108
|
+
const to = this.alias();
|
|
109
|
+
const { from, on } = this.reach(table, alias, field, to);
|
|
110
|
+
const inner = this.where(field.target, to, filter);
|
|
111
|
+
return sql `exists (select from ${from} where ${on} and ${test(inner)})`;
|
|
112
|
+
}
|
|
113
|
+
relation(table, alias, field, condition) {
|
|
114
|
+
const parts = [];
|
|
115
|
+
const exists = (filter) => this.exists(table, alias, field, filter);
|
|
116
|
+
if (condition.is !== undefined) {
|
|
117
|
+
parts.push(condition.is === null ? sql `(not ${exists({})})` : exists(condition.is));
|
|
118
|
+
}
|
|
119
|
+
if (condition.isNot !== undefined) {
|
|
120
|
+
parts.push(condition.isNot === null
|
|
121
|
+
? exists({})
|
|
122
|
+
: sql `(not ${exists(condition.isNot)})`);
|
|
123
|
+
}
|
|
124
|
+
if (condition.some !== undefined)
|
|
125
|
+
parts.push(exists(condition.some));
|
|
126
|
+
if (condition.every !== undefined) {
|
|
127
|
+
const failing = this.exists(table, alias, field, condition.every, (inner) => sql `(${inner}) is false`);
|
|
128
|
+
parts.push(sql `(not ${failing})`);
|
|
129
|
+
}
|
|
130
|
+
if (condition.none !== undefined) {
|
|
131
|
+
parts.push(sql `(not ${exists(condition.none)})`);
|
|
132
|
+
}
|
|
133
|
+
if (condition.removedBy !== undefined) {
|
|
134
|
+
// The key names a row an earlier delete of the write removed.
|
|
135
|
+
const removed = this.removed.get(condition.removedBy);
|
|
136
|
+
if (removed === undefined)
|
|
137
|
+
throw new Error('No such delete.');
|
|
138
|
+
const key = this.column(table, alias, field.name);
|
|
139
|
+
parts.push(sql `${key} in (select "id" from ${sql.id(removed)})`);
|
|
140
|
+
}
|
|
141
|
+
return all(parts);
|
|
142
|
+
}
|
|
143
|
+
scalar(table, alias, name, condition) {
|
|
144
|
+
const column = this.column(table, alias, name);
|
|
145
|
+
return this.compare(column, this.catalog.kind(table, name), condition);
|
|
146
|
+
}
|
|
147
|
+
/** A value of `kind`, a column or an aggregate (D-149), meets `condition`. */
|
|
148
|
+
compare(column, kind, condition) {
|
|
149
|
+
if (condition.path !== undefined)
|
|
150
|
+
return jsonPath(column, condition);
|
|
151
|
+
// An id matches and compares as its string, as in `memory()`.
|
|
152
|
+
const cast = kind === 'id' ? sql `cast(${column} as text)` : column;
|
|
153
|
+
// A string field's operators ignore case with `ilike` (D-140). Its
|
|
154
|
+
// `collate "C"` folds ASCII only: the database's locale folds the rest.
|
|
155
|
+
// `contains` and `endsWith` read the column so in both modes, as a
|
|
156
|
+
// trigram index does; a B-tree serves the rest (D-141).
|
|
157
|
+
const insensitive = condition.mode === 'insensitive';
|
|
158
|
+
const collated = sql `(${cast} collate "default")`;
|
|
159
|
+
const text = insensitive ? collated : cast;
|
|
160
|
+
const like = sql.raw(insensitive ? 'ilike' : 'like');
|
|
161
|
+
const parts = [];
|
|
162
|
+
for (const [operator, operand] of Object.entries(condition)) {
|
|
163
|
+
if (operand === undefined || operator === 'mode')
|
|
164
|
+
continue;
|
|
165
|
+
switch (operator) {
|
|
166
|
+
case 'equals':
|
|
167
|
+
case 'not': {
|
|
168
|
+
const equal = operand === null
|
|
169
|
+
? sql `${column} is null`
|
|
170
|
+
: insensitive
|
|
171
|
+
? sql `${text} ilike ${pattern(operand, '', '')}`
|
|
172
|
+
: this.equal(column, kind, operand);
|
|
173
|
+
parts.push(operator === 'equals' ? equal : sql `(not ${equal})`);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case 'in':
|
|
177
|
+
case 'notIn': {
|
|
178
|
+
const found = insensitive
|
|
179
|
+
? this.like(text, operand)
|
|
180
|
+
: this.member(column, kind, operand);
|
|
181
|
+
parts.push(operator === 'in' ? found : sql `(not ${found})`);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case 'has':
|
|
185
|
+
parts.push(sql `${column} @> ${value(kind, [operand])}`);
|
|
186
|
+
break;
|
|
187
|
+
case 'hasSome':
|
|
188
|
+
parts.push(sql `${column} && ${value(kind, operand)}`);
|
|
189
|
+
break;
|
|
190
|
+
case 'hasEvery':
|
|
191
|
+
parts.push(sql `${column} @> ${value(kind, operand)}`);
|
|
192
|
+
break;
|
|
193
|
+
case 'isEmpty':
|
|
194
|
+
parts.push(operand === true
|
|
195
|
+
? sql `cardinality(${column}) = 0`
|
|
196
|
+
: sql `cardinality(${column}) > 0`);
|
|
197
|
+
break;
|
|
198
|
+
case 'contains':
|
|
199
|
+
parts.push(sql `${collated} ${like} ${pattern(operand, '%', '%')}`);
|
|
200
|
+
break;
|
|
201
|
+
case 'startsWith':
|
|
202
|
+
parts.push(sql `${text} ${like} ${pattern(operand, '', '%')}`);
|
|
203
|
+
break;
|
|
204
|
+
case 'endsWith':
|
|
205
|
+
parts.push(sql `${collated} ${like} ${pattern(operand, '%', '')}`);
|
|
206
|
+
break;
|
|
207
|
+
default: {
|
|
208
|
+
const right = value(kind, operand);
|
|
209
|
+
const compare = compares[operator];
|
|
210
|
+
if (compare === undefined) {
|
|
211
|
+
throw new Error(`Unknown operator ${operator}.`);
|
|
212
|
+
}
|
|
213
|
+
parts.push(sql `${text} ${sql.raw(compare)} ${right}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return all(parts);
|
|
218
|
+
}
|
|
219
|
+
/** A string that is not an id equals no id, as in `memory()` (D-98). */
|
|
220
|
+
equal(column, kind, operand) {
|
|
221
|
+
if (kind === 'id' && !(typeof operand === 'string' && uuid.test(operand))) {
|
|
222
|
+
return sql `false`;
|
|
223
|
+
}
|
|
224
|
+
return sql `${column} = ${value(kind, operand)}`;
|
|
225
|
+
}
|
|
226
|
+
/** `in`: no item is false; an item no known value equals is dropped. */
|
|
227
|
+
member(column, kind, operand) {
|
|
228
|
+
const list = Array.isArray(operand) ? operand : [];
|
|
229
|
+
if (list.length === 0)
|
|
230
|
+
return sql `false`;
|
|
231
|
+
const items = list.filter((item) => item !== null &&
|
|
232
|
+
(kind !== 'id' || (typeof item === 'string' && uuid.test(item))));
|
|
233
|
+
if (items.length === 0) {
|
|
234
|
+
return sql `(case when ${column} is null then null else false end)`;
|
|
235
|
+
}
|
|
236
|
+
const values = items.map((item) => value(kind, item));
|
|
237
|
+
return sql `${column} in (${sql.join(values)})`;
|
|
238
|
+
}
|
|
239
|
+
/** `in` ignoring case: one `ilike` per item (D-140). */
|
|
240
|
+
like(text, operand) {
|
|
241
|
+
const list = Array.isArray(operand) ? operand : [];
|
|
242
|
+
if (list.length === 0)
|
|
243
|
+
return sql `false`;
|
|
244
|
+
const likes = list.map((item) => sql `${text} ilike ${pattern(item, '', '')}`);
|
|
245
|
+
return sql `(${sql.join(likes, sql ` or `)})`;
|
|
246
|
+
}
|
|
247
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Postgres } from './adapter.ts';
|
|
2
|
+
export type { Postgres };
|
|
3
|
+
/**
|
|
4
|
+
* Stores the app's models in the PostgreSQL database at `url`. An unset one,
|
|
5
|
+
* `process.env.DATABASE_URL` perhaps, is refused when the app starts. A query
|
|
6
|
+
* waits up to 10 seconds for one of its `connections`, 10 by default.
|
|
7
|
+
* `_search` reads its words in `searchLanguage`, a text search configuration
|
|
8
|
+
* of the server, `'simple'` by default.
|
|
9
|
+
*/
|
|
10
|
+
export declare function postgres(options: {
|
|
11
|
+
readonly url: string | undefined;
|
|
12
|
+
readonly connections?: number;
|
|
13
|
+
readonly searchLanguage?: string;
|
|
14
|
+
}): Postgres;
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAG5C,YAAY,EAAE,QAAQ,EAAE,CAAA;AAExB;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAChC,GAAG,QAAQ,CAEX"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createAdapter } from './adapter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Stores the app's models in the PostgreSQL database at `url`. An unset one,
|
|
4
|
+
* `process.env.DATABASE_URL` perhaps, is refused when the app starts. A query
|
|
5
|
+
* waits up to 10 seconds for one of its `connections`, 10 by default.
|
|
6
|
+
* `_search` reads its words in `searchLanguage`, a text search configuration
|
|
7
|
+
* of the server, `'simple'` by default.
|
|
8
|
+
*/
|
|
9
|
+
export function postgres(options) {
|
|
10
|
+
return createAdapter(options);
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Condition } from 'ultimabase';
|
|
2
|
+
import type { Sql } from './logic.ts';
|
|
3
|
+
export declare const compares: {
|
|
4
|
+
readonly [operator: string]: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* A json value's path filter (D-149): `#>` reaches the value, which an
|
|
8
|
+
* operator tests when it has the operator's JSON type. A missing value stays
|
|
9
|
+
* unknown, as in `memory()`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function jsonPath(column: Sql, condition: Condition): Sql;
|
|
12
|
+
//# sourceMappingURL=json-filter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json-filter.d.ts","sourceRoot":"","sources":["../src/json-filter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAC3C,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAA;AAKrC,eAAO,MAAM,QAAQ,EAAE;IAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;CAK3D,CAAA;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,SAAS,GAAG,GAAG,CAqD/D"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A json value's path filter as SQL (D-149), as `memory()` runs it.
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
import { all, pattern } from './logic.js';
|
|
4
|
+
const jsonb = (item) => sql `cast(${JSON.stringify(item)} as jsonb)`;
|
|
5
|
+
export const compares = {
|
|
6
|
+
lt: '<',
|
|
7
|
+
lte: '<=',
|
|
8
|
+
gt: '>',
|
|
9
|
+
gte: '>=',
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A json value's path filter (D-149): `#>` reaches the value, which an
|
|
13
|
+
* operator tests when it has the operator's JSON type. A missing value stays
|
|
14
|
+
* unknown, as in `memory()`.
|
|
15
|
+
*/
|
|
16
|
+
export function jsonPath(column, condition) {
|
|
17
|
+
const path = sql `cast(${condition.path ?? []} as text[])`;
|
|
18
|
+
const at = sql `(${column} #> ${path})`;
|
|
19
|
+
const text = sql `(${column} #>> ${path})`;
|
|
20
|
+
const insensitive = condition.mode === 'insensitive';
|
|
21
|
+
const folded = insensitive ? sql `(${text} collate "default")` : text;
|
|
22
|
+
const like = sql.raw(insensitive ? 'ilike' : 'like');
|
|
23
|
+
const typed = (type, test) => sql `(case when ${at} is null then null when jsonb_typeof(${at}) = ${type} then coalesce(${test}, false) else false end)`;
|
|
24
|
+
const parts = [];
|
|
25
|
+
for (const [operator, operand] of Object.entries(condition)) {
|
|
26
|
+
if (operand === undefined || operator === 'path' || operator === 'mode') {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const strings = (before, after) => typed('string', sql `${folded} ${like} ${pattern(operand, before, after)}`);
|
|
30
|
+
switch (operator) {
|
|
31
|
+
case 'equals':
|
|
32
|
+
parts.push(sql `${at} = ${jsonb(operand)}`);
|
|
33
|
+
break;
|
|
34
|
+
case 'not':
|
|
35
|
+
parts.push(sql `${at} <> ${jsonb(operand)}`);
|
|
36
|
+
break;
|
|
37
|
+
case 'stringContains':
|
|
38
|
+
parts.push(strings('%', '%'));
|
|
39
|
+
break;
|
|
40
|
+
case 'stringStartsWith':
|
|
41
|
+
parts.push(strings('', '%'));
|
|
42
|
+
break;
|
|
43
|
+
case 'stringEndsWith':
|
|
44
|
+
parts.push(strings('%', ''));
|
|
45
|
+
break;
|
|
46
|
+
case 'arrayContains':
|
|
47
|
+
parts.push(typed('array', sql `${at} @> ${jsonb(operand)}`));
|
|
48
|
+
break;
|
|
49
|
+
case 'arrayStartsWith':
|
|
50
|
+
parts.push(typed('array', sql `${at} -> 0 = ${jsonb(operand)}`));
|
|
51
|
+
break;
|
|
52
|
+
case 'arrayEndsWith':
|
|
53
|
+
parts.push(typed('array', sql `${at} -> -1 = ${jsonb(operand)}`));
|
|
54
|
+
break;
|
|
55
|
+
default: {
|
|
56
|
+
const compare = sql.raw(compares[operator] ?? '=');
|
|
57
|
+
// Strings by code point, as everywhere (D-94).
|
|
58
|
+
parts.push(typeof operand === 'number'
|
|
59
|
+
? typed('number', sql `${at} ${compare} ${jsonb(operand)}`)
|
|
60
|
+
: typed('string', sql `(${text} collate "C") ${compare} ${operand}`));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return all(parts);
|
|
65
|
+
}
|
package/dist/listen.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ClientConfig } from 'pg';
|
|
2
|
+
import type { Feed, Logger } from 'ultimabase';
|
|
3
|
+
/** The feed's channel of `schema`, within the 63 bytes of a name. */
|
|
4
|
+
export declare function channelOf(schema: string): string;
|
|
5
|
+
export type Listener = {
|
|
6
|
+
readonly stop: () => Promise<void>;
|
|
7
|
+
};
|
|
8
|
+
/** Listens on `channel` until stopped; a first connection that fails throws. */
|
|
9
|
+
export declare function listening(config: ClientConfig, channel: string, feed: Feed, logger: Logger): Promise<Listener>;
|
|
10
|
+
//# sourceMappingURL=listen.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listen.d.ts","sourceRoot":"","sources":["../src/listen.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,IAAI,CAAA;AAEtC,OAAO,KAAK,EAAW,IAAI,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAGvD,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAKhD;AAED,MAAM,MAAM,QAAQ,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAA;AAM7D,gFAAgF;AAChF,wBAAsB,SAAS,CAC9B,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,QAAQ,CAAC,CAoEnB"}
|
package/dist/listen.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
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 { Client } from 'pg';
|
|
5
|
+
import { changedOf } from './notify.js';
|
|
6
|
+
/** The feed's channel of `schema`, within the 63 bytes of a name. */
|
|
7
|
+
export function channelOf(schema) {
|
|
8
|
+
const channel = `ultimabase:${schema}`;
|
|
9
|
+
if (Buffer.byteLength(channel) <= 63)
|
|
10
|
+
return channel;
|
|
11
|
+
const hash = createHash('sha256').update(schema).digest('hex');
|
|
12
|
+
return `ultimabase:${hash.slice(0, 52)}`;
|
|
13
|
+
}
|
|
14
|
+
/** Milliseconds between two attempts to listen again, doubling. */
|
|
15
|
+
const first = 100;
|
|
16
|
+
const most = 5000;
|
|
17
|
+
/** Listens on `channel` until stopped; a first connection that fails throws. */
|
|
18
|
+
export async function listening(config, channel, feed, logger) {
|
|
19
|
+
let client;
|
|
20
|
+
let stopped = false;
|
|
21
|
+
let timer;
|
|
22
|
+
let heard = [];
|
|
23
|
+
const flush = () => {
|
|
24
|
+
const changes = heard;
|
|
25
|
+
heard = [];
|
|
26
|
+
if (changes.length > 0)
|
|
27
|
+
feed.changed(changes);
|
|
28
|
+
};
|
|
29
|
+
const connect = async () => {
|
|
30
|
+
const own = new Client({ ...config, application_name: 'ultimabase-feed' });
|
|
31
|
+
own.on('notification', ({ channel: to, payload }) => {
|
|
32
|
+
const change = to === channel ? changedOf(payload) : undefined;
|
|
33
|
+
if (change === undefined)
|
|
34
|
+
return;
|
|
35
|
+
if (heard.length === 0)
|
|
36
|
+
setImmediate(flush);
|
|
37
|
+
heard.push(change);
|
|
38
|
+
});
|
|
39
|
+
own.on('error', () => lost(own));
|
|
40
|
+
own.on('end', () => lost(own));
|
|
41
|
+
try {
|
|
42
|
+
await own.connect();
|
|
43
|
+
await own.query(`listen "${channel.replaceAll('"', '""')}"`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
own.removeAllListeners('end');
|
|
47
|
+
await own.end().catch(() => undefined);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
return own;
|
|
51
|
+
};
|
|
52
|
+
const retry = (wait) => {
|
|
53
|
+
timer = setTimeout(() => void again(wait), wait);
|
|
54
|
+
};
|
|
55
|
+
const again = async (wait) => {
|
|
56
|
+
let own;
|
|
57
|
+
try {
|
|
58
|
+
own = await connect();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
retry(Math.min(wait * 2, most));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (stopped) {
|
|
65
|
+
await own.end().catch(() => undefined);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
client = own;
|
|
69
|
+
logger.info('The change feed listens again.');
|
|
70
|
+
// What was committed while no one listened is unknown.
|
|
71
|
+
feed.missed();
|
|
72
|
+
};
|
|
73
|
+
const lost = (own) => {
|
|
74
|
+
if (stopped || client !== own)
|
|
75
|
+
return;
|
|
76
|
+
client = undefined;
|
|
77
|
+
own.removeAllListeners('end');
|
|
78
|
+
own.end().catch(() => undefined);
|
|
79
|
+
logger.warn('The change feed lost its connection: it listens again soon.');
|
|
80
|
+
retry(first);
|
|
81
|
+
};
|
|
82
|
+
client = await connect();
|
|
83
|
+
return {
|
|
84
|
+
stop: async () => {
|
|
85
|
+
stopped = true;
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
const own = client;
|
|
88
|
+
client = undefined;
|
|
89
|
+
await own?.end();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/logic.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RawBuilder } from 'kysely';
|
|
2
|
+
export type Sql = RawBuilder<unknown>;
|
|
3
|
+
export declare function all(parts: readonly Sql[]): Sql;
|
|
4
|
+
export declare function any(parts: readonly Sql[]): Sql;
|
|
5
|
+
/** `%`, `_` and `\` are characters, not patterns (D-94). */
|
|
6
|
+
export declare function pattern(operand: unknown, before: string, after: string): string;
|
|
7
|
+
//# sourceMappingURL=logic.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logic.d.ts","sourceRoot":"","sources":["../src/logic.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AAGxC,MAAM,MAAM,GAAG,GAAG,UAAU,CAAC,OAAO,CAAC,CAAA;AAErC,wBAAgB,GAAG,CAAC,KAAK,EAAE,SAAS,GAAG,EAAE,GAAG,GAAG,CAK9C;AAED,wBAAgB,GAAG,CAAC,KAAK,EAAE,SAAS,GAAG,EAAE,GAAG,GAAG,CAG9C;AAED,4DAA4D;AAC5D,wBAAgB,OAAO,CACtB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GACX,MAAM,CAGR"}
|
package/dist/logic.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
export function all(parts) {
|
|
3
|
+
if (parts.length === 0)
|
|
4
|
+
return sql `true`;
|
|
5
|
+
return parts.length === 1
|
|
6
|
+
? (parts[0] ?? sql `true`)
|
|
7
|
+
: sql `(${sql.join(parts, sql ` and `)})`;
|
|
8
|
+
}
|
|
9
|
+
export function any(parts) {
|
|
10
|
+
if (parts.length === 0)
|
|
11
|
+
return sql `false`;
|
|
12
|
+
return sql `(${sql.join(parts, sql ` or `)})`;
|
|
13
|
+
}
|
|
14
|
+
/** `%`, `_` and `\` are characters, not patterns (D-94). */
|
|
15
|
+
export function pattern(operand, before, after) {
|
|
16
|
+
const text = typeof operand === 'string' ? operand : '';
|
|
17
|
+
return `${before}${text.replaceAll(/[\\%_]/g, '\\$&')}${after}`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Kysely, QueryExecutorProvider } from 'kysely';
|
|
2
|
+
import type { Run, Stored } from 'ultimabase';
|
|
3
|
+
/** The tables UltimaBase keeps its own records in. */
|
|
4
|
+
export declare const tables: readonly string[];
|
|
5
|
+
/**
|
|
6
|
+
* The stored snapshot and the applied files, in one query. A deploy that
|
|
7
|
+
* stopped before it stored its snapshot leaves files and no snapshot.
|
|
8
|
+
*/
|
|
9
|
+
export declare function stored(db: Kysely<object>): Promise<Stored | undefined>;
|
|
10
|
+
export declare function apply(db: Kysely<object>, run: Run): Promise<void>;
|
|
11
|
+
/** The current schema's tables but an extension's, in name order. */
|
|
12
|
+
export declare function tablesOf(db: QueryExecutorProvider): Promise<string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Drops every table of the current schema but an extension's, such as
|
|
15
|
+
* PostGIS's `spatial_ref_sys`; returns the models' tables.
|
|
16
|
+
*/
|
|
17
|
+
export declare function reset(db: Kysely<object>): Promise<readonly string[]>;
|
|
18
|
+
/**
|
|
19
|
+
* The host `url` connects to when it is not this machine, read as `pg`
|
|
20
|
+
* reads it: no host, or a socket's directory, is this machine.
|
|
21
|
+
*/
|
|
22
|
+
export declare function remoteOf(url: string): string | undefined;
|
|
23
|
+
//# sourceMappingURL=migrations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,qBAAqB,EAAe,MAAM,QAAQ,CAAA;AAGxE,OAAO,KAAK,EAAa,GAAG,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAKxD,sDAAsD;AACtD,eAAO,MAAM,MAAM,EAAE,SAAS,MAAM,EAGnC,CAAA;AAwDD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA0B5E;AAmID,wBAAsB,KAAK,CAAC,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvE;AAED,qEAAqE;AACrE,wBAAsB,QAAQ,CAAC,EAAE,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAS3E;AAED;;;GAGG;AACH,wBAAsB,KAAK,CAAC,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAS1E;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOxD"}
|