@homeflare/alchemy 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +15 -1
  2. package/dist/litellm/client.d.ts +76 -0
  3. package/dist/litellm/client.d.ts.map +1 -0
  4. package/dist/litellm/credentials.d.ts +48 -0
  5. package/dist/litellm/credentials.d.ts.map +1 -0
  6. package/dist/litellm/fake-litellm.d.ts +36 -0
  7. package/dist/litellm/fake-litellm.d.ts.map +1 -0
  8. package/dist/litellm/fake-stack.d.ts +30 -0
  9. package/dist/litellm/fake-stack.d.ts.map +1 -0
  10. package/dist/litellm/generated/pass-through.d.ts +71 -0
  11. package/dist/litellm/generated/pass-through.d.ts.map +1 -0
  12. package/dist/litellm/index.d.ts +13 -0
  13. package/dist/litellm/index.d.ts.map +1 -0
  14. package/dist/litellm/index.js +270 -0
  15. package/dist/litellm/index.js.map +14 -0
  16. package/dist/litellm/pass-through-endpoint.d.ts +94 -0
  17. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
  18. package/dist/litellm/pass-through-form.d.ts +40 -0
  19. package/dist/litellm/pass-through-form.d.ts.map +1 -0
  20. package/dist/litellm/providers.d.ts +16 -0
  21. package/dist/litellm/providers.d.ts.map +1 -0
  22. package/dist/postgres/connection.d.ts +50 -0
  23. package/dist/postgres/connection.d.ts.map +1 -0
  24. package/dist/postgres/database-attrs.d.ts +79 -0
  25. package/dist/postgres/database-attrs.d.ts.map +1 -0
  26. package/dist/postgres/database-sql.d.ts +73 -0
  27. package/dist/postgres/database-sql.d.ts.map +1 -0
  28. package/dist/postgres/database.d.ts +63 -0
  29. package/dist/postgres/database.d.ts.map +1 -0
  30. package/dist/postgres/errors.d.ts +86 -0
  31. package/dist/postgres/errors.d.ts.map +1 -0
  32. package/dist/postgres/fake-sql.d.ts +22 -0
  33. package/dist/postgres/fake-sql.d.ts.map +1 -0
  34. package/dist/postgres/index.d.ts +16 -0
  35. package/dist/postgres/index.d.ts.map +1 -0
  36. package/dist/postgres/index.js +238 -0
  37. package/dist/postgres/index.js.map +15 -0
  38. package/dist/postgres/providers.d.ts +14 -0
  39. package/dist/postgres/providers.d.ts.map +1 -0
  40. package/dist/proxmox/api-token-form.d.ts +12 -1
  41. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  42. package/dist/proxmox/api-token.d.ts +8 -3
  43. package/dist/proxmox/api-token.d.ts.map +1 -1
  44. package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts +13 -13
  45. package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts.map +1 -1
  46. package/dist/proxmox/index.d.ts +9 -2
  47. package/dist/proxmox/index.d.ts.map +1 -1
  48. package/dist/proxmox/index.js +17 -9
  49. package/dist/proxmox/index.js.map +8 -7
  50. package/dist/proxmox/zfs-pool-spec.d.ts +27 -0
  51. package/dist/proxmox/zfs-pool-spec.d.ts.map +1 -0
  52. package/dist/proxmox/zfs-pool-write.d.ts +48 -7
  53. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  54. package/dist/proxmox/zfs-pool.d.ts +52 -22
  55. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  56. package/dist/release/archive.d.ts +6 -2
  57. package/dist/release/archive.d.ts.map +1 -1
  58. package/dist/release/binary-form.d.ts +14 -9
  59. package/dist/release/binary-form.d.ts.map +1 -1
  60. package/dist/release/binary-pins.d.ts +17 -0
  61. package/dist/release/binary-pins.d.ts.map +1 -0
  62. package/dist/release/catalog.d.ts.map +1 -1
  63. package/dist/release/index.js +90 -40
  64. package/dist/release/index.js.map +8 -7
  65. package/dist/release/tar.d.ts +9 -2
  66. package/dist/release/tar.d.ts.map +1 -1
  67. package/docs/adopted-deploys.md +14 -0
  68. package/docs/litellm.md +110 -0
  69. package/docs/peers.md +8 -1
  70. package/docs/postgres.md +125 -0
  71. package/docs/release-binary-catalogs.md +40 -4
  72. package/docs/release-binary-refusals.md +22 -8
  73. package/docs/release-binary-upstream.md +33 -15
  74. package/package.json +10 -1
  75. package/src/litellm/client.ts +195 -0
  76. package/src/litellm/credentials.ts +73 -0
  77. package/src/litellm/fake-litellm.ts +114 -0
  78. package/src/litellm/fake-stack.ts +100 -0
  79. package/src/litellm/generated/pass-through.ts +73 -0
  80. package/src/litellm/index.ts +42 -0
  81. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
  82. package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
  83. package/src/litellm/pass-through-endpoint.test.ts +208 -0
  84. package/src/litellm/pass-through-endpoint.ts +258 -0
  85. package/src/litellm/pass-through-form.test.ts +128 -0
  86. package/src/litellm/pass-through-form.ts +136 -0
  87. package/src/litellm/providers.ts +19 -0
  88. package/src/postgres/connection.ts +62 -0
  89. package/src/postgres/database-attrs.ts +87 -0
  90. package/src/postgres/database-bigint-serialization.test.ts +140 -0
  91. package/src/postgres/database-lifecycle.test.ts +113 -0
  92. package/src/postgres/database-provenance.test.ts +118 -0
  93. package/src/postgres/database-refusals.test.ts +135 -0
  94. package/src/postgres/database-sql.ts +174 -0
  95. package/src/postgres/database.ts +193 -0
  96. package/src/postgres/errors.ts +131 -0
  97. package/src/postgres/fake-sql.ts +123 -0
  98. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
  99. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
  100. package/src/postgres/index.ts +30 -0
  101. package/src/postgres/providers.ts +16 -0
  102. package/src/proxmox/api-token-adopt.test.ts +138 -0
  103. package/src/proxmox/api-token-form.ts +19 -2
  104. package/src/proxmox/api-token.ts +8 -3
  105. package/src/proxmox/generated/pve/cluster-sdn-fabrics.ts +23 -13
  106. package/src/proxmox/index.test.ts +5 -2
  107. package/src/proxmox/index.ts +9 -2
  108. package/src/proxmox/zfs-pool-adopt.test.ts +141 -0
  109. package/src/proxmox/zfs-pool-spec.ts +76 -0
  110. package/src/proxmox/zfs-pool-write.ts +63 -9
  111. package/src/proxmox/zfs-pool.ts +55 -97
  112. package/src/release/archive.ts +10 -4
  113. package/src/release/binary-form.ts +24 -76
  114. package/src/release/binary-pins.ts +95 -0
  115. package/src/release/binary-root.test.ts +122 -0
  116. package/src/release/catalog.ts +10 -2
  117. package/src/release/tar-root.test.ts +114 -0
  118. package/src/release/tar.ts +78 -12
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The wire body for a `LiteLLM.PassThroughEndpoint`, the comparison that decides whether it needs
3
+ * writing, and the refusals that stop a plan before it writes anything unsafe.
4
+ *
5
+ * ★ EXTRACTED SO IT CAN BE TESTED WITHOUT A SERVER — the same reason netbox keeps `prefix-form.ts`
6
+ * beside `prefix.ts` (S32/testing-and-docs.md).
7
+ *
8
+ * ⛔ PROPS ARE THE GENERATED TYPE, MINUS THE TWO FIELDS THIS RESOURCE OWNS ITSELF: `id` (a
9
+ * deterministic physical name — pass-through-endpoint.ts) and `is_from_config` (response-only;
10
+ * the vendor sets it, a declaration never does). Deriving `Props` with `Omit<>` rather than
11
+ * hand-typing it means a vendor schema change that adds, removes or narrows a field is felt here
12
+ * as a compiler error, not silently ignored.
13
+ */
14
+ import { deepEqual } from 'alchemy/Diff';
15
+ import type * as Generated from './generated/pass-through.ts';
16
+
17
+ export type PassThroughEndpointProps = Omit<
18
+ Generated.PassThroughGenericEndpoint,
19
+ 'id' | 'is_from_config'
20
+ >;
21
+
22
+ /** What `read` returns: the props plus the two vendor-owned fields, `id` now guaranteed present. */
23
+ export type PassThroughEndpointAttributes = Omit<Generated.PassThroughGenericEndpoint, 'id'> & {
24
+ readonly id: string;
25
+ };
26
+
27
+ /** ⚠️ NetBox `settled()`'s pattern: only fields the SCHEMA gives a vendor default for, copied from it (not assumed). */
28
+ export const settled = (props: PassThroughEndpointProps) => ({
29
+ auth: props.auth ?? true,
30
+ cost_per_request: props.cost_per_request ?? 0,
31
+ default_query_params: props.default_query_params ?? {},
32
+ headers: props.headers ?? {},
33
+ include_subpath: props.include_subpath ?? false,
34
+ });
35
+
36
+ /** The three fields whose absence the vendor's `exclude_none` update can never write (see client.ts). */
37
+ export const NULLABLE_FIELDS = ['timeout', 'methods', 'guardrails'] as const;
38
+
39
+ const isUnset = (value: unknown): boolean => value === undefined || value === null;
40
+
41
+ /**
42
+ * ⚠️ BUILT AS A WIDER RECORD AND PRUNED, NOT AS THE GENERATED TYPE DIRECTLY.
43
+ * `exactOptionalPropertyTypes` is on (packages/config/tsconfig.base.json), so writing
44
+ * `timeout: undefined` into a `{ timeout?: number | null }` is a type error rather than an
45
+ * absent key — the same reason `codegen/openapi.ts`'s `prune` exists, and the same fix.
46
+ */
47
+ const prune = (value: Record<string, unknown>): Record<string, unknown> =>
48
+ Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
49
+
50
+ /**
51
+ * Full body for CREATE. `id` is the deterministic physical name (pass-through-endpoint.ts), sent
52
+ * so a failed create is recoverable by `read` rather than orphaning a second row on retry.
53
+ */
54
+ export const createBody = (
55
+ props: PassThroughEndpointProps,
56
+ id: string,
57
+ ): Generated.PassThroughGenericEndpoint =>
58
+ prune({
59
+ ...settled(props),
60
+ guardrails: isUnset(props.guardrails) ? undefined : props.guardrails,
61
+ id,
62
+ methods: isUnset(props.methods) ? undefined : props.methods,
63
+ path: props.path,
64
+ target: props.target,
65
+ timeout: isUnset(props.timeout) ? undefined : props.timeout,
66
+ }) as unknown as Generated.PassThroughGenericEndpoint;
67
+
68
+ /**
69
+ * Partial body for UPDATE (`POST /{id}`). ⛔ ONLY FIELDS THIS DECLARATION ACTUALLY SETS — the
70
+ * vendor merges the parsed body with `model_dump(exclude_none=True)` (measured at v1.100.0), so an
71
+ * `undefined` OR an explicit `null` here would be dropped by the server anyway; sending neither is
72
+ * the same outcome with no request made when nothing changed (`Object.keys(body).length === 0`
73
+ * checked by the caller in pass-through-endpoint.ts).
74
+ */
75
+ export const updateBody = (
76
+ props: PassThroughEndpointProps,
77
+ ): Partial<Generated.PassThroughGenericEndpoint> =>
78
+ prune({
79
+ ...settled(props),
80
+ guardrails: isUnset(props.guardrails) ? undefined : props.guardrails,
81
+ methods: isUnset(props.methods) ? undefined : props.methods,
82
+ path: props.path,
83
+ target: props.target,
84
+ timeout: isUnset(props.timeout) ? undefined : props.timeout,
85
+ }) as Partial<Generated.PassThroughGenericEndpoint>;
86
+
87
+ /**
88
+ * Whether ANY of the three nullable fields went from set (in `live`) to unset (in `props`) — the
89
+ * one case `updateBody` cannot express, because the vendor's `exclude_none` merge silently keeps
90
+ * the old value instead of clearing it. Detected here so `pass-through-endpoint.ts` can plan a
91
+ * `replace` (delete-then-create) instead of an update that would look like it worked.
92
+ */
93
+ export const needsReplace = (
94
+ live: PassThroughEndpointAttributes,
95
+ props: PassThroughEndpointProps,
96
+ ): boolean => NULLABLE_FIELDS.some((field) => !isUnset(live[field]) && isUnset(props[field]));
97
+
98
+ /** Whether the live object already says what the declaration says — the `diff` "noop" test. */
99
+ export const matches = (
100
+ live: PassThroughEndpointAttributes,
101
+ props: PassThroughEndpointProps,
102
+ ): boolean => {
103
+ const fixed = settled(props);
104
+ return (
105
+ live.target === props.target &&
106
+ live.auth === fixed.auth &&
107
+ live.cost_per_request === fixed.cost_per_request &&
108
+ live.include_subpath === fixed.include_subpath &&
109
+ deepEqual(live.headers, fixed.headers) &&
110
+ deepEqual(live.default_query_params, fixed.default_query_params) &&
111
+ (isUnset(props.timeout) ? isUnset(live.timeout) : live.timeout === props.timeout) &&
112
+ (isUnset(props.methods) ? isUnset(live.methods) : deepEqual(live.methods, props.methods)) &&
113
+ (isUnset(props.guardrails)
114
+ ? isUnset(live.guardrails)
115
+ : deepEqual(live.guardrails, props.guardrails))
116
+ );
117
+ };
118
+
119
+ /**
120
+ * A literal secret in a forwarded header (S25, house rule — not a vendor one; see
121
+ * pass-through-endpoint.ts). Case-insensitive on the header name; the reference form is LiteLLM's
122
+ * own, `os.environ/NAME` (`set_env_variables_in_header`, pass_through_endpoints.py:117-160 at the
123
+ * tag) — a missing variable on the proxy host leaves that literal string in the header, which
124
+ * nothing in the API can detect, so this is enforced here rather than trusted to the vendor.
125
+ */
126
+ const SECRET_HEADER_NAMES = new Set(['authorization', 'x-api-key', 'cf-aig-authorization']);
127
+
128
+ export const literalSecretHeaders = (
129
+ headers: Readonly<Record<string, unknown>> | undefined,
130
+ ): readonly string[] =>
131
+ Object.entries(headers ?? {})
132
+ .filter(
133
+ ([name, value]) =>
134
+ SECRET_HEADER_NAMES.has(name.toLowerCase()) && !String(value).includes('os.environ/'),
135
+ )
136
+ .map(([name]) => name);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The LiteLLM provider, with its credential source, as one layer for a stack.
3
+ *
4
+ * Layer.mergeAll(litellmProviders(), …the stack's other providers)
5
+ *
6
+ * ★ THE DEFAULT READS `LITELLM_PROXY_URL`/`LITELLM_PROXY_API_KEY` AT CALL TIME (credentials.ts),
7
+ * never at layer build — pass another `Layer.Layer<LitellmCredentials>` (see
8
+ * `litellmCredentialsLayerFor` in a test) to source them another way.
9
+ * ⚠️ `HttpClient` is NOT provided here. It is ambient in every Alchemy runtime the same way it is
10
+ * for `netboxProviders`-style families in this kit; a stack that already deploys anything over
11
+ * HTTP already has one.
12
+ */
13
+ import * as Layer from 'effect/Layer';
14
+ import { type LitellmCredentials, litellmCredentialsLayer } from './credentials.ts';
15
+ import { LiteLLMPassThroughEndpointProvider } from './pass-through-endpoint.ts';
16
+
17
+ export const litellmProviders = (
18
+ creds: Layer.Layer<LitellmCredentials> = litellmCredentialsLayer,
19
+ ) => LiteLLMPassThroughEndpointProvider().pipe(Layer.provide(creds));
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where a database's cluster comes from, and how each lifecycle operation gets a client.
3
+ *
4
+ * ⛔ NO ESTATE VALUE LIVES HERE. The kit is PUBLIC; the mini's socket path, its user and its
5
+ * database names are the CONSUMER's props, passed to `postgresConnection(...)` in a house
6
+ * stack — never a default, never a fallback (`docs/postgres.md#path`).
7
+ * ★ A LAZY `Context.Service` WHOSE VALUE IS AN `Effect` (S24), THE SAME SHAPE THE HOUSE USES FOR
8
+ * CREDENTIALS: `yield* yield* PostgresConnection` resolves the config INSIDE each operation,
9
+ * never at layer build. Nothing here holds a secret today (`PostgresConnectionConfig` has no
10
+ * `password` — S25, and CREATE DATABASE has none to assert either), but the shape is the one a
11
+ * later TLS path (`Config.redacted` from the environment) drops into without a rewrite.
12
+ * ★ THE POOL IS BUILT IN A SCOPE AND CLOSED PER OPERATION. `Effect.provide(op, PgClient.layer(config))`
13
+ * already opens a scope for the layer's build and closes it when `op` finishes — one pool per
14
+ * `withPg` call, never held across a whole `reconcile`.
15
+ */
16
+ import * as PgClient from '@effect/sql-pg/PgClient';
17
+ import * as Context from 'effect/Context';
18
+ import * as Effect from 'effect/Effect';
19
+ import * as Layer from 'effect/Layer';
20
+ import type { SqlError } from 'effect/unstable/sql/SqlError';
21
+
22
+ /** What a stack passes to reach one cluster. `host` may be a socket directory (one beginning
23
+ * with `/` expands to `${host}/.s.PGSQL.${port}` — measured in
24
+ * `node_modules/@effect/sql-pg/src/PgConnection.ts`, the "Details" doc on `Config`) or a TCP
25
+ * host name; `port` defaults to `@effect/sql-pg`'s own default (5432) when omitted. */
26
+ export interface PostgresConnectionConfig {
27
+ readonly host: string;
28
+ readonly port?: number;
29
+ readonly database: string;
30
+ readonly username: string;
31
+ readonly ssl?: boolean;
32
+ }
33
+
34
+ /** The lazy connection service. Its value is an `Effect` of the config, per S24 — see the file
35
+ * header for why. */
36
+ export class PostgresConnection extends Context.Service<
37
+ PostgresConnection,
38
+ Effect.Effect<PostgresConnectionConfig>
39
+ >()('Postgres.Connection') {}
40
+
41
+ /** A stack's one-line way to provide a cluster: `Layer.provide(postgresConnection({ … }))` on
42
+ * `PostgresDatabaseProvider()`, or merged into the stack's own layer tree. */
43
+ export const postgresConnection = (
44
+ config: PostgresConnectionConfig,
45
+ ): Layer.Layer<PostgresConnection> => Layer.succeed(PostgresConnection, Effect.succeed(config));
46
+
47
+ /**
48
+ * Run one operation against a freshly built, freshly closed connection pool.
49
+ *
50
+ * Every `read`, `reconcile` step and `delete` in this family calls this once for the whole
51
+ * operation — never once per statement — so a reconcile that runs a `SELECT`, then a `CREATE`,
52
+ * then a re-read shares one pool and closes it once, while a plan-only `read` still opens and
53
+ * closes its own.
54
+ */
55
+ export const withPg = <A, E>(
56
+ build: (pg: PgClient.PgClient) => Effect.Effect<A, E>,
57
+ ): Effect.Effect<A, E | SqlError, PostgresConnection> =>
58
+ Effect.gen(function* () {
59
+ const resolveConfig = yield* PostgresConnection;
60
+ const config = yield* resolveConfig;
61
+ return yield* Effect.provide(Effect.flatMap(PgClient.PgClient, build), PgClient.layer(config));
62
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `Postgres.Database` props and attributes, and the one prop-level check that runs before any
3
+ * statement: the name's byte length against `NAMEDATALEN`.
4
+ *
5
+ * ⛔ EVERY ASSERTED PROP HERE HAS A CITED COLUMN. `docs/postgres.md#mapping` and
6
+ * `database-provenance.test.ts` are what make that true rather than asserted: the test parses
7
+ * the two committed fixtures (`create_database.sgml`'s synopsis, `pg_database.h`'s struct) and
8
+ * fails if a prop here has no synopsis option or no catalog column behind it.
9
+ * ⛔ NO PASSWORD, EVER. A database has none in `pg_database` (S25) — `CREATE DATABASE` has no
10
+ * password option at all (`gram.y@REL_18_6#createdb_opt_name`, measured: `IDENT | CONNECTION
11
+ * LIMIT | ENCODING | LOCATION | OWNER | TABLESPACE | TEMPLATE`).
12
+ */
13
+
14
+ /** NAMEDATALEN (`pg_config_manual.h@REL_18_6` line 29) minus the C string terminator: the
15
+ * longest name the server stores without truncating. */
16
+ export const POSTGRES_NAME_MAX_BYTES = 63;
17
+
18
+ /** UTF-8 byte length, the same unit `NAMEDATALEN` counts in (`scansup.c@REL_18_6#truncate_identifier`
19
+ * clips by `pg_mbcliplen`, a byte-oriented, multibyte-aware clip — never a JS `.length`, which
20
+ * counts UTF-16 code units and would pass a 63-byte multibyte name that is really 70+ bytes, or
21
+ * refuse a 63-byte name whose `.length` looks smaller). */
22
+ export const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
23
+
24
+ /**
25
+ * Declared, at-most-once-asserted properties of one database.
26
+ *
27
+ * Every prop here maps to a `CREATE DATABASE` `WITH` option `create_database.sgml` documents
28
+ * AND a `pg_database` column that stores it (`database-provenance.test.ts`). `TEMPLATE`,
29
+ * `STRATEGY` and `OID` are excluded because nothing in `pg_database` reads them back once the
30
+ * database exists; `ICU_RULES` and `COLLATION_VERSION` are excluded for having no consumer in
31
+ * this family today. See `docs/postgres.md#excluded`.
32
+ */
33
+ export interface PostgresDatabaseProps {
34
+ /** At most 63 UTF-8 bytes — refused at plan past that (`PostgresDatabaseNameRefused`). The
35
+ * identity: a name change is a plan-time refusal, never a replace. */
36
+ readonly name: string;
37
+ /** A role name in `pg_roles`, asserted before create (`PostgresDatabaseOwnerMissing`) and
38
+ * compared against `pg_get_userbyid(datdba)` on every plan thereafter. */
39
+ readonly owner: string;
40
+ /** `pg_encoding_to_char(datdatabase.encoding)`, e.g. `"UTF8"`. @default the template's. */
41
+ readonly encoding?: string;
42
+ /** `'builtin' | 'icu' | 'libc'` — `pg_database.datlocprovider` decoded
43
+ * (`pg_collation.h@REL_18_6#collprovider_name`). @default the template's. */
44
+ readonly localeProvider?: 'builtin' | 'icu' | 'libc';
45
+ /** `pg_database.datcollate`. @default the template's. */
46
+ readonly lcCollate?: string;
47
+ /** `pg_database.datctype`. @default the template's. */
48
+ readonly lcCtype?: string;
49
+ /** `pg_database.datallowconn`. @default `true`. */
50
+ readonly allowConnections?: boolean;
51
+ /** `pg_database.datconnlimit`; `-1` means unlimited. @default `-1`. */
52
+ readonly connectionLimit?: number;
53
+ /** `pg_database.datistemplate`. @default `false`. */
54
+ readonly isTemplate?: boolean;
55
+ /** A tablespace name in `pg_tablespace`. @default the template's (usually `pg_default`). */
56
+ readonly tablespace?: string;
57
+ }
58
+
59
+ /** Live attributes, read back after every reconcile. No `password` — see the file header. */
60
+ export interface PostgresDatabaseAttributes {
61
+ readonly name: string;
62
+ readonly oid: number;
63
+ readonly owner: string;
64
+ readonly encoding: string;
65
+ readonly localeProvider: 'builtin' | 'icu' | 'libc';
66
+ readonly collate: string;
67
+ readonly ctype: string;
68
+ readonly allowConnections: boolean;
69
+ readonly connectionLimit: number;
70
+ readonly isTemplate: boolean;
71
+ readonly tablespace: string;
72
+ }
73
+
74
+ /**
75
+ * Refuse a name the server would truncate, before any statement is built.
76
+ *
77
+ * ★ CALLED FROM `diff` (so a plan fails, never a deploy) AND FROM `reconcile` (so a fake or a
78
+ * direct call gets the same refusal, never just a broken statement).
79
+ */
80
+ export const nameByteRefusal = (
81
+ name: string,
82
+ ): { readonly byteLength: number; readonly limit: number } | undefined => {
83
+ const byteLength = utf8ByteLength(name);
84
+ return byteLength > POSTGRES_NAME_MAX_BYTES
85
+ ? { byteLength, limit: POSTGRES_NAME_MAX_BYTES }
86
+ : undefined;
87
+ };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Regression for the critical finding: `SELECT_DATABASE_SQL` casting `oid`/`datconnlimit` to
3
+ * `::int8` makes `@effect/sql-pg`'s binary-protocol codec decode them as JS `bigint`, and
4
+ * alchemy's `encodeState` has no `bigint` branch — so `JSON.stringify`ing the persisted state
5
+ * throws on every successful `reconcile`, and a matching declared `connectionLimit` reads as
6
+ * permanent drift (`bigint !== number`).
7
+ *
8
+ * ⛔ THIS DOES NOT HIT A LIVE SOCKET. It stays faithful to the real defect by driving the exact
9
+ * query TEXT `selectDatabase` sends (`fake-sql.ts`'s recorder does not decode types at all, so
10
+ * it cannot see this bug) through `@effect/sql-pg/PgTypes`'s own public `encode`/`decode`
11
+ * (the same codec table the real driver looks up by OID — measured `PgTypes.ts@4.0.0-rc.115`)
12
+ * and through alchemy's own `encodeState` (`alchemy/State/StateEncoding`, what `LocalState.js`
13
+ * calls before every `JSON.stringify`). If a column is cast to `::int8` in the production SQL,
14
+ * this fake decodes it exactly as the real wire would: a genuine `bigint`, round-tripped
15
+ * through the vendor's own codec, not a hand-simulated one.
16
+ */
17
+ import { describe, expect, test } from 'bun:test';
18
+ import * as Effect from 'effect/Effect';
19
+ import * as Result from 'effect/Result';
20
+ import * as PgTypes from '@effect/sql-pg/PgTypes';
21
+ import type { SqlError } from 'effect/unstable/sql/SqlError';
22
+ import { encodeState } from 'alchemy/State/StateEncoding';
23
+ import { reconcileWithClient } from './database.ts';
24
+ import { PostgresDatabaseDrift } from './errors.ts';
25
+ import type { PostgresDatabaseProps } from './database-attrs.ts';
26
+ import type { PgExecutor } from './database-sql.ts';
27
+
28
+ /** Round-trip `value` through the real `@effect/sql-pg` codec for `oid` — encode, then decode
29
+ * the resulting wire bytes back — so the JS type this returns is whatever the real driver would
30
+ * hand back for that OID, not an assumption about it. */
31
+ const wireRoundtrip = (value: number, oid: number): number | bigint => {
32
+ const encoded = PgTypes.encode(oid === PgTypes.OID.int8 ? BigInt(value) : value, oid);
33
+ if (!Result.isSuccess(encoded)) throw new Error(`fixture: encode failed for OID ${String(oid)}`);
34
+ const decoded = PgTypes.decode(encoded.success, oid, 1);
35
+ if (!Result.isSuccess(decoded)) throw new Error(`fixture: decode failed for OID ${String(oid)}`);
36
+ return decoded.success as number | bigint;
37
+ };
38
+
39
+ const baseRow = {
40
+ name: 'widgets',
41
+ owner: 'tim',
42
+ encoding: 'UTF8',
43
+ localeProvider: 'libc',
44
+ collate: 'C',
45
+ ctype: 'C',
46
+ allowConnections: true,
47
+ isTemplate: false,
48
+ tablespace: 'pg_default',
49
+ };
50
+
51
+ /** A `pg_database` row decoded exactly as the production `SELECT_DATABASE_SQL` TEXT dictates:
52
+ * `oid`/`connectionLimit` come back as `bigint` only when that text casts them to `::int8` —
53
+ * the same condition that flips the real driver's codec lookup. */
54
+ const wireAccurateRow = (text: string, oid: number, connectionLimit: number) => ({
55
+ ...baseRow,
56
+ oid: wireRoundtrip(oid, text.includes('d.oid::int8 AS oid') ? PgTypes.OID.int8 : PgTypes.OID.oid),
57
+ connectionLimit: wireRoundtrip(
58
+ connectionLimit,
59
+ text.includes('d.datconnlimit::int8 AS "connectionLimit"')
60
+ ? PgTypes.OID.int8
61
+ : PgTypes.OID.int4,
62
+ ),
63
+ });
64
+
65
+ /** A `PgExecutor` that answers `SELECT_DATABASE_SQL` with a wire-accurate row (see above) and
66
+ * every other statement the way `fake-sql.ts`'s own fake does. `seed`, when given, is the row
67
+ * already "on the cluster" — so `reconcileWithClient` takes the already-present/drift-check
68
+ * branch instead of create. With no seed, a `CREATE DATABASE` statement makes one up (oid
69
+ * 30000, the default `connectionLimit` of -1) so the mandatory post-create re-read has a row to
70
+ * decode — mirroring `fake-sql.ts`'s own greenfield bookkeeping. */
71
+ const wireAccuratePg = (seed?: {
72
+ readonly oid: number;
73
+ readonly connectionLimit: number;
74
+ }): PgExecutor => {
75
+ let created: { readonly oid: number; readonly connectionLimit: number } | undefined = seed;
76
+ return {
77
+ unsafe: <A extends object>(text: string, params: ReadonlyArray<unknown> = []) =>
78
+ Effect.suspend(() => {
79
+ if (text.startsWith('SELECT 1 AS present FROM pg_roles')) {
80
+ return Effect.succeed([{ present: 1 }] as unknown as ReadonlyArray<A>);
81
+ }
82
+ if (text.includes('FROM pg_database')) {
83
+ if (created === undefined) return Effect.succeed([] as unknown as ReadonlyArray<A>);
84
+ const row = wireAccurateRow(text, created.oid, created.connectionLimit);
85
+ return Effect.succeed([row] as unknown as ReadonlyArray<A>);
86
+ }
87
+ if (text.startsWith('CREATE DATABASE')) {
88
+ created = { oid: 30000, connectionLimit: -1 };
89
+ return Effect.succeed([] as unknown as ReadonlyArray<A>);
90
+ }
91
+ throw new Error(`wireAccuratePg: unrecognised statement: ${text}`);
92
+ }) as Effect.Effect<ReadonlyArray<A>, SqlError>,
93
+ };
94
+ };
95
+
96
+ const baseProps: PostgresDatabaseProps = { name: 'widgets', owner: 'tim' };
97
+
98
+ describe('measured: @effect/sql-pg int8 vs native-width codecs (PgTypes.ts@4.0.0-rc.115)', () => {
99
+ test('OID.int8 decodes to a JS bigint', () => {
100
+ expect(typeof wireRoundtrip(20000, PgTypes.OID.int8)).toBe('bigint');
101
+ });
102
+
103
+ test('OID.oid and OID.int4 — the columns’ real pg_database.h widths — decode to a JS number', () => {
104
+ expect(typeof wireRoundtrip(20000, PgTypes.OID.oid)).toBe('number');
105
+ expect(typeof wireRoundtrip(-1, PgTypes.OID.int4)).toBe('number');
106
+ });
107
+ });
108
+
109
+ describe('reconcileWithClient: state must survive alchemy’s real encodeState + JSON.stringify', () => {
110
+ test('greenfield create-and-reread never returns a value the state encoder cannot serialize', async () => {
111
+ const pg = wireAccuratePg();
112
+ const attrs = await Effect.runPromise(reconcileWithClient(pg, baseProps));
113
+
114
+ expect(typeof attrs.oid).toBe('number');
115
+ expect(typeof attrs.connectionLimit).toBe('number');
116
+
117
+ let json: string | undefined;
118
+ expect(() => {
119
+ json = JSON.stringify(encodeState(attrs), null, 2);
120
+ }).not.toThrow();
121
+ expect(JSON.parse(json as string)).toMatchObject({ oid: attrs.oid, connectionLimit: -1 });
122
+ });
123
+
124
+ test('a declared connectionLimit that matches the live value is a no-op, not a false-positive drift', async () => {
125
+ const pg = wireAccuratePg({ oid: 30000, connectionLimit: 5 });
126
+ const attrs = await Effect.runPromise(
127
+ reconcileWithClient(pg, { ...baseProps, connectionLimit: 5 }),
128
+ );
129
+ expect(attrs.connectionLimit).toBe(5);
130
+ });
131
+
132
+ test('PostgresDatabaseDrift.message never throws building itself, even when a mismatch is real', async () => {
133
+ const pg = wireAccuratePg({ oid: 30000, connectionLimit: 5 });
134
+ const error = await Effect.runPromise(
135
+ Effect.flip(reconcileWithClient(pg, { ...baseProps, connectionLimit: 10 })),
136
+ );
137
+ expect(error).toBeInstanceOf(PostgresDatabaseDrift);
138
+ expect(() => (error as PostgresDatabaseDrift).message).not.toThrow();
139
+ });
140
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `reconcile` and `read` against `fake-sql.ts`'s recording fake — greenfield, no-drift, drift,
3
+ * a `42P04` race, a missing owner role, and exact quoting for hostile names. Every case reverts
4
+ * cleanly by reverting `database.ts`/`database-sql.ts` locally: these tests fail on `origin/main`
5
+ * because the files do not exist there.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import { makeFakeSql } from './fake-sql.ts';
10
+ import { readWithClient, reconcileWithClient } from './database.ts';
11
+ import { PostgresDatabaseDrift, PostgresDatabaseOwnerMissing } from './errors.ts';
12
+ import type { PostgresDatabaseAttributes, PostgresDatabaseProps } from './database-attrs.ts';
13
+
14
+ const run = <A, E>(eff: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(eff);
15
+ const fails = <A, E>(eff: Effect.Effect<A, E>): Promise<E> => Effect.runPromise(Effect.flip(eff));
16
+
17
+ const baseProps: PostgresDatabaseProps = { name: 'widgets', owner: 'tim' };
18
+
19
+ const liveRow = (over: Partial<PostgresDatabaseAttributes> = {}): PostgresDatabaseAttributes => ({
20
+ name: 'widgets',
21
+ oid: 30000,
22
+ owner: 'tim',
23
+ encoding: 'UTF8',
24
+ localeProvider: 'libc',
25
+ collate: 'C',
26
+ ctype: 'C',
27
+ allowConnections: true,
28
+ connectionLimit: -1,
29
+ isTemplate: false,
30
+ tablespace: 'pg_default',
31
+ ...over,
32
+ });
33
+
34
+ describe('reconcile: greenfield', () => {
35
+ test('issues exactly one CREATE, quoted, with an OWNER clause, and nothing before the role check', async () => {
36
+ const fake = makeFakeSql({ roles: ['tim'] });
37
+ const attrs = await run(reconcileWithClient(fake, baseProps));
38
+ expect(attrs.name).toBe('widgets');
39
+ expect(attrs.owner).toBe('tim');
40
+ const creates = fake.statements.filter((s) => s.text.startsWith('CREATE DATABASE'));
41
+ expect(creates.length).toBe(1);
42
+ expect(creates[0]?.text).toBe('CREATE DATABASE "widgets" WITH OWNER "tim"');
43
+ });
44
+
45
+ test('refuses before any CREATE when the owner role does not exist', async () => {
46
+ const fake = makeFakeSql({ roles: [] });
47
+ const error = await fails(reconcileWithClient(fake, baseProps));
48
+ expect(error).toBeInstanceOf(PostgresDatabaseOwnerMissing);
49
+ expect(fake.statements.some((s) => s.text.startsWith('CREATE DATABASE'))).toBe(false);
50
+ });
51
+
52
+ test('a 42P04 race (something else created it first) converges instead of failing', async () => {
53
+ const fake = makeFakeSql({ roles: ['tim'], raceNextCreate: true });
54
+ // Seed the row a "competing" create would have produced, so the post-race re-read finds it.
55
+ fake.databases.set('widgets', liveRow());
56
+ const attrs = await run(reconcileWithClient(fake, baseProps));
57
+ expect(attrs.name).toBe('widgets');
58
+ });
59
+
60
+ test('hostile names are quoted exactly: embedded quote and a hyphen', async () => {
61
+ const fake = makeFakeSql({ roles: ['tim'] });
62
+ await run(reconcileWithClient(fake, { name: 'a"b-c', owner: 'tim' }));
63
+ const create = fake.statements.find((s) => s.text.startsWith('CREATE DATABASE'));
64
+ expect(create?.text).toBe('CREATE DATABASE "a""b-c" WITH OWNER "tim"');
65
+ });
66
+ });
67
+
68
+ describe('reconcile: already present', () => {
69
+ test('no drift records zero write statements', async () => {
70
+ const fake = makeFakeSql({ roles: ['tim'], databases: [liveRow()] });
71
+ const attrs = await run(reconcileWithClient(fake, baseProps));
72
+ expect(attrs).toEqual(liveRow());
73
+ expect(
74
+ fake.statements.every((s) => !s.text.startsWith('CREATE') && !s.text.startsWith('ALTER')),
75
+ ).toBe(true);
76
+ });
77
+
78
+ test('an owner mismatch fails with the typed drift tag and issues no ALTER', async () => {
79
+ const fake = makeFakeSql({
80
+ roles: ['tim', 'someone-else'],
81
+ databases: [liveRow({ owner: 'someone-else' })],
82
+ });
83
+ const error = await fails(reconcileWithClient(fake, baseProps));
84
+ expect(error).toBeInstanceOf(PostgresDatabaseDrift);
85
+ expect((error as PostgresDatabaseDrift).prop).toBe('owner');
86
+ expect(fake.statements.some((s) => s.text.startsWith('ALTER'))).toBe(false);
87
+ });
88
+
89
+ test('an asserted-prop mismatch fails with the typed drift tag naming that prop', async () => {
90
+ const fake = makeFakeSql({ roles: ['tim'], databases: [liveRow({ isTemplate: true })] });
91
+ const error = await fails(reconcileWithClient(fake, { ...baseProps, isTemplate: false }));
92
+ expect(error).toBeInstanceOf(PostgresDatabaseDrift);
93
+ expect((error as PostgresDatabaseDrift).prop).toBe('isTemplate');
94
+ });
95
+
96
+ test('no recorded statement ever runs inside BEGIN', async () => {
97
+ const fake = makeFakeSql({ roles: ['tim'], databases: [liveRow()] });
98
+ await run(reconcileWithClient(fake, baseProps));
99
+ expect(fake.statements.some((s) => /\bBEGIN\b/.test(s.text))).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe('read', () => {
104
+ test('answers undefined when the database is absent', async () => {
105
+ const fake = makeFakeSql();
106
+ expect(await run(readWithClient(fake, 'widgets'))).toBeUndefined();
107
+ });
108
+
109
+ test("answers the live row when present (ownership branding is the caller's job)", async () => {
110
+ const fake = makeFakeSql({ databases: [liveRow()] });
111
+ expect(await run(readWithClient(fake, 'widgets'))).toEqual(liveRow());
112
+ });
113
+ });