@di-framework/bindings 6.0.7 → 6.0.8

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 CHANGED
@@ -46,6 +46,55 @@ connection and the selected database; multiple independently credentialed Postgr
46
46
  bindings are not supported by this QuickJS path yet. Guests initialize before the
47
47
  application, including bindings constructed at module startup.
48
48
 
49
+ ## Transaction semantics
50
+
51
+ `wasmcloud:postgres@0.2.0` has `query` and `queryBatch`, and no transaction handle. Each call goes
52
+ to whichever pooled connection is free. A named import (`user-database-query`) is served from a
53
+ per-credential pool with no connection affinity, so a `BEGIN` in one call does not cover the next
54
+ statement. A `COMMIT` can land on a connection that never saw the `BEGIN`, and that connection
55
+ stays `idle in transaction`.
56
+
57
+ Guests that use `@di-framework/repo` open the binding with `openPostgresDatabase` from
58
+ `@di-framework/bindings/postgres`. The handle is an `AutocommitDatabase`. Every statement is its
59
+ own transaction, and `transaction(fn)` runs `fn` on that same handle. The adapter never sends
60
+ `BEGIN`, `COMMIT`, or `ROLLBACK`. `FOR UPDATE` and advisory locks last only for the statement
61
+ that carries them.
62
+
63
+ Keep an invariant in one statement:
64
+
65
+ - a conditional `UPDATE … RETURNING`
66
+ - a data-modifying CTE
67
+ - `INSERT … SELECT … WHERE NOT EXISTS`
68
+
69
+ `run` sets `changes` from the rows returned. The provider does not report a command tag, so a
70
+ count is a `RETURNING` list. A `changes` value of `0` means no rows came back, which is what an
71
+ `UPDATE` or `DELETE` without `RETURNING` reports even when Postgres modified rows.
72
+
73
+ `exec` sends its script as one `queryBatch`. The host runs that string on one pooled client, and
74
+ Postgres treats a multi-statement simple query as one implicit transaction.
75
+
76
+ `atomicBatch` is for a write-only sequence that must commit or roll back together and that does
77
+ not read between statements. It renders each parameter as a literal (quotes doubled, a negative
78
+ number parenthesized so it cannot form a `--` comment) and submits that script as one
79
+ `queryBatch`. The script has no `BEGIN` or `COMMIT`. Postgres runs a multi-statement simple
80
+ query as one implicit transaction and rolls it back on error, returning the pooled client idle.
81
+ wasmCloud v2.8.0 runs that string on one pooled client:
82
+ `crates/wash-runtime/src/plugin/wasmcloud_postgres/async_p3.rs` checks out a single client in
83
+ `query_batch` and calls `client.batch_execute` (`batch_with_client`, line 191).
84
+
85
+ ```ts
86
+ import { openPostgresDatabase } from '@di-framework/bindings/postgres';
87
+
88
+ const db = openPostgresDatabase(database);
89
+ await db.atomicBatch([
90
+ { sql: 'INSERT INTO t (name, n) VALUES (?, ?)', params: ["o'brien", -1] },
91
+ ]);
92
+ ```
93
+
94
+ `@di-framework/repo` is an optional peer of this package. The main `@di-framework/bindings` entry
95
+ leaves it unloaded. Import `@di-framework/bindings/postgres` from a guest that depends on
96
+ `@di-framework/repo`.
97
+
49
98
  Secret material is referenced, never inlined:
50
99
 
