@homeflare/alchemy 0.23.0 → 0.25.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 (177) hide show
  1. package/README.md +25 -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/paperless/client.d.ts +37 -0
  23. package/dist/paperless/client.d.ts.map +1 -0
  24. package/dist/paperless/constraint-guard.d.ts +19 -0
  25. package/dist/paperless/constraint-guard.d.ts.map +1 -0
  26. package/dist/paperless/constraints.d.ts +50 -0
  27. package/dist/paperless/constraints.d.ts.map +1 -0
  28. package/dist/paperless/credentials.d.ts +40 -0
  29. package/dist/paperless/credentials.d.ts.map +1 -0
  30. package/dist/paperless/custom-field.d.ts +75 -0
  31. package/dist/paperless/custom-field.d.ts.map +1 -0
  32. package/dist/paperless/document-type.d.ts +30 -0
  33. package/dist/paperless/document-type.d.ts.map +1 -0
  34. package/dist/paperless/errors.d.ts +49 -0
  35. package/dist/paperless/errors.d.ts.map +1 -0
  36. package/dist/paperless/fake-paperless.d.ts +39 -0
  37. package/dist/paperless/fake-paperless.d.ts.map +1 -0
  38. package/dist/paperless/generated/constraints/index.d.ts +13 -0
  39. package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
  40. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
  41. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
  42. package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
  43. package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
  44. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
  45. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
  46. package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
  47. package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
  48. package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
  49. package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
  50. package/dist/paperless/generated/types/document-types.d.ts +39 -0
  51. package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
  52. package/dist/paperless/generated/types/index.d.ts +10 -0
  53. package/dist/paperless/generated/types/index.d.ts.map +1 -0
  54. package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
  55. package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
  56. package/dist/paperless/generated/types/tags.d.ts +37 -0
  57. package/dist/paperless/generated/types/tags.d.ts.map +1 -0
  58. package/dist/paperless/index.d.ts +19 -0
  59. package/dist/paperless/index.d.ts.map +1 -0
  60. package/dist/paperless/index.js +524 -0
  61. package/dist/paperless/index.js.map +26 -0
  62. package/dist/paperless/matching-locate.d.ts +33 -0
  63. package/dist/paperless/matching-locate.d.ts.map +1 -0
  64. package/dist/paperless/matching-types.d.ts +60 -0
  65. package/dist/paperless/matching-types.d.ts.map +1 -0
  66. package/dist/paperless/matching.d.ts +61 -0
  67. package/dist/paperless/matching.d.ts.map +1 -0
  68. package/dist/paperless/providers.d.ts +15 -0
  69. package/dist/paperless/providers.d.ts.map +1 -0
  70. package/dist/paperless/storage-path.d.ts +32 -0
  71. package/dist/paperless/storage-path.d.ts.map +1 -0
  72. package/dist/paperless/tag.d.ts +47 -0
  73. package/dist/paperless/tag.d.ts.map +1 -0
  74. package/dist/postgres/connection.d.ts +50 -0
  75. package/dist/postgres/connection.d.ts.map +1 -0
  76. package/dist/postgres/database-attrs.d.ts +79 -0
  77. package/dist/postgres/database-attrs.d.ts.map +1 -0
  78. package/dist/postgres/database-sql.d.ts +73 -0
  79. package/dist/postgres/database-sql.d.ts.map +1 -0
  80. package/dist/postgres/database.d.ts +63 -0
  81. package/dist/postgres/database.d.ts.map +1 -0
  82. package/dist/postgres/errors.d.ts +86 -0
  83. package/dist/postgres/errors.d.ts.map +1 -0
  84. package/dist/postgres/fake-sql.d.ts +22 -0
  85. package/dist/postgres/fake-sql.d.ts.map +1 -0
  86. package/dist/postgres/index.d.ts +16 -0
  87. package/dist/postgres/index.d.ts.map +1 -0
  88. package/dist/postgres/index.js +238 -0
  89. package/dist/postgres/index.js.map +15 -0
  90. package/dist/postgres/providers.d.ts +14 -0
  91. package/dist/postgres/providers.d.ts.map +1 -0
  92. package/dist/proxmox/index.js.map +2 -2
  93. package/dist/proxmox/zfs-pool.d.ts +10 -2
  94. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  95. package/dist/release/archive.d.ts +6 -2
  96. package/dist/release/archive.d.ts.map +1 -1
  97. package/dist/release/binary-form.d.ts +14 -9
  98. package/dist/release/binary-form.d.ts.map +1 -1
  99. package/dist/release/binary-pins.d.ts +17 -0
  100. package/dist/release/binary-pins.d.ts.map +1 -0
  101. package/dist/release/catalog.d.ts.map +1 -1
  102. package/dist/release/index.js +90 -40
  103. package/dist/release/index.js.map +8 -7
  104. package/dist/release/tar.d.ts +9 -2
  105. package/dist/release/tar.d.ts.map +1 -1
  106. package/docs/litellm.md +110 -0
  107. package/docs/paperless.md +115 -0
  108. package/docs/peers.md +8 -1
  109. package/docs/postgres.md +125 -0
  110. package/docs/release-binary-catalogs.md +40 -4
  111. package/docs/release-binary-refusals.md +22 -8
  112. package/docs/release-binary-upstream.md +33 -15
  113. package/package.json +14 -1
  114. package/src/litellm/client.ts +195 -0
  115. package/src/litellm/credentials.ts +73 -0
  116. package/src/litellm/fake-litellm.ts +114 -0
  117. package/src/litellm/fake-stack.ts +100 -0
  118. package/src/litellm/generated/pass-through.ts +73 -0
  119. package/src/litellm/index.ts +42 -0
  120. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
  121. package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
  122. package/src/litellm/pass-through-endpoint.test.ts +208 -0
  123. package/src/litellm/pass-through-endpoint.ts +258 -0
  124. package/src/litellm/pass-through-form.test.ts +128 -0
  125. package/src/litellm/pass-through-form.ts +136 -0
  126. package/src/litellm/providers.ts +19 -0
  127. package/src/paperless/client.ts +88 -0
  128. package/src/paperless/constraint-guard.ts +50 -0
  129. package/src/paperless/constraints.test.ts +74 -0
  130. package/src/paperless/constraints.ts +111 -0
  131. package/src/paperless/credentials.ts +69 -0
  132. package/src/paperless/custom-field.test.ts +74 -0
  133. package/src/paperless/custom-field.ts +95 -0
  134. package/src/paperless/document-type.ts +74 -0
  135. package/src/paperless/errors.ts +62 -0
  136. package/src/paperless/fake-paperless.ts +156 -0
  137. package/src/paperless/generated/constraints/index.ts +23 -0
  138. package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
  139. package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
  140. package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
  141. package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
  142. package/src/paperless/generated/types/custom-fields.ts +23 -0
  143. package/src/paperless/generated/types/document-types.ts +30 -0
  144. package/src/paperless/generated/types/index.ts +9 -0
  145. package/src/paperless/generated/types/storage-paths.ts +31 -0
  146. package/src/paperless/generated/types/tags.ts +37 -0
  147. package/src/paperless/index.ts +56 -0
  148. package/src/paperless/matching-identity.test.ts +194 -0
  149. package/src/paperless/matching-locate.ts +104 -0
  150. package/src/paperless/matching-types.ts +61 -0
  151. package/src/paperless/matching.test.ts +128 -0
  152. package/src/paperless/matching.ts +239 -0
  153. package/src/paperless/providers.ts +27 -0
  154. package/src/paperless/storage-path.ts +78 -0
  155. package/src/paperless/tag.ts +91 -0
  156. package/src/postgres/connection.ts +62 -0
  157. package/src/postgres/database-attrs.ts +87 -0
  158. package/src/postgres/database-bigint-serialization.test.ts +140 -0
  159. package/src/postgres/database-lifecycle.test.ts +113 -0
  160. package/src/postgres/database-provenance.test.ts +118 -0
  161. package/src/postgres/database-refusals.test.ts +135 -0
  162. package/src/postgres/database-sql.ts +174 -0
  163. package/src/postgres/database.ts +193 -0
  164. package/src/postgres/errors.ts +131 -0
  165. package/src/postgres/fake-sql.ts +123 -0
  166. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
  167. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
  168. package/src/postgres/index.ts +30 -0
  169. package/src/postgres/providers.ts +16 -0
  170. package/src/proxmox/zfs-pool.ts +10 -2
  171. package/src/release/archive.ts +10 -4
  172. package/src/release/binary-form.ts +24 -76
  173. package/src/release/binary-pins.ts +95 -0
  174. package/src/release/binary-root.test.ts +122 -0
  175. package/src/release/catalog.ts +10 -2
  176. package/src/release/tar-root.test.ts +114 -0
  177. package/src/release/tar.ts +78 -12
