@owlmeans/postgres-resource 0.1.15

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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/agent-meta/instructions/postgres-resource.instructions.md +60 -0
  4. package/agent-meta/manifest.json +23 -0
  5. package/agent-meta/skills/postgres-resource/SKILL.md +186 -0
  6. package/build/consts.d.ts +81 -0
  7. package/build/consts.d.ts.map +1 -0
  8. package/build/consts.js +86 -0
  9. package/build/consts.js.map +1 -0
  10. package/build/declarations.d.ts +12 -0
  11. package/build/declarations.d.ts.map +1 -0
  12. package/build/declarations.js +28 -0
  13. package/build/declarations.js.map +1 -0
  14. package/build/errors.d.ts +70 -0
  15. package/build/errors.d.ts.map +1 -0
  16. package/build/errors.js +201 -0
  17. package/build/errors.js.map +1 -0
  18. package/build/helper.d.ts +4 -0
  19. package/build/helper.d.ts.map +1 -0
  20. package/build/helper.js +5 -0
  21. package/build/helper.js.map +1 -0
  22. package/build/index.d.ts +8 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +7 -0
  25. package/build/index.js.map +1 -0
  26. package/build/resource.d.ts +4 -0
  27. package/build/resource.d.ts.map +1 -0
  28. package/build/resource.js +395 -0
  29. package/build/resource.js.map +1 -0
  30. package/build/types.d.ts +273 -0
  31. package/build/types.d.ts.map +1 -0
  32. package/build/types.js +2 -0
  33. package/build/types.js.map +1 -0
  34. package/build/utils/criteria.d.ts +24 -0
  35. package/build/utils/criteria.d.ts.map +1 -0
  36. package/build/utils/criteria.js +209 -0
  37. package/build/utils/criteria.js.map +1 -0
  38. package/build/utils/diff.d.ts +24 -0
  39. package/build/utils/diff.d.ts.map +1 -0
  40. package/build/utils/diff.js +330 -0
  41. package/build/utils/diff.js.map +1 -0
  42. package/build/utils/index.d.ts +12 -0
  43. package/build/utils/index.d.ts.map +1 -0
  44. package/build/utils/index.js +12 -0
  45. package/build/utils/index.js.map +1 -0
  46. package/build/utils/introspect.d.ts +7 -0
  47. package/build/utils/introspect.d.ts.map +1 -0
  48. package/build/utils/introspect.js +70 -0
  49. package/build/utils/introspect.js.map +1 -0
  50. package/build/utils/life-cycle.d.ts +39 -0
  51. package/build/utils/life-cycle.d.ts.map +1 -0
  52. package/build/utils/life-cycle.js +119 -0
  53. package/build/utils/life-cycle.js.map +1 -0
  54. package/build/utils/marshal.d.ts +36 -0
  55. package/build/utils/marshal.d.ts.map +1 -0
  56. package/build/utils/marshal.js +152 -0
  57. package/build/utils/marshal.js.map +1 -0
  58. package/build/utils/migrations.d.ts +16 -0
  59. package/build/utils/migrations.d.ts.map +1 -0
  60. package/build/utils/migrations.js +108 -0
  61. package/build/utils/migrations.js.map +1 -0
  62. package/build/utils/name.d.ts +39 -0
  63. package/build/utils/name.d.ts.map +1 -0
  64. package/build/utils/name.js +63 -0
  65. package/build/utils/name.js.map +1 -0
  66. package/build/utils/schema.d.ts +28 -0
  67. package/build/utils/schema.d.ts.map +1 -0
  68. package/build/utils/schema.js +356 -0
  69. package/build/utils/schema.js.map +1 -0
  70. package/build/utils/sql.d.ts +22 -0
  71. package/build/utils/sql.d.ts.map +1 -0
  72. package/build/utils/sql.js +0 -0
  73. package/build/utils/sql.js.map +1 -0
  74. package/build/utils/sync.d.ts +21 -0
  75. package/build/utils/sync.d.ts.map +1 -0
  76. package/build/utils/sync.js +93 -0
  77. package/build/utils/sync.js.map +1 -0
  78. package/build/utils/table.d.ts +8 -0
  79. package/build/utils/table.d.ts.map +1 -0
  80. package/build/utils/table.js +68 -0
  81. package/build/utils/table.js.map +1 -0
  82. package/package.json +50 -0
  83. package/src/consts.ts +95 -0
  84. package/src/declarations.ts +41 -0
  85. package/src/errors.ts +242 -0
  86. package/src/helper.ts +7 -0
  87. package/src/index.ts +7 -0
  88. package/src/resource.ts +524 -0
  89. package/src/types.ts +310 -0
  90. package/src/utils/criteria.ts +246 -0
  91. package/src/utils/diff.ts +363 -0
  92. package/src/utils/index.ts +11 -0
  93. package/src/utils/introspect.ts +87 -0
  94. package/src/utils/life-cycle.ts +155 -0
  95. package/src/utils/marshal.ts +175 -0
  96. package/src/utils/migrations.ts +135 -0
  97. package/src/utils/name.ts +79 -0
  98. package/src/utils/schema.ts +426 -0
  99. package/src/utils/sql.ts +0 -0
  100. package/src/utils/sync.ts +106 -0
  101. package/src/utils/table.ts +76 -0
  102. package/tests/errors.spec.ts +121 -0
  103. package/tests/name.spec.ts +75 -0
  104. package/tests/schema.spec.ts +258 -0
  105. package/tests/sql.spec.ts +104 -0
  106. package/tsconfig.json +16 -0
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@owlmeans/postgres-resource",
3
+ "version": "0.1.15",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "tsc -b",
8
+ "dev": "sleep 204 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
9
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
10
+ "test": "bun test ./tests"
11
+ },
12
+ "main": "build/index.js",
13
+ "module": "build/index.js",
14
+ "types": "build/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "import": "./build/index.js",
18
+ "require": "./build/index.js",
19
+ "default": "./build/index.js",
20
+ "module": "./build/index.js",
21
+ "types": "./build/index.d.ts"
22
+ }
23
+ },
24
+ "peerDependencies": {
25
+ "ajv": "*",
26
+ "pg": "*"
27
+ },
28
+ "dependencies": {
29
+ "@noble/hashes": "^1.5.0",
30
+ "@owlmeans/basic-ids": "^0.1.15",
31
+ "@owlmeans/context": "^0.1.15",
32
+ "@owlmeans/resource": "^0.1.15",
33
+ "@owlmeans/server-context": "^0.1.15",
34
+ "@scure/base": "^1.1.9",
35
+ "drizzle-orm": "~0.45.2"
36
+ },
37
+ "devDependencies": {
38
+ "@owlmeans/dep-config": "workspace:*",
39
+ "@owlmeans/test-integration": "^0.1.15",
40
+ "@types/bun": "^1.3.14",
41
+ "@types/node": "^26.1.0",
42
+ "@types/pg": "^8.20.4",
43
+ "nodemon": "^3.1.14",
44
+ "pg": "^8.22.0",
45
+ "typescript": "^6.0.3"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,95 @@
1
+
2
+ export const DEFAULT_DB_ALIAS = 'postgres'
3
+
4
+ export const DEFAULT_PAGE_SIZE = 10
5
+
6
+ /**
7
+ * Table that records which code-registered migrations have already been applied.
8
+ * Lives in the same Postgres schema as the resources it tracks, so dropping the
9
+ * schema drops the ledger with it.
10
+ */
11
+ export const DEF_MIGRATIONS_TABLE = '_owlmeans_migrations'
12
+
13
+ /** JSON Schema keyword carrying the Postgres specific overrides. */
14
+ export const PG_KEYWORD = 'pg'
15
+
16
+ /** Postgres `NAMEDATALEN - 1`. Identifiers past this are silently truncated by the server. */
17
+ export const PG_MAX_IDENTIFIER = 63
18
+
19
+ /** Property name that carries the record identity across every OwlMeans resource. */
20
+ export const ID_FIELD = 'id'
21
+
22
+ /** Fallback when a scalar property can't be mapped to anything more specific. */
23
+ export const DEF_SQL_TYPE = 'text'
24
+
25
+ export const DEF_JSON_TYPE = 'jsonb'
26
+
27
+ /** Server side identity default for the implicit `id` primary key. */
28
+ export const DEF_ID_DEFAULT = 'gen_random_uuid()::text'
29
+
30
+ /**
31
+ * How far structure reconciliation is allowed to go.
32
+ *
33
+ * `additive` is the adoption path for a table this package didn't create: it adds what's
34
+ * missing but never retypes and never drops, so a first boot against an existing schema
35
+ * converges without touching data. Flip to `full` once the plan comes out empty.
36
+ */
37
+ export enum PgAutoSync {
38
+ Full = 'full',
39
+ Additive = 'additive',
40
+ Off = 'off'
41
+ }
42
+
43
+ export enum PgIndexMethod {
44
+ BTree = 'btree',
45
+ Hash = 'hash',
46
+ Gin = 'gin',
47
+ Gist = 'gist',
48
+ Brin = 'brin',
49
+ SpGist = 'spgist'
50
+ }
51
+
52
+ export enum PgReferentialAction {
53
+ NoAction = 'no action',
54
+ Restrict = 'restrict',
55
+ Cascade = 'cascade',
56
+ SetNull = 'set null',
57
+ SetDefault = 'set default'
58
+ }
59
+
60
+ /**
61
+ * Postgres error codes the resource layer translates into framework errors. Sourced
62
+ * from the `pg` driver's `DatabaseError.code`.
63
+ */
64
+ export enum PgErrorCode {
65
+ UniqueViolation = '23505',
66
+ ForeignKeyViolation = '23503',
67
+ NotNullViolation = '23502',
68
+ CheckViolation = '23514',
69
+ UndefinedTable = '42P01',
70
+ UndefinedColumn = '42703',
71
+ DuplicateTable = '42P07',
72
+ DuplicateColumn = '42701',
73
+ DuplicateObject = '42710',
74
+ CannotCoerce = '42846',
75
+ DatatypeMismatch = '42804',
76
+ InvalidTextRepresentation = '22P02',
77
+ StringDataRightTruncation = '22001',
78
+ NumericValueOutOfRange = '22003',
79
+ SerializationFailure = '40001',
80
+ DeadlockDetected = '40P01',
81
+ NoActiveTransaction = '25001'
82
+ }
83
+
84
+ /**
85
+ * Postgres reports scalar values of these types as strings to avoid precision loss.
86
+ * The marshaller converts them back using the resource's schema.
87
+ */
88
+ export const STRING_RETURNING_TYPES = ['numeric', 'bigint', 'int8', 'decimal', 'money'] as const
89
+
90
+ /** Built-in type OIDs the marshaller has to name to reach the driver's own parsers. */
91
+ export enum PgTypeOid {
92
+ Date = 1082,
93
+ Timestamp = 1114,
94
+ TimestampTz = 1184
95
+ }
@@ -0,0 +1,41 @@
1
+ import { createMigrationRegistry } from '@owlmeans/resource'
2
+ import type { MigrationRegistry } from '@owlmeans/resource'
3
+ import type { AnySchema } from 'ajv'
4
+
5
+ import type { PgIndexSpec, PostgresTx } from './types.js'
6
+
7
+ export interface PostgresDeclaration {
8
+ schema?: AnySchema
9
+ indexes: PgIndexSpec[]
10
+ migrations: MigrationRegistry<PostgresTx>
11
+ }
12
+
13
+ /**
14
+ * Per-alias declaration store, held at module scope rather than on the resource object.
15
+ *
16
+ * `reinitializeContext` rebuilds every resource, which drops anything a caller attached
17
+ * by chaining. Mongo lives with that by requiring the app to pass `makeCustomResource`
18
+ * and re-run the whole maker; migrations can't depend on that discipline, because losing
19
+ * one silently means a data transformation never runs. Keying the declarations by alias
20
+ * makes them survive any number of context switches.
21
+ */
22
+ const declarations: Map<string, PostgresDeclaration> = new Map()
23
+
24
+ export const getDeclaration = (alias: string): PostgresDeclaration => {
25
+ let declaration = declarations.get(alias)
26
+ if (declaration == null) {
27
+ declaration = { indexes: [], migrations: createMigrationRegistry<PostgresTx>() }
28
+ declarations.set(alias, declaration)
29
+ }
30
+
31
+ return declaration
32
+ }
33
+
34
+ /** Testing seam — drops every declaration so a spec can redeclare a resource from scratch. */
35
+ export const resetDeclarations = (alias?: string): void => {
36
+ if (alias == null) {
37
+ declarations.clear()
38
+ return
39
+ }
40
+ declarations.delete(alias)
41
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,242 @@
1
+ import { ResilientError } from '@owlmeans/error'
2
+ import {
3
+ MisshapedRecord, RecordExists, ResourceError
4
+ } from '@owlmeans/resource'
5
+
6
+ import { PgErrorCode } from './consts.js'
7
+
8
+ export class PostgresError extends ResourceError {
9
+ public static override typeName = `${ResourceError.typeName}PostgresError`
10
+
11
+ constructor(message: string = 'error') {
12
+ super(`postgres:${message}`)
13
+ this.type = PostgresError.typeName
14
+ }
15
+ }
16
+
17
+ /** Structure reconciliation failed. The message carries the offending statement. */
18
+ export class PostgresSyncError extends PostgresError {
19
+ public static override typeName = `${PostgresError.typeName}SyncError`
20
+
21
+ constructor(message: string) {
22
+ super(`sync:${message}`)
23
+ this.type = PostgresSyncError.typeName
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Postgres refused to cast a column to its new type. Fix it with `pg: { using: '<expr>' }`
29
+ * on the property, or with a `pre` migration that performs the ALTER by hand — after
30
+ * which reconciliation sees no drift and emits nothing.
31
+ */
32
+ export class PostgresCastRequired extends PostgresSyncError {
33
+ public static override typeName = `${PostgresSyncError.typeName}CastRequired`
34
+
35
+ constructor(message: string) {
36
+ super(`cast-required:${message}`)
37
+ this.type = PostgresCastRequired.typeName
38
+ }
39
+ }
40
+
41
+ export class PostgresConstraintError extends PostgresError {
42
+ public static override typeName = `${PostgresError.typeName}ConstraintError`
43
+
44
+ constructor(message: string) {
45
+ super(`constraint:${message}`)
46
+ this.type = PostgresConstraintError.typeName
47
+ }
48
+ }
49
+
50
+ export class PostgresForeignKeyError extends PostgresConstraintError {
51
+ public static override typeName = `${PostgresConstraintError.typeName}ForeignKey`
52
+
53
+ constructor(message: string) {
54
+ super(`foreign-key:${message}`)
55
+ this.type = PostgresForeignKeyError.typeName
56
+ }
57
+ }
58
+
59
+ export class PostgresCheckError extends PostgresConstraintError {
60
+ public static override typeName = `${PostgresConstraintError.typeName}Check`
61
+
62
+ constructor(message: string) {
63
+ super(`check:${message}`)
64
+ this.type = PostgresCheckError.typeName
65
+ }
66
+ }
67
+
68
+ /** Serialization failure or deadlock — the caller may retry the whole transaction. */
69
+ export class PostgresDeadlockError extends PostgresError {
70
+ public static override typeName = `${PostgresError.typeName}DeadlockError`
71
+
72
+ public readonly retryable: boolean = true
73
+
74
+ constructor(message: string) {
75
+ super(`deadlock:${message}`)
76
+ this.type = PostgresDeadlockError.typeName
77
+ }
78
+ }
79
+
80
+ /** A `{{alias}}` placeholder in custom SQL couldn't be resolved. */
81
+ export class PostgresPlaceholderError extends PostgresError {
82
+ public static override typeName = `${PostgresError.typeName}PlaceholderError`
83
+
84
+ constructor(message: string) {
85
+ super(`placeholder:${message}`)
86
+ this.type = PostgresPlaceholderError.typeName
87
+ }
88
+ }
89
+
90
+ export class PostgresConnectionError extends PostgresError {
91
+ public static override typeName = `${PostgresError.typeName}ConnectionError`
92
+
93
+ constructor(message: string) {
94
+ super(`connection:${message}`)
95
+ this.type = PostgresConnectionError.typeName
96
+ }
97
+ }
98
+
99
+ /** The least-privilege admin path failed. */
100
+ export class PostgresBootstrapError extends PostgresError {
101
+ public static override typeName = `${PostgresError.typeName}BootstrapError`
102
+
103
+ constructor(message: string) {
104
+ super(`bootstrap:${message}`)
105
+ this.type = PostgresBootstrapError.typeName
106
+ }
107
+ }
108
+
109
+ interface PgDriverError {
110
+ code?: string
111
+ detail?: string
112
+ hint?: string
113
+ severity?: string
114
+ constraint?: string
115
+ table?: string
116
+ column?: string
117
+ message?: string
118
+ }
119
+
120
+ const isDriverError = (error: unknown): error is PgDriverError =>
121
+ error != null && typeof error === 'object' && 'code' in error
122
+
123
+ /**
124
+ * Reach the `pg` error inside whatever wrapped it.
125
+ *
126
+ * Drizzle raises `DrizzleQueryError` and hangs the driver error off `cause`, so a unique
127
+ * violation arriving through the query builder carries no `code` at the top level. Reading
128
+ * only the outer error is what makes the whole vocabulary below collapse to one opaque
129
+ * class on every CRUD path — and it is exactly the paths that go through Drizzle.
130
+ *
131
+ * The walk is bounded because `cause` chains can be circular.
132
+ */
133
+ const unwrap = (error: unknown): unknown => {
134
+ let current = error
135
+ for (let depth = 0; depth < 8; depth += 1) {
136
+ if (isDriverError(current)) {
137
+ return current
138
+ }
139
+ const cause = (current as { cause?: unknown } | null)?.cause
140
+ if (cause == null || cause === current) {
141
+ return error
142
+ }
143
+ current = cause
144
+ }
145
+
146
+ return error
147
+ }
148
+
149
+ /**
150
+ * The framework's error marshalling preserves only type, message and stack, so the
151
+ * Postgres diagnostics have to travel inside the message. Downstream tooling — notably
152
+ * the viable-agent fixer, which retries on `42P01` / `42703` — classifies on exactly
153
+ * this text, so the code always leads.
154
+ *
155
+ * `where` is deliberately never included: it can echo bound parameter values.
156
+ *
157
+ * Exported so a wrapper can quote the diagnostics without also quoting the translated error's
158
+ * own type prefix, which would nest one error vocabulary inside another's message.
159
+ */
160
+ export const describePgError = (error: unknown): string => {
161
+ const driver = unwrap(error)
162
+ if (!isDriverError(driver)) {
163
+ return error instanceof Error ? error.message : `${error}`
164
+ }
165
+
166
+ return describe(driver)
167
+ }
168
+
169
+ const describe = (error: PgDriverError): string => {
170
+ const parts = [error.code ?? 'unknown']
171
+ if (error.message != null) parts.push(error.message)
172
+ if (error.constraint != null) parts.push(`constraint=${error.constraint}`)
173
+ if (error.table != null) parts.push(`table=${error.table}`)
174
+ if (error.column != null) parts.push(`column=${error.column}`)
175
+ if (error.detail != null) parts.push(`detail=${error.detail}`)
176
+ if (error.hint != null) parts.push(`hint=${error.hint}`)
177
+ if (error.severity != null) parts.push(`severity=${error.severity}`)
178
+
179
+ return parts.join(' ')
180
+ }
181
+
182
+ /**
183
+ * Translate a raw `pg` driver error into the framework's error vocabulary, so a driver
184
+ * type never escapes the package. Errors that already are `ResilientError`s pass through
185
+ * untouched — the reconciler and the migration runner raise their own.
186
+ */
187
+ export const pgErrorToResourceError = (error: unknown): Error => {
188
+ if (error instanceof ResilientError) {
189
+ return error
190
+ }
191
+ const driver = unwrap(error)
192
+ if (!isDriverError(driver)) {
193
+ return error instanceof Error ? error : new PostgresError(`${error}`)
194
+ }
195
+
196
+ const message = describe(driver)
197
+ const produce = (): Error => {
198
+ switch (driver.code) {
199
+ case PgErrorCode.UniqueViolation:
200
+ return new RecordExists(message)
201
+ case PgErrorCode.NotNullViolation:
202
+ return new MisshapedRecord(message)
203
+ case PgErrorCode.ForeignKeyViolation:
204
+ return new PostgresForeignKeyError(message)
205
+ case PgErrorCode.CheckViolation:
206
+ return new PostgresCheckError(message)
207
+ case PgErrorCode.DatatypeMismatch:
208
+ case PgErrorCode.CannotCoerce:
209
+ return new PostgresCastRequired(message)
210
+ case PgErrorCode.SerializationFailure:
211
+ case PgErrorCode.DeadlockDetected:
212
+ return new PostgresDeadlockError(message)
213
+ default:
214
+ break
215
+ }
216
+ if (driver.code != null && driver.code.startsWith('08')) {
217
+ return new PostgresConnectionError(message)
218
+ }
219
+ if (driver.code != null && driver.code.startsWith('23')) {
220
+ return new PostgresConstraintError(message)
221
+ }
222
+
223
+ return new PostgresError(message)
224
+ }
225
+
226
+ const produced = produce()
227
+ /** The *original* error, wrapper and all — unwrapping is for classification, not for loss. */
228
+ produced.cause = error
229
+
230
+ return produced
231
+ }
232
+
233
+ ResilientError.registerErrorClass(PostgresError)
234
+ ResilientError.registerErrorClass(PostgresSyncError)
235
+ ResilientError.registerErrorClass(PostgresCastRequired)
236
+ ResilientError.registerErrorClass(PostgresConstraintError)
237
+ ResilientError.registerErrorClass(PostgresForeignKeyError)
238
+ ResilientError.registerErrorClass(PostgresCheckError)
239
+ ResilientError.registerErrorClass(PostgresDeadlockError)
240
+ ResilientError.registerErrorClass(PostgresPlaceholderError)
241
+ ResilientError.registerErrorClass(PostgresConnectionError)
242
+ ResilientError.registerErrorClass(PostgresBootstrapError)
package/src/helper.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { AnySchema, JSONSchemaType } from 'ajv'
2
+
3
+ /** Properties marked `secure: true` — the fields `lock()`/`unlock()` operate on by default. */
4
+ export const getSchemaSecureFeilds = (schema: AnySchema): string[] =>
5
+ Object.entries((schema as JSONSchemaType<any>)?.properties ?? {})
6
+ .filter(([, property]) => (property as { secure?: boolean }).secure === true)
7
+ .map(([key]) => key)
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type * from './types.js'
2
+ export * from './consts.js'
3
+ export * from './declarations.js'
4
+ export * from './errors.js'
5
+ export * from './helper.js'
6
+ export * from './resource.js'
7
+ export * from './utils/index.js'