51
100
  ```ts
@@ -0,0 +1,69 @@
1
+ import type { SqlDatabase } from '@di-framework/repo';
2
+ import type { PostgresGuest } from '../bindings/postgres.js';
3
+ import { type AtomicStatement } from './atomic-batch.js';
4
+ /**
5
+ * `SqlDatabase` over `wasmcloud:postgres@0.2.0` where every statement is its own transaction.
6
+ *
7
+ * The binding has no connection or transaction handle, and a named import has no connection
8
+ * affinity: each `query` and `queryBatch` may run on any pooled connection. A `BEGIN` sent on
9
+ * one call does not cover the next. This handle never sends `BEGIN`, `COMMIT`, or `ROLLBACK`.
10
+ * `transaction(fn)` runs `fn` on this same handle. `FOR UPDATE` and advisory locks last only
11
+ * for their statement.
12
+ *
13
+ * Keep an invariant in one statement:
14
+ *
15
+ * - conditional `UPDATE … RETURNING`
16
+ * - a data-modifying CTE
17
+ * - `INSERT … SELECT … WHERE NOT EXISTS`
18
+ *
19
+ * `run` reports `changes` as the number of rows returned. The provider does not report a
20
+ * command tag, so a count comes from `RETURNING`.
21
+ *
22
+ * A multi-statement `exec` script is still one `queryBatch`. The host runs that string with
23
+ * `batch_execute` on a single pooled client, so Postgres treats it as one implicit transaction.
24
+ */
25
+ export interface AutocommitDatabase extends SqlDatabase {
26
+ /**
27
+ * Writes `statements` as one transaction and returns no rows.
28
+ *
29
+ * Parameters are rendered as SQL literals and the text is submitted as a single
30
+ * `queryBatch` string. The string has no `BEGIN`, `COMMIT`, or `ROLLBACK`.
31
+ * Postgres treats that multi-statement simple query as one implicit transaction
32
+ * and rolls it back on error, so the pooled client returns idle. A quote is
33
+ * doubled. A negative number is parenthesized so it cannot form a `--` comment.
34
+ * Do not use this when a later statement must read an earlier one.
35
+ *
36
+ * The whole string runs on one pooled client. wasmCloud v2.8.0 (commit
37
+ * `5c4ec4a3d008b3f401d9e763515f434deebc9936`, the wash revision this platform
38
+ * pins) implements `query_batch` in
39
+ * `crates/wash-runtime/src/plugin/wasmcloud_postgres/async_p3.rs`. The default
40
+ * import checks out one client (`query_batch`, line 331) and `batch_with_client`
41
+ * (line 190) calls `client.batch_execute` with that string (line 191). The named
42
+ * import does the same (`query_batch`, line 407; `id.client()`, line 412;
43
+ * `batch_with_client`, line 416). `crates/provider-sqldb-postgres` is not in that
44
+ * tag. The invocation-lease patch does not split this string, so it still
45
+ * reaches one `batch_execute`.
46
+ */
47
+ atomicBatch(statements: readonly AtomicStatement[]): Promise<void>;
48
+ }
49
+ /**
50
+ * Opens `binding` as an {@link AutocommitDatabase}.
51
+ *
52
+ * `wasmcloud:postgres@0.2.0` has no connection or transaction handle: each `query` and
53
+ * `query-batch` call may run on any pooled connection of the host, so a `BEGIN` sent in one call
54
+ * does not cover the next. The guest therefore never opens a transaction. Every statement
55
+ * commits on its own, `transaction(fn)` runs `fn` on this same handle, and `FOR UPDATE` and
56
+ * advisory locks last only for their statement. Keep invariants in one statement (conditional
57
+ * `UPDATE … RETURNING`, data-modifying CTEs, `INSERT … SELECT … WHERE NOT EXISTS`). A
58
+ * multi-statement `exec` script still runs as one `query-batch`, which Postgres executes as one
59
+ * implicit transaction on one connection.
60
+ *
61
+ * `run`, `query`, and `first` call `query`. `exec` calls `queryBatch`. Failures keep a
62
+ * five-digit SQLSTATE on `error.code`.
63
+ */
64
+ export declare function openPostgresDatabase(binding: PostgresGuest): AutocommitDatabase;
65
+ /**
66
+ * {@link AutocommitDatabase.atomicBatch} for a `PostgresGuest` that is not wrapped
67
+ * as a `SqlDatabase`.
68
+ */
69
+ export declare function atomicBatch(binding: PostgresGuest, statements: readonly AtomicStatement[]): Promise<void>;
@@ -0,0 +1,77 @@
1
+ import { assertBatch, postgresError, readRows } from '@di-framework/repo/postgres';
2
+ import { renderAtomicBatch } from './atomic-batch.js';
3
+ import { bindParams } from './bind-params.js';
4
+ /**
5
+ * Opens `binding` as an {@link AutocommitDatabase}.
6
+ *
7
+ * `wasmcloud:postgres@0.2.0` has no connection or transaction handle: each `query` and
8
+ * `query-batch` call may run on any pooled connection of the host, so a `BEGIN` sent in one call
9
+ * does not cover the next. The guest therefore never opens a transaction. Every statement
10
+ * commits on its own, `transaction(fn)` runs `fn` on this same handle, and `FOR UPDATE` and
11
+ * advisory locks last only for their statement. Keep invariants in one statement (conditional
12
+ * `UPDATE … RETURNING`, data-modifying CTEs, `INSERT … SELECT … WHERE NOT EXISTS`). A
13
+ * multi-statement `exec` script still runs as one `query-batch`, which Postgres executes as one
14
+ * implicit transaction on one connection.
15
+ *
16
+ * `run`, `query`, and `first` call `query`. `exec` calls `queryBatch`. Failures keep a
17
+ * five-digit SQLSTATE on `error.code`.
18
+ */
19
+ export function openPostgresDatabase(binding) {
20
+ const handle = {
21
+ async run(sql, params = []) {
22
+ const rows = await statements(binding, sql, params);
23
+ return { changes: rows.length };
24
+ },
25
+ async query(sql, params = []) {
26
+ return (await statements(binding, sql, params));
27
+ },
28
+ async first(sql, params = []) {
29
+ const rows = await statements(binding, sql, params);
30
+ return (rows[0] ?? null);
31
+ },
32
+ async exec(sql) {
33
+ await submitBatch(binding, sql);
34
+ },
35
+ transaction(fn) {
36
+ return fn(handle);
37
+ },
38
+ atomicBatch(statements) {
39
+ return submitAtomicBatch(binding, statements);
40
+ },
41
+ };
42
+ return handle;
43
+ }
44
+ /**
45
+ * {@link AutocommitDatabase.atomicBatch} for a `PostgresGuest` that is not wrapped
46
+ * as a `SqlDatabase`.
47
+ */
48
+ export async function atomicBatch(binding, statements) {
49
+ await submitAtomicBatch(binding, statements);
50
+ }
51
+ async function submitAtomicBatch(binding, statements) {
52
+ await submitBatch(binding, renderAtomicBatch(statements));
53
+ }
54
+ async function statements(binding, sql, params) {
55
+ const bound = bindParams(sql, params);
56
+ try {
57
+ return await readRows(await binding.query(bound.text, bound.params));
58
+ }
59
+ catch (error) {
60
+ throw coded(error);
61
+ }
62
+ }
63
+ async function submitBatch(binding, sql) {
64
+ try {
65
+ assertBatch(await binding.queryBatch(sql));
66
+ }
67
+ catch (error) {
68
+ throw coded(error);
69
+ }
70
+ }
71
+ function coded(error) {
72
+ const wrapped = (error instanceof Error && error.message.startsWith('PostgreSQL') ? error : postgresError(error));
73
+ const code = /PostgreSQL (\d{5})/.exec(wrapped.message)?.[1];
74
+ if (code !== undefined)
75
+ wrapped.code = code;
76
+ return wrapped;
77
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * One write statement inside {@link renderAtomicBatch}.
3
+ * `?` is a value placeholder. `queryBatch` takes no parameters, so each placeholder
4
+ * is rendered as a SQL literal before the batch is submitted.
5
+ */
6
+ export type AtomicStatement = {
7
+ sql: string;
8
+ params?: readonly unknown[];
9
+ };
10
+ /**
11
+ * Renders `statements` as one multi-statement simple query.
12
+ *
13
+ * There is no `BEGIN` or `COMMIT`. Postgres runs the string as one implicit
14
+ * transaction and rolls it back on error, returning the pooled client idle.
15
+ * An explicit `BEGIN` whose `COMMIT` is skipped would leave that client
16
+ * `idle in transaction (aborted)`.
17
+ *
18
+ * Literals use standard SQL quotes (`standard_conforming_strings`): a quote is
19
+ * doubled, and a backslash is not an escape. A negative number is parenthesized
20
+ * so `x-?` with `-1` becomes `x-(-1)` and cannot form a `--` comment. Strings
21
+ * that match a UUID are quoted too; the hex pattern cannot close the quote.
22
+ */
23
+ export declare function renderAtomicBatch(statements: readonly AtomicStatement[]): string;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Renders `statements` as one multi-statement simple query.
3
+ *
4
+ * There is no `BEGIN` or `COMMIT`. Postgres runs the string as one implicit
5
+ * transaction and rolls it back on error, returning the pooled client idle.
6
+ * An explicit `BEGIN` whose `COMMIT` is skipped would leave that client
7
+ * `idle in transaction (aborted)`.
8
+ *
9
+ * Literals use standard SQL quotes (`standard_conforming_strings`): a quote is
10
+ * doubled, and a backslash is not an escape. A negative number is parenthesized
11
+ * so `x-?` with `-1` becomes `x-(-1)` and cannot form a `--` comment. Strings
12
+ * that match a UUID are quoted too; the hex pattern cannot close the quote.
13
+ */
14
+ export function renderAtomicBatch(statements) {
15
+ if (statements.length === 0)
16
+ throw new Error('atomicBatch requires at least one statement');
17
+ const rendered = statements.map((statement) => renderStatement(statement.sql, statement.params ?? []));
18
+ // No BEGIN/COMMIT: a multi-statement simple query is one implicit transaction
19
+ // that Postgres rolls back on error without leaving the connection aborted.
20
+ return `${rendered.join(';\n')};`;
21
+ }
22
+ function renderStatement(sql, params) {
23
+ const body = sql
24
+ .trim()
25
+ .replace(/;+\s*$/u, '')
26
+ .trim();
27
+ if (body.length === 0)
28
+ throw new Error('atomicBatch statement is empty');
29
+ let index = 0;
30
+ const rendered = body.replaceAll('?', () => {
31
+ const value = params[index++];
32
+ return sqlLiteral(value);
33
+ });
34
+ if (index !== params.length) {
35
+ throw new Error(`SQL placeholder count ${index} does not match ${params.length} parameters`);
36
+ }
37
+ return rendered;
38
+ }
39
+ function sqlLiteral(value) {
40
+ if (value == null)
41
+ return 'NULL';
42
+ if (typeof value === 'boolean')
43
+ return value ? 'true' : 'false';
44
+ if (typeof value === 'string')
45
+ return quote(value);
46
+ if (typeof value === 'bigint')
47
+ return integerLiteral(value);
48
+ if (typeof value === 'number')
49
+ return numberLiteral(value);
50
+ if (value instanceof Date)
51
+ return timestampLiteral(value);
52
+ if (value instanceof Uint8Array)
53
+ return byteaLiteral(value);
54
+ if (typeof value === 'object')
55
+ return jsonLiteral(value);
56
+ return unrenderable();
57
+ }
58
+ function quote(value) {
59
+ if (value.includes('\0'))
60
+ throw new Error('PostgreSQL text parameter contains a null byte');
61
+ return `'${value.replaceAll("'", "''")}'`;
62
+ }
63
+ function integerLiteral(value) {
64
+ return value < 0n ? `(${value})` : `${value}`;
65
+ }
66
+ function numberLiteral(value) {
67
+ if (!Number.isFinite(value))
68
+ throw new Error('PostgreSQL parameter is not a finite number');
69
+ if (Number.isInteger(value)) {
70
+ if (!Number.isSafeInteger(value)) {
71
+ throw new Error('PostgreSQL int8 parameter is not an exact integer');
72
+ }
73
+ return integerLiteral(BigInt(value));
74
+ }
75
+ const text = String(value);
76
+ if (text.startsWith('-'))
77
+ return `(${text})`;
78
+ return text;
79
+ }
80
+ function timestampLiteral(value) {
81
+ if (Number.isNaN(value.getTime())) {
82
+ throw new Error('PostgreSQL timestamp-tz parameter has the wrong type');
83
+ }
84
+ return quote(value.toISOString());
85
+ }
86
+ function byteaLiteral(value) {
87
+ const hex = [...value].map((byte) => byte.toString(16).padStart(2, '0')).join('');
88
+ return `decode('${hex}', 'hex')`;
89
+ }
90
+ function jsonLiteral(value) {
91
+ let json;
92
+ try {
93
+ json = JSON.stringify(value);
94
+ }
95
+ catch {
96
+ return unrenderable();
97
+ }
98
+ if (json === undefined)
99
+ return unrenderable();
100
+ return quote(json);
101
+ }
102
+ function unrenderable() {
103
+ throw new Error('PostgreSQL parameter cannot be rendered as a literal');
104
+ }
@@ -0,0 +1,17 @@
1
+ import { type PgParameter } from '@di-framework/repo/postgres';
2
+ /**
3
+ * Rewrites `@di-framework/repo` `?` placeholders for the wasmCloud Postgres binding.
4
+ *
5
+ * The host binds every parameter in binary as the type Postgres infers for its slot, and the
6
+ * guest never sees that type. A UUID string may fill a `uuid` column or a `varchar` one (the
7
+ * Spring Authorization Server tables), and its 16 binary bytes are invalid UTF-8 in a
8
+ * `varchar`. An integer may fill an `int4` slot or an `int8` one (`LIMIT`), and the widths
9
+ * differ. So UUID strings go inline as untyped literals and safe integers as numeric
10
+ * constants, which Postgres resolves from context. Neither can carry SQL: the UUID pattern
11
+ * admits only hex digits and dashes, and a negative integer is parenthesized so it cannot
12
+ * form a `--` comment.
13
+ */
14
+ export declare function bindParams(sql: string, params?: readonly unknown[]): {
15
+ text: string;
16
+ params: PgParameter[];
17
+ };
@@ -0,0 +1,44 @@
1
+ import { pgValue } from '@di-framework/repo/postgres';
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ /**
4
+ * Rewrites `@di-framework/repo` `?` placeholders for the wasmCloud Postgres binding.
5
+ *
6
+ * The host binds every parameter in binary as the type Postgres infers for its slot, and the
7
+ * guest never sees that type. A UUID string may fill a `uuid` column or a `varchar` one (the
8
+ * Spring Authorization Server tables), and its 16 binary bytes are invalid UTF-8 in a
9
+ * `varchar`. An integer may fill an `int4` slot or an `int8` one (`LIMIT`), and the widths
10
+ * differ. So UUID strings go inline as untyped literals and safe integers as numeric
11
+ * constants, which Postgres resolves from context. Neither can carry SQL: the UUID pattern
12
+ * admits only hex digits and dashes, and a negative integer is parenthesized so it cannot
13
+ * form a `--` comment.
14
+ */
15
+ export function bindParams(sql, params = []) {
16
+ if (params.length === 0)
17
+ return { text: sql, params: [] };
18
+ const bound = [];
19
+ let index = 0;
20
+ const text = sql.replaceAll('?', () => {
21
+ const value = params[index++];
22
+ const literal = inlineLiteral(value);
23
+ if (literal !== undefined)
24
+ return literal;
25
+ bound.push(pgValue(value));
26
+ return `$${bound.length}`;
27
+ });
28
+ if (index !== params.length) {
29
+ throw new Error(`SQL placeholder count ${index} does not match ${params.length} parameters`);
30
+ }
31
+ return { text, params: bound };
32
+ }
33
+ function inlineLiteral(value) {
34
+ if (typeof value === 'string')
35
+ return UUID.test(value) ? `'${value}'` : undefined;
36
+ const integer = typeof value === 'bigint'
37
+ ? value
38
+ : Number.isSafeInteger(value)
39
+ ? BigInt(value)
40
+ : undefined;
41
+ if (integer === undefined)
42
+ return undefined;
43
+ return integer < 0n ? `(${integer})` : `${integer}`;
44
+ }
@@ -0,0 +1,3 @@
1
+ export { type AutocommitDatabase, atomicBatch, openPostgresDatabase } from './adapter.js';
2
+ export type { AtomicStatement } from './atomic-batch.js';
3
+ export { bindParams } from './bind-params.js';
@@ -0,0 +1,2 @@
1
+ export { atomicBatch, openPostgresDatabase } from './adapter.js';
2
+ export { bindParams } from './bind-params.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@di-framework/bindings",
3
- "version": "6.0.7",
3
+ "version": "6.0.8",
4
4
  "description": "Native platform WIT service bindings for DI Framework applications.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -36,12 +36,24 @@
36
36
  "import": "./dist/index.js",
37
37
  "default": "./dist/index.js"
38
38
  },
39
+ "./postgres": {
40
+ "types": "./dist/postgres/index.d.ts",
41
+ "bun": "./dist/postgres/index.js",
42
+ "import": "./dist/postgres/index.js",
43
+ "default": "./dist/postgres/index.js"
44
+ },
39
45
  "./catalog.json": "./catalog.json"
40
46
  },
41
47
  "peerDependencies": {
42
48
  "@di-framework/core": "^5",
49
+ "@di-framework/repo": "^6.0.4",
43
50
  "typescript": "^5"
44
51
  },
52
+ "peerDependenciesMeta": {
53
+ "@di-framework/repo": {
54
+ "optional": true
55
+ }
56
+ },
45
57
  "files": [
46
58
  "dist",
47
59
  "catalog.json",
@@ -49,6 +61,7 @@
49
61
  ],
50
62
  "devDependencies": {
51
63
  "@di-framework/core": "^5",
64
+ "@di-framework/repo": "^6.0.4",
52
65
  "@types/bun": "latest"
53
66
  }
54
67
  }