@owlmeans/postgres-resource 0.1.18-rc.42 → 0.1.18-rc.43
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/README.md +13 -13
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/postgres-resource/SKILL.md +1 -1
- package/build/consts.d.ts +9 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +6 -0
- package/build/consts.js.map +1 -1
- package/build/declarations/types.d.ts +12 -0
- package/build/declarations/types.d.ts.map +1 -0
- package/build/declarations/types.js +2 -0
- package/build/declarations/types.js.map +1 -0
- package/build/declarations.d.ts +5 -1
- package/build/declarations.d.ts.map +1 -1
- package/build/declarations.js +25 -15
- package/build/declarations.js.map +1 -1
- package/build/errors.d.ts +0 -20
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +1 -111
- package/build/errors.js.map +1 -1
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/pg-error/types.d.ts +24 -0
- package/build/pg-error/types.d.ts.map +1 -0
- package/build/pg-error/types.js +2 -0
- package/build/pg-error/types.js.map +1 -0
- package/build/pg-error.d.ts +4 -0
- package/build/pg-error.d.ts.map +1 -0
- package/build/pg-error.js +100 -0
- package/build/pg-error.js.map +1 -0
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +15 -11
- package/build/resource.js.map +1 -1
- package/build/types.d.ts.map +1 -1
- package/build/utils/consts.local.d.ts +2 -0
- package/build/utils/consts.local.d.ts.map +1 -1
- package/build/utils/consts.local.js +2 -0
- package/build/utils/consts.local.js.map +1 -1
- package/build/utils/criteria/types.d.ts +31 -0
- package/build/utils/criteria/types.d.ts.map +1 -0
- package/build/utils/criteria/types.js +2 -0
- package/build/utils/criteria/types.js.map +1 -0
- package/build/utils/criteria.d.ts +3 -27
- package/build/utils/criteria.d.ts.map +1 -1
- package/build/utils/criteria.js +86 -104
- package/build/utils/criteria.js.map +1 -1
- package/build/utils/diff/types.d.ts +40 -0
- package/build/utils/diff/types.d.ts.map +1 -0
- package/build/utils/diff/types.js +2 -0
- package/build/utils/diff/types.js.map +1 -0
- package/build/utils/diff.d.ts +5 -35
- package/build/utils/diff.d.ts.map +1 -1
- package/build/utils/diff.js +320 -344
- package/build/utils/diff.js.map +1 -1
- package/build/utils/index.d.ts +9 -0
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js.map +1 -1
- package/build/utils/introspect/types.d.ts +9 -0
- package/build/utils/introspect/types.d.ts.map +1 -0
- package/build/utils/introspect/types.js +2 -0
- package/build/utils/introspect/types.js.map +1 -0
- package/build/utils/introspect.d.ts +3 -3
- package/build/utils/introspect.d.ts.map +1 -1
- package/build/utils/introspect.js +41 -38
- package/build/utils/introspect.js.map +1 -1
- package/build/utils/life-cycle/types.d.ts +37 -0
- package/build/utils/life-cycle/types.d.ts.map +1 -0
- package/build/utils/life-cycle/types.js +2 -0
- package/build/utils/life-cycle/types.js.map +1 -0
- package/build/utils/life-cycle.d.ts +3 -33
- package/build/utils/life-cycle.d.ts.map +1 -1
- package/build/utils/life-cycle.js +88 -108
- package/build/utils/life-cycle.js.map +1 -1
- package/build/utils/marshal/types.d.ts +39 -0
- package/build/utils/marshal/types.d.ts.map +1 -0
- package/build/utils/marshal/types.js +2 -0
- package/build/utils/marshal/types.js.map +1 -0
- package/build/utils/marshal.d.ts +4 -32
- package/build/utils/marshal.d.ts.map +1 -1
- package/build/utils/marshal.js +121 -144
- package/build/utils/marshal.js.map +1 -1
- package/build/utils/migrations.d.ts.map +1 -1
- package/build/utils/migrations.js +41 -36
- package/build/utils/migrations.js.map +1 -1
- package/build/utils/name/types.d.ts +43 -0
- package/build/utils/name/types.d.ts.map +1 -0
- package/build/utils/name/types.js +2 -0
- package/build/utils/name/types.js.map +1 -0
- package/build/utils/name.d.ts +6 -35
- package/build/utils/name.d.ts.map +1 -1
- package/build/utils/name.js +38 -57
- package/build/utils/name.js.map +1 -1
- package/build/utils/schema/types.d.ts +22 -0
- package/build/utils/schema/types.d.ts.map +1 -0
- package/build/utils/schema/types.js +2 -0
- package/build/utils/schema/types.js.map +1 -0
- package/build/utils/schema.d.ts +4 -24
- package/build/utils/schema.d.ts.map +1 -1
- package/build/utils/schema.js +339 -352
- package/build/utils/schema.js.map +1 -1
- package/build/utils/sql/types.d.ts +25 -0
- package/build/utils/sql/types.d.ts.map +1 -0
- package/build/utils/sql/types.js +2 -0
- package/build/utils/sql/types.js.map +1 -0
- package/build/utils/sql.d.ts +4 -18
- package/build/utils/sql.d.ts.map +1 -1
- package/build/utils/sql.js +0 -0
- package/build/utils/sql.js.map +1 -1
- package/build/utils/sync/types.d.ts +25 -0
- package/build/utils/sync/types.d.ts.map +1 -0
- package/build/utils/sync/types.js +2 -0
- package/build/utils/sync/types.js.map +1 -0
- package/build/utils/sync.d.ts +2 -19
- package/build/utils/sync.d.ts.map +1 -1
- package/build/utils/sync.js +64 -71
- package/build/utils/sync.js.map +1 -1
- package/package.json +7 -7
- package/src/consts.ts +7 -0
- package/src/declarations/types.ts +12 -0
- package/src/declarations.ts +29 -14
- package/src/errors.ts +1 -121
- package/src/index.ts +3 -0
- package/src/pg-error/types.ts +23 -0
- package/src/pg-error.ts +114 -0
- package/src/resource.ts +17 -13
- package/src/types.ts +1 -0
- package/src/utils/consts.local.ts +3 -0
- package/src/utils/criteria/types.ts +32 -0
- package/src/utils/criteria.ts +95 -110
- package/src/utils/diff/types.ts +41 -0
- package/src/utils/diff.ts +330 -348
- package/src/utils/index.ts +9 -0
- package/src/utils/introspect/types.ts +9 -0
- package/src/utils/introspect.ts +51 -45
- package/src/utils/life-cycle/types.ts +41 -0
- package/src/utils/life-cycle.ts +102 -121
- package/src/utils/marshal/types.ts +40 -0
- package/src/utils/marshal.ts +129 -147
- package/src/utils/migrations.ts +39 -33
- package/src/utils/name/types.ts +44 -0
- package/src/utils/name.ts +47 -60
- package/src/utils/schema/types.ts +26 -0
- package/src/utils/schema.ts +359 -366
- package/src/utils/sql/types.ts +25 -0
- package/src/utils/sql.ts +0 -0
- package/src/utils/sync/types.ts +25 -0
- package/src/utils/sync.ts +62 -68
- package/tests/criteria.spec.ts +4 -1
- package/tests/diff.spec.ts +4 -1
- package/tests/errors.spec.ts +4 -3
- package/tests/name.spec.ts +5 -3
- package/tests/schema.spec.ts +3 -1
- package/tests/sql.spec.ts +20 -22
package/src/utils/marshal.ts
CHANGED
|
@@ -4,177 +4,159 @@ import { types as pgTypes } from 'pg'
|
|
|
4
4
|
|
|
5
5
|
import { ID_FIELD, PgTypeOid } from '../consts.js'
|
|
6
6
|
import type { ColumnSpec, TableSpec } from '../types.js'
|
|
7
|
+
import type { PgMarshalHelper } from './marshal/types.js'
|
|
7
8
|
|
|
8
9
|
const log = logger('postgres-resource')
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const fromDriver = (value: unknown, column: ColumnSpec, table?: string): unknown => {
|
|
30
|
-
if (value == null) {
|
|
31
|
-
return value
|
|
32
|
-
}
|
|
33
|
-
/** Ciphertext is opaque — coercing it would corrupt it. */
|
|
34
|
-
if (column.secure) {
|
|
35
|
-
return value
|
|
11
|
+
export const createPgMarshalHelper = (): PgMarshalHelper => {
|
|
12
|
+
/**
|
|
13
|
+
* Parse a temporal value the way the driver would have.
|
|
14
|
+
*
|
|
15
|
+
* Drizzle's node-postgres session installs its own `getTypeParser` that hands date and
|
|
16
|
+
* timestamp columns back as raw text, because its own statically declared column types do
|
|
17
|
+
* the conversion. Ours are compiled at runtime and can't, so the driver's parser is called
|
|
18
|
+
* directly — the same function, just later. Raw SQL never comes through here, since that
|
|
19
|
+
* path keeps the driver's default parsers.
|
|
20
|
+
*/
|
|
21
|
+
const toDate = (value: string, column: ColumnSpec): unknown => {
|
|
22
|
+
const oid = column.sqlType.startsWith('timestamp with time zone')
|
|
23
|
+
? PgTypeOid.TimestampTz
|
|
24
|
+
: column.sqlType.startsWith('timestamp')
|
|
25
|
+
? PgTypeOid.Timestamp
|
|
26
|
+
: PgTypeOid.Date
|
|
27
|
+
|
|
28
|
+
return (pgTypes.getTypeParser(oid as never) as (raw: string) => unknown)(value)
|
|
36
29
|
}
|
|
37
30
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
31
|
+
const fromDriver = (value: unknown, column: ColumnSpec, table?: string): unknown => {
|
|
32
|
+
if (value == null) {
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
/** Ciphertext is opaque — coercing it would corrupt it. */
|
|
36
|
+
if (column.secure) {
|
|
37
|
+
return value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
switch (column.jsonType) {
|
|
41
|
+
case 'number':
|
|
42
|
+
/** Postgres returns `numeric` as a string to protect precision the JS number can't hold. */
|
|
43
|
+
return typeof value === 'string' ? parseFloat(value) : value
|
|
44
|
+
case 'integer':
|
|
45
|
+
return typeof value === 'string' ? parseInt(value, 10) : value
|
|
46
|
+
case 'bigint': {
|
|
47
|
+
if (typeof value !== 'string' && typeof value !== 'bigint') {
|
|
48
|
+
return value
|
|
49
|
+
}
|
|
50
|
+
const parsed = Number(value)
|
|
51
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
52
|
+
// Fires per row read: one line per window per column is enough.
|
|
53
|
+
if (logThrottle(`postgres-resource:${table ?? ''}:${column.column}:unsafe-bigint`, 300_000)) {
|
|
54
|
+
log.warn('Bigint column value exceeds the safe integer range; the value read back is imprecise', {
|
|
55
|
+
table, column: column.column,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
55
58
|
}
|
|
59
|
+
|
|
60
|
+
return parsed
|
|
56
61
|
}
|
|
62
|
+
case 'binary':
|
|
63
|
+
return Buffer.isBuffer(value) ? value.toString('base64') : value
|
|
64
|
+
case 'date':
|
|
65
|
+
return typeof value === 'string' ? toDate(value, column) : value
|
|
66
|
+
default:
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
}
|
|
57
70
|
|
|
58
|
-
|
|
71
|
+
const toDriver = (value: unknown, column: ColumnSpec): unknown => {
|
|
72
|
+
if (value == null) {
|
|
73
|
+
return value
|
|
59
74
|
}
|
|
60
|
-
|
|
61
|
-
return Buffer.isBuffer(value) ? value.toString('base64') : value
|
|
62
|
-
case 'date':
|
|
63
|
-
return typeof value === 'string' ? toDate(value, column) : value
|
|
64
|
-
default:
|
|
75
|
+
if (column.secure) {
|
|
65
76
|
return value
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
}
|
|
78
|
+
if (column.jsonb) {
|
|
79
|
+
/**
|
|
80
|
+
* node-postgres stringifies plain objects on its own but turns arrays into Postgres
|
|
81
|
+
* array literals, which a jsonb column rejects. Stringifying here covers both.
|
|
82
|
+
*/
|
|
83
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
84
|
+
}
|
|
85
|
+
if (column.jsonType === 'date' && !(value instanceof Date)) {
|
|
86
|
+
return new Date(value as string | number)
|
|
87
|
+
}
|
|
88
|
+
if (column.jsonType === 'binary' && typeof value === 'string') {
|
|
89
|
+
return Buffer.from(value, 'base64')
|
|
90
|
+
}
|
|
68
91
|
|
|
69
|
-
const toDriver = (value: unknown, column: ColumnSpec): unknown => {
|
|
70
|
-
if (value == null) {
|
|
71
92
|
return value
|
|
72
93
|
}
|
|
73
|
-
if (column.secure) {
|
|
74
|
-
return value
|
|
75
|
-
}
|
|
76
|
-
if (column.jsonb) {
|
|
77
|
-
/**
|
|
78
|
-
* node-postgres stringifies plain objects on its own but turns arrays into Postgres
|
|
79
|
-
* array literals, which a jsonb column rejects. Stringifying here covers both.
|
|
80
|
-
*/
|
|
81
|
-
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
82
|
-
}
|
|
83
|
-
if (column.jsonType === 'date' && !(value instanceof Date)) {
|
|
84
|
-
return new Date(value as string | number)
|
|
85
|
-
}
|
|
86
|
-
if (column.jsonType === 'binary' && typeof value === 'string') {
|
|
87
|
-
return Buffer.from(value, 'base64')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return value
|
|
91
|
-
}
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
for (const [key, value] of Object.entries(row)) {
|
|
103
|
-
const column = spec.byColumn[key]
|
|
104
|
-
if (column == null) {
|
|
105
|
-
record[key] = value
|
|
106
|
-
continue
|
|
95
|
+
const rowToRecord = <T extends ResourceRecord>(row: Record<string, unknown>, spec: TableSpec): T => {
|
|
96
|
+
const record: Record<string, unknown> = {}
|
|
97
|
+
for (const [key, value] of Object.entries(row)) {
|
|
98
|
+
const column = spec.byColumn[key]
|
|
99
|
+
if (column == null) {
|
|
100
|
+
record[key] = value
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
record[column.property] = fromDriver(value, column, spec.qualified)
|
|
107
104
|
}
|
|
108
|
-
record[
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
if (record[ID_FIELD] != null) {
|
|
106
|
+
record[ID_FIELD] = `${record[ID_FIELD]}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return record as T
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
const resultToRecord = <T extends ResourceRecord>(
|
|
113
|
+
row: Record<string, unknown>, spec: TableSpec
|
|
114
|
+
): T => {
|
|
115
|
+
const record: Record<string, unknown> = {}
|
|
116
|
+
for (const [key, value] of Object.entries(row)) {
|
|
117
|
+
const column = spec.byProperty[key]
|
|
118
|
+
record[key] = column == null ? value : fromDriver(value, column, spec.qualified)
|
|
119
|
+
}
|
|
120
|
+
if (record[ID_FIELD] != null) {
|
|
121
|
+
record[ID_FIELD] = `${record[ID_FIELD]}`
|
|
122
|
+
}
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
* Same coercion as {@link rowToRecord}, for rows Drizzle produced.
|
|
119
|
-
*
|
|
120
|
-
* Drizzle keys its results by the table object's JS property names — which are the schema
|
|
121
|
-
* property names — where raw SQL returns physical column names. Two functions rather than
|
|
122
|
-
* one lookup that tries both, because a schema that renames `a` to column `b` while another
|
|
123
|
-
* property is itself named `b` would make the combined lookup pick the wrong spec.
|
|
124
|
-
*/
|
|
125
|
-
export const resultToRecord = <T extends ResourceRecord>(
|
|
126
|
-
row: Record<string, unknown>, spec: TableSpec
|
|
127
|
-
): T => {
|
|
128
|
-
const record: Record<string, unknown> = {}
|
|
129
|
-
for (const [key, value] of Object.entries(row)) {
|
|
130
|
-
const column = spec.byProperty[key]
|
|
131
|
-
record[key] = column == null ? value : fromDriver(value, column, spec.qualified)
|
|
132
|
-
}
|
|
133
|
-
if (record[ID_FIELD] != null) {
|
|
134
|
-
record[ID_FIELD] = `${record[ID_FIELD]}`
|
|
124
|
+
return record as T
|
|
135
125
|
}
|
|
136
126
|
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
* an explicit `null` is kept, because nulling a column is a real intent.
|
|
146
|
-
*/
|
|
147
|
-
export const recordToValues = (record: Record<string, unknown>, spec: TableSpec): Record<string, unknown> => {
|
|
148
|
-
const values: Record<string, unknown> = {}
|
|
149
|
-
for (const [key, value] of Object.entries(record)) {
|
|
150
|
-
const column = spec.byProperty[key]
|
|
151
|
-
if (column == null || value === undefined) {
|
|
152
|
-
continue
|
|
127
|
+
const recordToValues = (record: Record<string, unknown>, spec: TableSpec): Record<string, unknown> => {
|
|
128
|
+
const values: Record<string, unknown> = {}
|
|
129
|
+
for (const [key, value] of Object.entries(record)) {
|
|
130
|
+
const column = spec.byProperty[key]
|
|
131
|
+
if (column == null || value === undefined) {
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
values[column.property] = toDriver(value, column)
|
|
153
135
|
}
|
|
154
|
-
values[column.property] = toDriver(value, column)
|
|
155
|
-
}
|
|
156
136
|
|
|
157
|
-
|
|
158
|
-
}
|
|
137
|
+
return values
|
|
138
|
+
}
|
|
159
139
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const values: Record<string, unknown> = {}
|
|
171
|
-
for (const column of spec.columns) {
|
|
172
|
-
if (spec.primaryKey.includes(column.column) || spec.unmanaged.includes(column.column)) {
|
|
173
|
-
continue
|
|
140
|
+
const recordToFullValues = (
|
|
141
|
+
record: Record<string, unknown>, spec: TableSpec
|
|
142
|
+
): Record<string, unknown> => {
|
|
143
|
+
const values: Record<string, unknown> = {}
|
|
144
|
+
for (const column of spec.columns) {
|
|
145
|
+
if (spec.primaryKey.includes(column.column) || spec.unmanaged.includes(column.column)) {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const value = record[column.property]
|
|
149
|
+
values[column.property] = value === undefined ? null : toDriver(value, column)
|
|
174
150
|
}
|
|
175
|
-
|
|
176
|
-
values
|
|
151
|
+
|
|
152
|
+
return values
|
|
177
153
|
}
|
|
178
154
|
|
|
179
|
-
return
|
|
155
|
+
return { rowToRecord, resultToRecord, recordToValues, recordToFullValues }
|
|
180
156
|
}
|
|
157
|
+
|
|
158
|
+
export const pgMarshalHelper = createPgMarshalHelper()
|
|
159
|
+
|
|
160
|
+
/** @deprecated compat:factory-refactor — use `pgMarshalHelper.rowToRecord(…)` */
|
|
161
|
+
export const rowToRecord = <T extends ResourceRecord>(row: Record<string, unknown>, spec: TableSpec): T =>
|
|
162
|
+
pgMarshalHelper.rowToRecord<T>(row, spec)
|
package/src/utils/migrations.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Migration, MigrationStore } from '@owlmeans/resource'
|
|
2
2
|
import type { PoolClient, QueryResultRow } from 'pg'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { pgErrorHelper } from '../pg-error.js'
|
|
5
5
|
import type { PostgresTx, TableSpec } from '../types.js'
|
|
6
|
-
import {
|
|
6
|
+
import { pgNameHelper } from './name.js'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* A transaction façade over a checked out client. `{{alias}}` resolution is injected so
|
|
@@ -11,41 +11,45 @@ import { qualify, quoteIdent } from './name.js'
|
|
|
11
11
|
*/
|
|
12
12
|
export const makeTx = (
|
|
13
13
|
client: PoolClient, resolve: (text: string) => string, ref: (alias?: string) => string
|
|
14
|
-
): PostgresTx =>
|
|
15
|
-
|
|
14
|
+
): PostgresTx => {
|
|
15
|
+
const { pgErrorToResourceError } = pgErrorHelper
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
const result = await client.query<Row>(query, params as never[])
|
|
21
|
-
return result.rows
|
|
22
|
-
} catch (error) {
|
|
23
|
-
throw pgErrorToResourceError(error, query)
|
|
24
|
-
}
|
|
25
|
-
},
|
|
17
|
+
return {
|
|
18
|
+
client,
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
20
|
+
query: async <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
|
|
21
|
+
const query = resolve(text)
|
|
22
|
+
try {
|
|
23
|
+
const result = await client.query<Row>(query, params as never[])
|
|
24
|
+
return result.rows
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw pgErrorToResourceError(error, query)
|
|
27
|
+
}
|
|
28
|
+
},
|
|
36
29
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
30
|
+
queryOne: async <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
|
|
31
|
+
const query = resolve(text)
|
|
32
|
+
try {
|
|
33
|
+
const result = await client.query<Row>(query, params as never[])
|
|
34
|
+
return result.rows[0] ?? null
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw pgErrorToResourceError(error, query)
|
|
37
|
+
}
|
|
38
|
+
},
|
|
46
39
|
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
execute: async (text: string, params?: unknown[]) => {
|
|
41
|
+
const query = resolve(text)
|
|
42
|
+
try {
|
|
43
|
+
const result = await client.query(query, params as never[])
|
|
44
|
+
return result.rowCount ?? 0
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw pgErrorToResourceError(error, query)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
ref
|
|
51
|
+
}
|
|
52
|
+
}
|
|
49
53
|
|
|
50
54
|
/**
|
|
51
55
|
* Migration ledger, one table per Postgres schema.
|
|
@@ -60,6 +64,8 @@ export const makeMigrationStore = (
|
|
|
60
64
|
resolve: (text: string) => string,
|
|
61
65
|
ref: (alias?: string) => string
|
|
62
66
|
): MigrationStore<PostgresTx> => {
|
|
67
|
+
const { pgErrorToResourceError } = pgErrorHelper
|
|
68
|
+
const { qualify, quoteIdent } = pgNameHelper
|
|
63
69
|
const ledger = qualify(spec.schema, table)
|
|
64
70
|
|
|
65
71
|
return {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { DbConfig, ResourceRecord } from '@owlmeans/resource'
|
|
2
|
+
|
|
3
|
+
import type { PostgresResource } from '../../types.js'
|
|
4
|
+
|
|
5
|
+
/** Postgres identifiers and literals: sanitizing, asserting, quoting and naming. */
|
|
6
|
+
export interface PgNameHelper {
|
|
7
|
+
/**
|
|
8
|
+
* Coerce an arbitrary name into a legal Postgres identifier of at most 63 bytes.
|
|
9
|
+
*
|
|
10
|
+
* Postgres cuts anything past `NAMEDATALEN - 1` off server side and says nothing about
|
|
11
|
+
* it, so two names that differ only beyond byte 63 would collapse into one identifier.
|
|
12
|
+
* Clamping here instead leaves room for a hash suffix that keeps them apart.
|
|
13
|
+
*/
|
|
14
|
+
pgIdentifier: (name: string) => string
|
|
15
|
+
/**
|
|
16
|
+
* Assert a value is safe to interpolate as an identifier. Postgres can't bind identifiers
|
|
17
|
+
* as parameters, so every one of them reaches the server as text — which makes this the
|
|
18
|
+
* only thing standing between a config value and injection.
|
|
19
|
+
*
|
|
20
|
+
* @throws {SyntaxError}
|
|
21
|
+
*/
|
|
22
|
+
assertSqlIdentifier: (value: string, what?: string) => string
|
|
23
|
+
/** Quote an identifier for emission, doubling any interior quote. */
|
|
24
|
+
quoteIdent: (value: string) => string
|
|
25
|
+
/**
|
|
26
|
+
* Quote a string literal. Only ever used for values Postgres refuses to bind — notably
|
|
27
|
+
* the password in `CREATE ROLE`.
|
|
28
|
+
*
|
|
29
|
+
* @throws {SyntaxError} on a NUL byte, which no escaping makes safe.
|
|
30
|
+
*/
|
|
31
|
+
quoteLiteral: (value: string) => string
|
|
32
|
+
/** `"schema"."table"`, each part quoted. */
|
|
33
|
+
qualify: (schema: string, table: string) => string
|
|
34
|
+
/**
|
|
35
|
+
* Physical table name of a resource: the explicit `name`, else the resource alias,
|
|
36
|
+
* prefixed per `DbConfig.resourcePrefix` and sanitized.
|
|
37
|
+
*/
|
|
38
|
+
pgTableName: (config: DbConfig, resource: PostgresResource<ResourceRecord>) => string
|
|
39
|
+
/**
|
|
40
|
+
* Derive a pair of int32s for `pg_advisory_lock` from a qualified table name, so every
|
|
41
|
+
* replica booting against the same table serializes on the same key.
|
|
42
|
+
*/
|
|
43
|
+
advisoryKey: (qualified: string) => [number, number]
|
|
44
|
+
}
|
package/src/utils/name.ts
CHANGED
|
@@ -5,74 +5,61 @@ import type { DbConfig, ResourceRecord } from '@owlmeans/resource'
|
|
|
5
5
|
import { PG_MAX_IDENTIFIER } from '../consts.js'
|
|
6
6
|
import type { PostgresResource } from '../types.js'
|
|
7
7
|
import { SAFE_IDENTIFIER } from './consts.local.js'
|
|
8
|
+
import type { PgNameHelper } from './name/types.js'
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (Buffer.byteLength(prefixed, 'utf8') <= PG_MAX_IDENTIFIER) {
|
|
20
|
-
return prefixed
|
|
10
|
+
export const createPgNameHelper = (): PgNameHelper => {
|
|
11
|
+
const pgIdentifier = (name: string): string => {
|
|
12
|
+
const sanitized = name.replace(/[^A-Za-z0-9_$]/g, '_')
|
|
13
|
+
const prefixed = /^[A-Za-z_]/.test(sanitized) ? sanitized : `_${sanitized}`
|
|
14
|
+
if (Buffer.byteLength(prefixed, 'utf8') <= PG_MAX_IDENTIFIER) {
|
|
15
|
+
return prefixed
|
|
16
|
+
}
|
|
17
|
+
const digest = hex.encode(sha256(new TextEncoder().encode(name))).substring(0, 32)
|
|
18
|
+
|
|
19
|
+
return `${prefixed.substring(0, 30)}_${digest}`
|
|
21
20
|
}
|
|
22
|
-
const digest = hex.encode(sha256(new TextEncoder().encode(name))).substring(0, 32)
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const assertSqlIdentifier = (value: string, what: string = 'identifier'): string => {
|
|
23
|
+
if (!SAFE_IDENTIFIER.test(value) || Buffer.byteLength(value, 'utf8') > PG_MAX_IDENTIFIER) {
|
|
24
|
+
throw new SyntaxError(`postgres:unsafe-${what}:${value}`)
|
|
25
|
+
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
* Assert a value is safe to interpolate as an identifier. Postgres can't bind identifiers
|
|
29
|
-
* as parameters, so every one of them reaches the server as text — which makes this the
|
|
30
|
-
* only thing standing between a config value and injection.
|
|
31
|
-
*
|
|
32
|
-
* @throws {SyntaxError}
|
|
33
|
-
*/
|
|
34
|
-
export const assertSqlIdentifier = (value: string, what: string = 'identifier'): string => {
|
|
35
|
-
if (!SAFE_IDENTIFIER.test(value) || Buffer.byteLength(value, 'utf8') > PG_MAX_IDENTIFIER) {
|
|
36
|
-
throw new SyntaxError(`postgres:unsafe-${what}:${value}`)
|
|
27
|
+
return value
|
|
37
28
|
}
|
|
38
29
|
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
const quoteIdent = (value: string): string => `"${value.replace(/"/g, '""')}"`
|
|
31
|
+
|
|
32
|
+
const quoteLiteral = (value: string): string => {
|
|
33
|
+
if (value.includes('\0')) {
|
|
34
|
+
throw new SyntaxError('postgres:unsafe-literal:nul-byte')
|
|
35
|
+
}
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
export const quoteIdent = (value: string): string => `"${value.replace(/"/g, '""')}"`
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Quote a string literal. Only ever used for values Postgres refuses to bind — notably
|
|
47
|
-
* the password in `CREATE ROLE`.
|
|
48
|
-
*
|
|
49
|
-
* @throws {SyntaxError} on a NUL byte, which no escaping makes safe.
|
|
50
|
-
*/
|
|
51
|
-
export const quoteLiteral = (value: string): string => {
|
|
52
|
-
if (value.includes('\0')) {
|
|
53
|
-
throw new SyntaxError('postgres:unsafe-literal:nul-byte')
|
|
37
|
+
return `'${value.replace(/'/g, "''")}'`
|
|
54
38
|
}
|
|
55
39
|
|
|
56
|
-
|
|
57
|
-
}
|
|
40
|
+
const qualify = (schema: string, table: string): string =>
|
|
41
|
+
`${quoteIdent(schema)}.${quoteIdent(table)}`
|
|
42
|
+
|
|
43
|
+
const pgTableName = (config: DbConfig, resource: PostgresResource<ResourceRecord>): string =>
|
|
44
|
+
pgIdentifier(`${config.resourcePrefix ?? ''}${resource.name ?? resource.alias}`)
|
|
45
|
+
|
|
46
|
+
const advisoryKey = (qualified: string): [number, number] => {
|
|
47
|
+
const digest = sha256(new TextEncoder().encode(`owlmeans:${qualified}`))
|
|
48
|
+
const view = new DataView(digest.buffer, digest.byteOffset, digest.byteLength)
|
|
58
49
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* Physical table name of a resource: the explicit `name`, else the resource alias,
|
|
64
|
-
* prefixed per `DbConfig.resourcePrefix` and sanitized.
|
|
65
|
-
*/
|
|
66
|
-
export const pgTableName = (config: DbConfig, resource: PostgresResource<ResourceRecord>): string =>
|
|
67
|
-
pgIdentifier(`${config.resourcePrefix ?? ''}${resource.name ?? resource.alias}`)
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Derive a pair of int32s for `pg_advisory_lock` from a qualified table name, so every
|
|
71
|
-
* replica booting against the same table serializes on the same key.
|
|
72
|
-
*/
|
|
73
|
-
export const advisoryKey = (qualified: string): [number, number] => {
|
|
74
|
-
const digest = sha256(new TextEncoder().encode(`owlmeans:${qualified}`))
|
|
75
|
-
const view = new DataView(digest.buffer, digest.byteOffset, digest.byteLength)
|
|
76
|
-
|
|
77
|
-
return [view.getInt32(0, false), view.getInt32(4, false)]
|
|
50
|
+
return [view.getInt32(0, false), view.getInt32(4, false)]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { pgIdentifier, assertSqlIdentifier, quoteIdent, quoteLiteral, qualify, pgTableName, advisoryKey }
|
|
78
54
|
}
|
|
55
|
+
|
|
56
|
+
export const pgNameHelper = createPgNameHelper()
|
|
57
|
+
|
|
58
|
+
/** @deprecated compat:factory-refactor — use `pgNameHelper.pgIdentifier(…)` */
|
|
59
|
+
export const pgIdentifier = (name: string): string => pgNameHelper.pgIdentifier(name)
|
|
60
|
+
|
|
61
|
+
/** @deprecated compat:factory-refactor — use `pgNameHelper.quoteIdent(…)` */
|
|
62
|
+
export const quoteIdent = (value: string): string => pgNameHelper.quoteIdent(value)
|
|
63
|
+
|
|
64
|
+
/** @deprecated compat:factory-refactor — use `pgNameHelper.advisoryKey(…)` */
|
|
65
|
+
export const advisoryKey = (qualified: string): [number, number] => pgNameHelper.advisoryKey(qualified)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AnySchema } from 'ajv'
|
|
2
|
+
|
|
3
|
+
import type { PgIndexSpec, TableSpec } from '../../types.js'
|
|
4
|
+
|
|
5
|
+
/** The JSON Schema → table specification compiler. */
|
|
6
|
+
export interface PgSchemaHelper {
|
|
7
|
+
/**
|
|
8
|
+
* Normalize a Postgres type name to the spelling `format_type()` reports, so the drift
|
|
9
|
+
* comparison against a live table is an exact string match instead of a fuzzy one.
|
|
10
|
+
*/
|
|
11
|
+
toFormatType: (type: string) => string
|
|
12
|
+
/**
|
|
13
|
+
* Compile a resource's AJV schema into the table specification that drives both DDL
|
|
14
|
+
* emission and drift detection.
|
|
15
|
+
*
|
|
16
|
+
* `extraIndexes` carries what `resource.index()` declared. It's compiled here rather than
|
|
17
|
+
* merged afterwards so chained declarations get the same property-to-column mapping and
|
|
18
|
+
* the same generated names as schema-borne ones.
|
|
19
|
+
*
|
|
20
|
+
* @throws {UnsupportedArgumentError} when an override asks for something unrepresentable.
|
|
21
|
+
*/
|
|
22
|
+
schemaToTableSpec: (
|
|
23
|
+
alias: string, schema: AnySchema | undefined, pgSchema: string, table: string,
|
|
24
|
+
autoSync: boolean, extraIndexes?: PgIndexSpec[]
|
|
25
|
+
) => TableSpec
|
|
26
|
+
}
|