@@ -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
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Provenance: every `PostgresDatabaseProps` field traces to a real `CREATE DATABASE` synopsis
3
+ * option AND a real `pg_database` column, and every synopsis option this family does NOT expose
4
+ * as a prop is named with a reason. Both fixtures are parsed, not eyeballed — deleting a fixture
5
+ * line, or adding a prop with no mapping, fails this file (S38, the house's provenance rule).
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ const FIXTURES = join(import.meta.dir, 'fixtures');
12
+ const synopsis = readFileSync(join(FIXTURES, 'pg-REL_18_6-create-database-synopsis.txt'), 'utf8');
13
+ const struct = readFileSync(join(FIXTURES, 'pg-REL_18_6-pg_database.h.txt'), 'utf8');
14
+
15
+ const SYNOPSIS_BLOB = '4da8aeebb50a29e89bd9f68a8deac4b1a3ec7b18';
16
+ const STRUCT_BLOB = '54f0d38c9c9e1cd4d419b05b296b8f84875e9768';
17
+
18
+ const synopsisOptions = (): readonly string[] => {
19
+ const re = /\[\s*([A-Z][A-Z_ ]*?)\s*(?:\[=\]|=)\s*<replaceable/g;
20
+ const found: string[] = [];
21
+ for (const m of synopsis.matchAll(re)) found.push(m[1] as string);
22
+ return found;
23
+ };
24
+
25
+ const structColumns = (): readonly string[] => {
26
+ const re = /^\t[A-Za-z_][A-Za-z0-9_]*\s+(\w+)(?:\[\d+\])?\b/;
27
+ const found: string[] = [];
28
+ for (const line of struct.split('\n')) {
29
+ const m = re.exec(line);
30
+ if (m !== null) found.push(m[1] as string);
31
+ }
32
+ return found;
33
+ };
34
+
35
+ /** Every asserted prop except `name`, which is the mandatory `CREATE DATABASE <name>` token, not
36
+ * a bracketed `WITH` option — checked separately below. */
37
+ const PROP_MAPPING: Readonly<Record<string, { readonly option: string; readonly column: string }>> =
38
+ {
39
+ owner: { option: 'OWNER', column: 'datdba' },
40
+ encoding: { option: 'ENCODING', column: 'encoding' },
41
+ localeProvider: { option: 'LOCALE_PROVIDER', column: 'datlocprovider' },
42
+ lcCollate: { option: 'LC_COLLATE', column: 'datcollate' },
43
+ lcCtype: { option: 'LC_CTYPE', column: 'datctype' },
44
+ allowConnections: { option: 'ALLOW_CONNECTIONS', column: 'datallowconn' },
45
+ connectionLimit: { option: 'CONNECTION LIMIT', column: 'datconnlimit' },
46
+ isTemplate: { option: 'IS_TEMPLATE', column: 'datistemplate' },
47
+ tablespace: { option: 'TABLESPACE', column: 'dattablespace' },
48
+ };
49
+
50
+ /** Every synopsis option this family does NOT declare as a prop, and why. */
51
+ const EXCLUDED: Readonly<Record<string, string>> = {
52
+ TEMPLATE: 'consumed only at create time to copy from; pg_database has no column recording it',
53
+ STRATEGY: 'consumed only at create time (WAL_LOG vs FILE_COPY copy method); not stored',
54
+ LOCALE:
55
+ 'a shorthand that sets LC_COLLATE/LC_CTYPE (or BUILTIN_LOCALE); this family declares lcCollate/lcCtype directly instead',
56
+ BUILTIN_LOCALE:
57
+ "only meaningful when localeProvider='builtin'; folds into datcollate/datctype like LOCALE, no column of its own",
58
+ ICU_LOCALE:
59
+ "only meaningful when localeProvider='icu' (datlocale column exists); deferred — measured 2026-09-23, all 24 live databases are locale provider 'c' (libc)",
60
+ ICU_RULES:
61
+ 'stored in daticurules, but this family has no ICU-provider consumer yet (see ICU_LOCALE)',
62
+ COLLATION_VERSION:
63
+ 'intended for pg_upgrade only; normally omitted so PostgreSQL computes it from the OS',
64
+ OID: 'primarily for pg_upgrade\'s internal use (docs: "only pg_upgrade can specify a value less than 16384"); not something a stack should assert',
65
+ };
66
+
67
+ describe('postgres provenance', () => {
68
+ test('fixture headers name the measured blob shas', () => {
69
+ expect(synopsis).toContain(SYNOPSIS_BLOB);
70
+ expect(synopsis).toContain('doc/src/sgml/ref/create_database.sgml');
71
+ expect(struct).toContain(STRUCT_BLOB);
72
+ expect(struct).toContain('src/include/catalog/pg_database.h');
73
+ });
74
+
75
+ test('the name token is documented and backed by datname', () => {
76
+ expect(synopsis).toContain('CREATE DATABASE <replaceable class="parameter">name</replaceable>');
77
+ expect(structColumns()).toContain('datname');
78
+ });
79
+
80
+ test('every declared prop maps to a real synopsis option', () => {
81
+ const options = synopsisOptions();
82
+ for (const [prop, { option }] of Object.entries(PROP_MAPPING)) {
83
+ expect(options, `prop "${prop}" claims synopsis option "${option}"`).toContain(option);
84
+ }
85
+ });
86
+
87
+ test('every declared prop maps to a real pg_database column', () => {
88
+ const columns = structColumns();
89
+ for (const [prop, { column }] of Object.entries(PROP_MAPPING)) {
90
+ expect(columns, `prop "${prop}" claims column "${column}"`).toContain(column);
91
+ }
92
+ });
93
+
94
+ test('every synopsis option is either a declared prop or a documented exclusion', () => {
95
+ const declaredOptions = new Set(Object.values(PROP_MAPPING).map((m) => m.option));
96
+ for (const option of synopsisOptions()) {
97
+ const accounted = declaredOptions.has(option) || option in EXCLUDED;
98
+ expect(accounted, `synopsis option "${option}" is neither a prop nor in EXCLUDED`).toBe(true);
99
+ }
100
+ });
101
+
102
+ test('no excluded option is silently also a declared prop', () => {
103
+ const declaredOptions = new Set(Object.values(PROP_MAPPING).map((m) => m.option));
104
+ for (const option of Object.keys(EXCLUDED)) {
105
+ expect(declaredOptions.has(option), `"${option}" is both excluded and declared`).toBe(false);
106
+ }
107
+ });
108
+
109
+ test('every excluded option actually exists in the synopsis (no stale entries)', () => {
110
+ const options = new Set(synopsisOptions());
111
+ for (const option of Object.keys(EXCLUDED)) {
112
+ expect(
113
+ options.has(option),
114
+ `EXCLUDED names "${option}", which the synopsis no longer has`,
115
+ ).toBe(true);
116
+ }
117
+ });
118
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Every plan-time refusal, checked without a client: name-byte precision, identifier and string
3
+ * quoting, the rename refusal, a full sweep proving `diff` never answers `replace`, and `delete`.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { readFileSync } from 'node:fs';
7
+ import * as Effect from 'effect/Effect';
8
+ import { POSTGRES_NAME_MAX_BYTES, nameByteRefusal, utf8ByteLength } from './database-attrs.ts';
9
+ import { buildCreateDatabaseSql, quoteIdent, quoteStringLiteral } from './database-sql.ts';
10
+ import { diffPostgresDatabase, postgresDatabaseHandlers } from './database.ts';
11
+ import { PostgresDatabaseDropRefused, PostgresDatabaseRenameRefused } from './errors.ts';
12
+ import type { PostgresDatabaseAttributes, PostgresDatabaseProps } from './database-attrs.ts';
13
+
14
+ describe('name byte length', () => {
15
+ test('a 63-byte ASCII name is accepted', () => {
16
+ const name = 'a'.repeat(63);
17
+ expect(utf8ByteLength(name)).toBe(63);
18
+ expect(nameByteRefusal(name)).toBeUndefined();
19
+ });
20
+
21
+ test('a 64-byte ASCII name is refused', () => {
22
+ const name = 'a'.repeat(64);
23
+ const refusal = nameByteRefusal(name);
24
+ expect(refusal).toEqual({ byteLength: 64, limit: POSTGRES_NAME_MAX_BYTES });
25
+ });
26
+
27
+ test('a 63-byte multibyte name (21 three-byte characters) is accepted, though .length is 21', () => {
28
+ // U+4E2D ("中") is 3 UTF-8 bytes. 21 * 3 = 63 bytes exactly — the last size that survives.
29
+ const name = '中'.repeat(21);
30
+ expect(name.length).toBe(21);
31
+ expect(utf8ByteLength(name)).toBe(63);
32
+ expect(nameByteRefusal(name)).toBeUndefined();
33
+ });
34
+
35
+ test('one more multibyte character (64 bytes) is refused', () => {
36
+ const name = `${'中'.repeat(21)}a`;
37
+ expect(utf8ByteLength(name)).toBe(64);
38
+ expect(nameByteRefusal(name)).toEqual({ byteLength: 64, limit: POSTGRES_NAME_MAX_BYTES });
39
+ });
40
+ });
41
+
42
+ describe('quoting', () => {
43
+ test('quoteIdent doubles an embedded double quote and leaves a hyphen alone', () => {
44
+ expect(quoteIdent('a"b-c')).toBe('"a""b-c"');
45
+ });
46
+
47
+ test('quoteIdent does not split a dot the way a qualified-name escaper would', () => {
48
+ // The hazard this file exists to avoid — see database-sql.ts's header.
49
+ expect(quoteIdent('my.app')).toBe('"my.app"');
50
+ });
51
+
52
+ test('quoteStringLiteral doubles an embedded single quote', () => {
53
+ expect(quoteStringLiteral("en_US.UTF-8'; DROP")).toBe("'en_US.UTF-8''; DROP'");
54
+ });
55
+
56
+ test('buildCreateDatabaseSql never string-concatenates an unescaped value', () => {
57
+ const sql = buildCreateDatabaseSql({ name: 'db"1', owner: 'ro"le', encoding: "UT'F8" });
58
+ expect(sql).toBe('CREATE DATABASE "db""1" WITH OWNER "ro""le" ENCODING \'UT\'\'F8\'');
59
+ });
60
+ });
61
+
62
+ const sampleOutput: PostgresDatabaseAttributes = {
63
+ name: 'widgets',
64
+ oid: 1,
65
+ owner: 'tim',
66
+ encoding: 'UTF8',
67
+ localeProvider: 'libc',
68
+ collate: 'C',
69
+ ctype: 'C',
70
+ allowConnections: true,
71
+ connectionLimit: -1,
72
+ isTemplate: false,
73
+ tablespace: 'pg_default',
74
+ };
75
+ const base: PostgresDatabaseProps = { name: 'widgets', owner: 'tim' };
76
+
77
+ describe('diff', () => {
78
+ const output = sampleOutput;
79
+
80
+ test('a rename is refused at plan, before reconcile ever runs', async () => {
81
+ const error = await Effect.runPromise(
82
+ Effect.flip(diffPostgresDatabase({ ...base, name: 'gadgets' }, output)),
83
+ );
84
+ expect(error).toBeInstanceOf(PostgresDatabaseRenameRefused);
85
+ });
86
+
87
+ test('an unchanged declaration answers noop', async () => {
88
+ const result = await Effect.runPromise(diffPostgresDatabase(base, output));
89
+ expect(result).toEqual({ action: 'noop' });
90
+ });
91
+
92
+ test('a change in any single prop answers update, and NEVER replace — swept across every prop', async () => {
93
+ const variants: ReadonlyArray<Partial<PostgresDatabaseProps>> = [
94
+ { owner: 'someone-else' },
95
+ { encoding: 'LATIN1' },
96
+ { localeProvider: 'icu' },
97
+ { lcCollate: 'en_US.UTF-8' },
98
+ { lcCtype: 'en_US.UTF-8' },
99
+ { allowConnections: false },
100
+ { connectionLimit: 5 },
101
+ { isTemplate: true },
102
+ { tablespace: 'fast_ssd' },
103
+ ];
104
+ for (const variant of variants) {
105
+ const result = await Effect.runPromise(diffPostgresDatabase({ ...base, ...variant }, output));
106
+ expect(result?.action, `variant ${JSON.stringify(variant)}`).toBe('update');
107
+ expect(result).not.toEqual({ action: 'replace' });
108
+ }
109
+ });
110
+ });
111
+
112
+ describe('delete', () => {
113
+ test('the real handler refuses with PostgresDatabaseDropRefused naming the database', async () => {
114
+ const error = await Effect.runPromise(
115
+ Effect.flip(
116
+ postgresDatabaseHandlers.delete({
117
+ id: 'x',
118
+ fqn: 'x',
119
+ instanceId: 'x',
120
+ olds: base,
121
+ output: sampleOutput,
122
+ session: undefined as never,
123
+ bindings: [] as never,
124
+ }),
125
+ ),
126
+ );
127
+ expect(error).toBeInstanceOf(PostgresDatabaseDropRefused);
128
+ expect((error as PostgresDatabaseDropRefused).database).toBe('widgets');
129
+ });
130
+
131
+ test('defaultRemovalPolicy is retain (declared alongside PostgresDatabase, checked in source)', () => {
132
+ const source = readFileSync(new URL('./database.ts', import.meta.url), 'utf8');
133
+ expect(source).toContain("defaultRemovalPolicy: 'retain'");
134
+ });
135
+ });