@homeflare/alchemy 0.42.0 → 0.43.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 (80) hide show
  1. package/dist/litellm/budget-errors.d.ts +36 -0
  2. package/dist/litellm/budget-errors.d.ts.map +1 -0
  3. package/dist/litellm/budget-form.d.ts +50 -0
  4. package/dist/litellm/budget-form.d.ts.map +1 -0
  5. package/dist/litellm/budget-operations.d.ts +20 -0
  6. package/dist/litellm/budget-operations.d.ts.map +1 -0
  7. package/dist/litellm/budget.d.ts +61 -0
  8. package/dist/litellm/budget.d.ts.map +1 -0
  9. package/dist/litellm/fake-litellm.d.ts +5 -0
  10. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  11. package/dist/litellm/index.d.ts +2 -0
  12. package/dist/litellm/index.d.ts.map +1 -1
  13. package/dist/litellm/index.js +125 -2
  14. package/dist/litellm/index.js.map +9 -5
  15. package/dist/litellm/operations.d.ts +22 -0
  16. package/dist/litellm/operations.d.ts.map +1 -1
  17. package/dist/litellm/providers.d.ts +1 -1
  18. package/dist/litellm/providers.d.ts.map +1 -1
  19. package/dist/openbao/auth-role-form.d.ts +9 -0
  20. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  21. package/dist/openbao/auth-role-wire.d.ts.map +1 -1
  22. package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
  23. package/dist/openbao/index.js +9 -6
  24. package/dist/openbao/index.js.map +4 -4
  25. package/dist/postgres/connection.d.ts +20 -2
  26. package/dist/postgres/connection.d.ts.map +1 -1
  27. package/dist/postgres/database-sql.d.ts +1 -1
  28. package/dist/postgres/database-sql.d.ts.map +1 -1
  29. package/dist/postgres/database.d.ts +1 -1
  30. package/dist/postgres/database.d.ts.map +1 -1
  31. package/dist/postgres/index.d.ts +5 -3
  32. package/dist/postgres/index.d.ts.map +1 -1
  33. package/dist/postgres/index.js +124 -38
  34. package/dist/postgres/index.js.map +8 -7
  35. package/dist/postgres/providers.d.ts +4 -2
  36. package/dist/postgres/providers.d.ts.map +1 -1
  37. package/dist/postgres/psql-executor.d.ts +21 -0
  38. package/dist/postgres/psql-executor.d.ts.map +1 -0
  39. package/dist/proxmox/ceph-flag-types.d.ts +57 -0
  40. package/dist/proxmox/ceph-flag-types.d.ts.map +1 -0
  41. package/dist/proxmox/ceph-flag-wire.d.ts +77 -0
  42. package/dist/proxmox/ceph-flag-wire.d.ts.map +1 -0
  43. package/dist/proxmox/ceph-flag.d.ts +15 -50
  44. package/dist/proxmox/ceph-flag.d.ts.map +1 -1
  45. package/dist/proxmox/ceph-osd-write.d.ts +10 -13
  46. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  47. package/dist/proxmox/index.js +772 -740
  48. package/dist/proxmox/index.js.map +8 -7
  49. package/docs/litellm-budget.md +55 -0
  50. package/docs/litellm.md +7 -6
  51. package/docs/postgres.md +22 -0
  52. package/docs/proxmox-sdk-migration.md +45 -0
  53. package/package.json +3 -3
  54. package/src/litellm/budget-errors.ts +38 -0
  55. package/src/litellm/budget-form.ts +94 -0
  56. package/src/litellm/budget-operations.ts +51 -0
  57. package/src/litellm/budget.test.ts +105 -0
  58. package/src/litellm/budget.ts +127 -0
  59. package/src/litellm/fake-litellm.ts +50 -1
  60. package/src/litellm/index.ts +13 -0
  61. package/src/litellm/operations.ts +1 -1
  62. package/src/litellm/providers.ts +4 -1
  63. package/src/openbao/auth-role-form.test.ts +37 -0
  64. package/src/openbao/auth-role-form.ts +12 -1
  65. package/src/openbao/auth-role-wire.test.ts +1 -0
  66. package/src/openbao/auth-role-wire.ts +13 -1
  67. package/src/openbao/distilled-retry.test.ts +1 -0
  68. package/src/openbao/fake-engines-roles.ts +7 -1
  69. package/src/postgres/connection.ts +37 -3
  70. package/src/postgres/database-sql.ts +4 -3
  71. package/src/postgres/database.ts +3 -2
  72. package/src/postgres/index.ts +10 -3
  73. package/src/postgres/providers.ts +15 -2
  74. package/src/postgres/psql-executor.test.ts +106 -0
  75. package/src/postgres/psql-executor.ts +130 -0
  76. package/src/proxmox/ceph-flag-types.ts +70 -0
  77. package/src/proxmox/ceph-flag-wire.test.ts +72 -0
  78. package/src/proxmox/ceph-flag-wire.ts +123 -0
  79. package/src/proxmox/ceph-flag.ts +77 -130
  80. package/src/proxmox/ceph-osd-write.ts +10 -13
@@ -40,6 +40,8 @@ export interface FakeLitellm {
40
40
  readonly fetch: typeof globalThis.fetch;
41
41
  readonly rows: () => readonly PassThroughGenericEndpoint[];
42
42
  readonly requests: () => readonly FakeRequest[];
43
+ /** `/budget/*` rows, wire-shaped (`tpm_limit`/`rpm_limit` as decimal strings, like the real table). */
44
+ readonly budgets: () => readonly Record<string, unknown>[];
43
45
  }
44
46
 
45
47
  const RACE_WINDOW_MS = 15;
@@ -53,11 +55,52 @@ export const startFakeLitellm = (options?: {
53
55
  readonly seed?: readonly PassThroughGenericEndpoint[];
54
56
  /** Every `DELETE` 400s `not_allowed_access`, whether or not the row exists — see the file header. */
55
57
  readonly forbidDelete?: boolean;
58
+ readonly budgetSeed?: readonly Record<string, unknown>[];
59
+ /** `/budget/update` drops `null`s instead of writing them (an `exclude_none` merge). */
60
+ readonly budgetExcludeNone?: boolean;
56
61
  }): FakeLitellm => {
57
62
  const masterKey = options?.masterKey ?? 'sk-test-master';
58
63
  const forbidDelete = options?.forbidDelete ?? false;
59
64
  let rows: PassThroughGenericEndpoint[] = [...(options?.seed ?? [])];
60
65
  const requests: FakeRequest[] = [];
66
+ let budgets: Record<string, unknown>[] = [...(options?.budgetSeed ?? [])];
67
+
68
+ /**
69
+ * ⚠️ Shapes from the generated 1.100.0 schema (`budget_management.ts`), not a live 1.103.0 read.
70
+ * Create of an existing id is 400 (a unique-key failure); update/delete of a missing id is 400
71
+ * (unmeasured, the SDK patch for these routes declares `BadRequest`).
72
+ */
73
+ const budgetRoute = async (request: Request, url: URL) => {
74
+ if (request.method === 'GET' && url.pathname === '/budget/list') return json(200, budgets);
75
+ const body = (await request.json()) as Record<string, unknown>;
76
+ const id = String(body['budget_id'] ?? body['id']);
77
+ const at = budgets.findIndex((row) => row['budget_id'] === id);
78
+ const wire = (fields: Record<string, unknown>) =>
79
+ Object.fromEntries(
80
+ Object.entries(fields).map(([k, v]) => [
81
+ k,
82
+ k.endsWith('_limit') && v !== null ? String(v) : v,
83
+ ]),
84
+ );
85
+ if (url.pathname === '/budget/new') {
86
+ if (at !== -1) return json(400, { detail: `budget ${id} exists` });
87
+ budgets = [...budgets, { created_at: 'now', updated_at: 'now', ...wire(body) }];
88
+ return json(200, {});
89
+ }
90
+ if (at === -1) return json(400, { detail: `budget ${id} not found` });
91
+ if (url.pathname === '/budget/update') {
92
+ const patch = options?.budgetExcludeNone
93
+ ? Object.fromEntries(Object.entries(body).filter(([, v]) => v !== null))
94
+ : body;
95
+ budgets = budgets.map((row, i) => (i === at ? { ...row, ...wire(patch) } : row));
96
+ return json(200, {});
97
+ }
98
+ if (url.pathname === '/budget/delete') {
99
+ budgets = budgets.filter((_, i) => i !== at);
100
+ return json(200, {});
101
+ }
102
+ return json(404, { detail: 'not found' });
103
+ };
61
104
 
62
105
  const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
63
106
  const request =
@@ -66,6 +109,7 @@ export const startFakeLitellm = (options?: {
66
109
  requests.push({ method: request.method, path: `${url.pathname}${url.search}` });
67
110
  const auth = request.headers.get('authorization');
68
111
  if (auth !== `Bearer ${masterKey}`) return json(401, { detail: 'invalid api key' });
112
+ if (url.pathname.startsWith('/budget/')) return budgetRoute(request, url);
69
113
  if (
70
114
  url.pathname !== '/config/pass_through_endpoint' &&
71
115
  !url.pathname.startsWith('/config/pass_through_endpoint/')
@@ -117,5 +161,10 @@ export const startFakeLitellm = (options?: {
117
161
  return json(405, { detail: `unhandled ${request.method} ${url.pathname}` });
118
162
  }) as typeof globalThis.fetch;
119
163
 
120
- return { fetch, requests: () => [...requests], rows: () => [...rows] };
164
+ return {
165
+ budgets: () => [...budgets],
166
+ fetch,
167
+ requests: () => [...requests],
168
+ rows: () => [...rows],
169
+ };
121
170
  };
@@ -25,4 +25,17 @@ export {
25
25
  type PassThroughEndpointError,
26
26
  type PassThroughEndpointProps,
27
27
  } from './pass-through-endpoint.ts';
28
+ export {
29
+ LiteLLMBudget,
30
+ LiteLLMBudgetProvider,
31
+ isLiteLLMBudget,
32
+ type BudgetAttributes,
33
+ type BudgetError,
34
+ type BudgetProps,
35
+ } from './budget.ts';
36
+ export {
37
+ LitellmBudgetFieldNotClearedError,
38
+ LitellmBudgetHardCapUnjustifiedError,
39
+ LitellmBudgetUnreadableError,
40
+ } from './budget-errors.ts';
28
41
  export { litellmProviders } from './providers.ts';
@@ -77,7 +77,7 @@ const semaphoreFor = (baseUrl: string): Effect.Effect<Semaphore.Semaphore> =>
77
77
  * `Fetch` service still wins inside that client, which is how the fake proxy stays the test
78
78
  * transport.
79
79
  */
80
- const throughFetch = <A, E, R>(io: Effect.Effect<A, E, R>) =>
80
+ export const throughFetch = <A, E, R>(io: Effect.Effect<A, E, R>) =>
81
81
  io.pipe(Effect.provide(FetchHttpClient.layer));
82
82
 
83
83
  const mutate = <A, E>(
@@ -20,7 +20,10 @@
20
20
  */
21
21
  import { type Credentials, CredentialsFromEnv } from '@distilled.cloud/litellm/Credentials';
22
22
  import * as Layer from 'effect/Layer';
23
+ import { LiteLLMBudgetProvider } from './budget.ts';
23
24
  import { LiteLLMPassThroughEndpointProvider } from './pass-through-endpoint.ts';
24
25
 
25
26
  export const litellmProviders = (creds: Layer.Layer<Credentials> = CredentialsFromEnv) =>
26
- LiteLLMPassThroughEndpointProvider().pipe(Layer.provideMerge(creds));
27
+ Layer.mergeAll(LiteLLMPassThroughEndpointProvider(), LiteLLMBudgetProvider()).pipe(
28
+ Layer.provideMerge(creds),
29
+ );
@@ -43,3 +43,40 @@ describe('auth role secret_id TTL', () => {
43
43
  expect(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS)).toBe(false);
44
44
  });
45
45
  });
46
+
47
+ describe('auth role token_period', () => {
48
+ const LIVE = {
49
+ bind_secret_id: true,
50
+ secret_id_num_uses: 0,
51
+ secret_id_ttl: 7776000,
52
+ token_max_ttl: 3600,
53
+ token_period: 0,
54
+ token_policies: ['default', 'host-cert'],
55
+ token_ttl: 900,
56
+ };
57
+
58
+ it('omits token_period from the SDK write when the prop is unset', async () => {
59
+ expect((await Effect.runPromise(authRoleRequest(PROPS))).token_period).toBeUndefined();
60
+ });
61
+
62
+ it('writes 24h as the equivalent SDK seconds, never legacy period', async () => {
63
+ const props = { ...PROPS, tokenPeriod: '24h' };
64
+ const request = await Effect.runPromise(authRoleRequest(props));
65
+ expect(request.token_period).toBe(86400);
66
+ expect(request).not.toHaveProperty('period');
67
+ });
68
+
69
+ it('reads a live token_period back and matches an equivalent declaration', () => {
70
+ const props = { ...PROPS, tokenPeriod: '24h' };
71
+ const attributes = attributesOf(props, { ...LIVE, token_period: 86400 });
72
+ expect(attributes.tokenPeriod).toBe('1d');
73
+ expect(matches(attributes, props)).toBe(true);
74
+ expect(matches(attributesOf(props, { ...LIVE, token_period: 3600 }), props)).toBe(false);
75
+ });
76
+
77
+ it('leaves an unmanaged live token_period out of drift when the prop is unset', () => {
78
+ const attributes = attributesOf(PROPS, { ...LIVE, token_period: 86400 });
79
+ expect(attributes.tokenPeriod).toBe('1d');
80
+ expect(matches(attributes, PROPS)).toBe(true);
81
+ });
82
+ });
@@ -16,6 +16,14 @@ export interface BaoAuthRoleProps {
16
16
  bindSecretId?: boolean;
17
17
  /** `0` means unlimited uses — the shape reconcile.sh writes for headless roles. */
18
18
  secretIdNumUses?: number;
19
+ /**
20
+ * Token period, e.g. `24h` — set only for a periodic (forever-renewable) role, where
21
+ * `tokenTtl`/`tokenMaxTtl` are conventionally `'0'`. Omitted means this declaration does not
22
+ * manage the field: a live role's `token_period` (however it got set) is left untouched, the
23
+ * same way an omitted `bindSecretId` is. See house `openbao/approles.ts` for the role this
24
+ * unblocks — a periodic agent token that was inexpressible with these props.
25
+ */
26
+ tokenPeriod?: string;
19
27
  }
20
28
 
21
29
  export interface BaoAuthRoleAttributes {
@@ -26,6 +34,7 @@ export interface BaoAuthRoleAttributes {
26
34
  secretIdTtl: string;
27
35
  bindSecretId: boolean;
28
36
  secretIdNumUses: number;
37
+ tokenPeriod: string;
29
38
  /** SHA-256 of the managed fields — safe to persist; see policy.ts. */
30
39
  digest: string;
31
40
  }
@@ -64,6 +73,7 @@ export const attributesOf = (
64
73
  secretIdTtl: ttlText(ttlSeconds(live['secret_id_ttl']), '0'),
65
74
  bindSecretId: bool(live['bind_secret_id'], true),
66
75
  secretIdNumUses: int(live['secret_id_num_uses'], 0),
76
+ tokenPeriod: ttlText(ttlSeconds(live['token_period']), '0'),
67
77
  };
68
78
  return { ...attrs, digest: sha256(JSON.stringify(attrs)) };
69
79
  };
@@ -88,6 +98,7 @@ export const matches = (attributes: BaoAuthRoleAttributes, props: BaoAuthRolePro
88
98
  sameTtl(props.tokenMaxTtl, attributes.tokenMaxTtl) &&
89
99
  sameTtl(props.secretIdTtl, attributes.secretIdTtl) &&
90
100
  (props.bindSecretId === undefined || attributes.bindSecretId === props.bindSecretId) &&
91
- (props.secretIdNumUses === undefined || attributes.secretIdNumUses === props.secretIdNumUses)
101
+ (props.secretIdNumUses === undefined || attributes.secretIdNumUses === props.secretIdNumUses) &&
102
+ (props.tokenPeriod === undefined || sameTtl(props.tokenPeriod, attributes.tokenPeriod))
92
103
  );
93
104
  };
@@ -16,6 +16,7 @@ const DATA = {
16
16
  secret_id_num_uses: 0,
17
17
  secret_id_ttl: 7776000,
18
18
  token_max_ttl: 3600,
19
+ token_period: 0,
19
20
  token_policies: ['host-cert'],
20
21
  token_ttl: 900,
21
22
  };
@@ -35,6 +35,7 @@ const completeRole = (live: AppRoleReadRoleResponse): boolean =>
35
35
  seconds(live.secret_id_ttl) &&
36
36
  seconds(live.token_ttl) &&
37
37
  seconds(live.token_max_ttl) &&
38
+ seconds(live.token_period) &&
38
39
  Array.isArray(live.token_policies) &&
39
40
  live.token_policies.every((policy) => typeof policy === 'string');
40
41
 
@@ -56,7 +57,14 @@ export const authRoleRequest = (props: BaoAuthRoleProps) =>
56
57
  const tokenTtl = parseDuration(props.tokenTtl);
57
58
  const tokenMaxTtl = parseDuration(props.tokenMaxTtl);
58
59
  const secretIdTtl = parseDuration(props.secretIdTtl);
59
- if (!seconds(tokenTtl) || !seconds(tokenMaxTtl) || !seconds(secretIdTtl)) {
60
+ const tokenPeriod =
61
+ props.tokenPeriod === undefined ? undefined : parseDuration(props.tokenPeriod);
62
+ if (
63
+ !seconds(tokenTtl) ||
64
+ !seconds(tokenMaxTtl) ||
65
+ !seconds(secretIdTtl) ||
66
+ (props.tokenPeriod !== undefined && !seconds(tokenPeriod))
67
+ ) {
60
68
  return yield* Effect.fail(
61
69
  new BaoError(0, 'POST', readPath(props.name), ['invalid role TTL duration']),
62
70
  );
@@ -64,6 +72,9 @@ export const authRoleRequest = (props: BaoAuthRoleProps) =>
64
72
  // ★ The former CLI-style body used strings for every k=v. The SDK schema uses seconds,
65
73
  // booleans and arrays; omitted knobs stay omitted on update.
66
74
  // pathRoleCreateUpdate preserves absent bind_secret_id/secret_id_num_uses on existing roles.
75
+ // ⛔ token_period, not period — the vendor doc on both fields says period is legacy and loses
76
+ // to token_period when both are set (approle.ts AppRoleWriteRoleRequest); this Resource only
77
+ // ever emits the modern field.
67
78
  const request: AppRoleWriteRoleRequest = {
68
79
  ...identity(props.name),
69
80
  token_policies: [...props.tokenPolicies],
@@ -72,6 +83,7 @@ export const authRoleRequest = (props: BaoAuthRoleProps) =>
72
83
  secret_id_ttl: secretIdTtl,
73
84
  ...(props.bindSecretId === undefined ? {} : { bind_secret_id: props.bindSecretId }),
74
85
  ...(props.secretIdNumUses === undefined ? {} : { secret_id_num_uses: props.secretIdNumUses }),
86
+ ...(tokenPeriod === undefined ? {} : { token_period: tokenPeriod }),
75
87
  };
76
88
  return request;
77
89
  });
@@ -25,6 +25,7 @@ const ROLE = {
25
25
  secret_id_num_uses: 0,
26
26
  secret_id_ttl: 0,
27
27
  token_max_ttl: 3600,
28
+ token_period: 0,
28
29
  token_policies: ['default'],
29
30
  token_ttl: 900,
30
31
  };
@@ -57,7 +57,13 @@ export const wireRoles = (): Store =>
57
57
  store<Stored>(
58
58
  (path, body, before) =>
59
59
  path.startsWith('auth/approle/role/')
60
- ? { bind_secret_id: true, secret_id_num_uses: 0, ...before, ...typed(body) }
60
+ ? {
61
+ bind_secret_id: true,
62
+ secret_id_num_uses: 0,
63
+ token_period: 0,
64
+ ...before,
65
+ ...typed(body),
66
+ }
61
67
  : typed(body),
62
68
  (path) => keyOf(path),
63
69
  );
@@ -18,6 +18,8 @@ import * as Context from 'effect/Context';
18
18
  import * as Effect from 'effect/Effect';
19
19
  import * as Layer from 'effect/Layer';
20
20
  import type { SqlError } from 'effect/unstable/sql/SqlError';
21
+ import type { PgExecutor } from './database-sql.ts';
22
+ import { type PsqlRunner, makePsqlExecutor } from './psql-executor.ts';
21
23
 
22
24
  /** What a stack passes to reach one cluster. `host` may be a socket directory (one beginning
23
25
  * with `/` expands to `${host}/.s.PGSQL.${port}` — measured in
@@ -31,11 +33,27 @@ export interface PostgresConnectionConfig {
31
33
  readonly ssl?: boolean;
32
34
  }
33
35
 
36
+ /** The runner transport (`psql-executor.ts`): SQL goes through `run` instead of a socket, for a
37
+ * host whose Postgres is reachable only by a command (CT100: ssh + `podman exec`). `template`
38
+ * is the `CREATE DATABASE` template — `template0` by default, because CT100's `template1` is
39
+ * ParadeDB's and would copy postgis/pg_ivm/paradedb objects into every new database. */
40
+ export interface PostgresRunnerConfig {
41
+ readonly run: PsqlRunner;
42
+ readonly database: string;
43
+ readonly username: string;
44
+ readonly template?: string;
45
+ }
46
+
47
+ /** What `withPg`'s callback learns about the transport beyond the client. */
48
+ export interface PgContext {
49
+ readonly template?: string;
50
+ }
51
+
34
52
  /** The lazy connection service. Its value is an `Effect` of the config, per S24 — see the file
35
53
  * header for why. */
36
54
  export class PostgresConnection extends Context.Service<
37
55
  PostgresConnection,
38
- Effect.Effect<PostgresConnectionConfig>
56
+ Effect.Effect<PostgresConnectionConfig | PostgresRunnerConfig>
39
57
  >()('Postgres.Connection') {}
40
58
 
41
59
  /** A stack's one-line way to provide a cluster: `Layer.provide(postgresConnection({ … }))` on
@@ -44,6 +62,14 @@ export const postgresConnection = (
44
62
  config: PostgresConnectionConfig,
45
63
  ): Layer.Layer<PostgresConnection> => Layer.succeed(PostgresConnection, Effect.succeed(config));
46
64
 
65
+ /** A stack's one-line way to provide a cluster reached through a command runner. */
66
+ export const postgresRunnerConnection = (
67
+ config: PostgresRunnerConfig,
68
+ ): Layer.Layer<PostgresConnection> => Layer.succeed(PostgresConnection, Effect.succeed(config));
69
+
70
+ const isRunner = (c: PostgresConnectionConfig | PostgresRunnerConfig): c is PostgresRunnerConfig =>
71
+ 'run' in c;
72
+
47
73
  /**
48
74
  * Run one operation against a freshly built, freshly closed connection pool.
49
75
  *
@@ -53,10 +79,18 @@ export const postgresConnection = (
53
79
  * closes its own.
54
80
  */
55
81
  export const withPg = <A, E>(
56
- build: (pg: PgClient.PgClient) => Effect.Effect<A, E>,
82
+ build: (pg: PgClient.PgClient | PgExecutor, context: PgContext) => Effect.Effect<A, E>,
57
83
  ): Effect.Effect<A, E | SqlError, PostgresConnection> =>
58
84
  Effect.gen(function* () {
59
85
  const resolveConfig = yield* PostgresConnection;
60
86
  const config = yield* resolveConfig;
61
- return yield* Effect.provide(Effect.flatMap(PgClient.PgClient, build), PgClient.layer(config));
87
+ if (isRunner(config)) {
88
+ return yield* build(makePsqlExecutor(config.run, config), {
89
+ template: config.template ?? 'template0',
90
+ });
91
+ }
92
+ return yield* Effect.provide(
93
+ Effect.flatMap(PgClient.PgClient, (pg) => build(pg, {})),
94
+ PgClient.layer(config),
95
+ );
62
96
  });
@@ -47,8 +47,9 @@ export const quoteIdent = (value: string): string => `"${value.replace(/"/g, '""
47
47
  * value that is locale or encoding text, never for an identifier (see the file header). */
48
48
  export const quoteStringLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
49
49
 
50
- const withOptions = (props: PostgresDatabaseProps): string => {
50
+ const withOptions = (props: PostgresDatabaseProps, template?: string): string => {
51
51
  const parts: string[] = [`OWNER ${quoteIdent(props.owner)}`];
52
+ if (template !== undefined) parts.push(`TEMPLATE ${quoteIdent(template)}`);
52
53
  if (props.encoding !== undefined) parts.push(`ENCODING ${quoteStringLiteral(props.encoding)}`);
53
54
  if (props.localeProvider !== undefined) {
54
55
  parts.push(`LOCALE_PROVIDER ${quoteStringLiteral(props.localeProvider)}`);
@@ -74,8 +75,8 @@ const withOptions = (props: PostgresDatabaseProps): string => {
74
75
  * `Math.trunc`ed and stringified, and a boolean is one of exactly two literal words this file
75
76
  * writes, so no declared value reaches the statement through string interpolation unescaped.
76
77
  */
77
- export const buildCreateDatabaseSql = (props: PostgresDatabaseProps): string =>
78
- `CREATE DATABASE ${quoteIdent(props.name)} WITH ${withOptions(props)}`;
78
+ export const buildCreateDatabaseSql = (props: PostgresDatabaseProps, template?: string): string =>
79
+ `CREATE DATABASE ${quoteIdent(props.name)} WITH ${withOptions(props, template)}`;
79
80
 
80
81
  /** `42P04` (`duplicate_database`) is a race: something else created the name between our read
81
82
  * and our create. Measured at `internal/sqlError.ts@effect/sql-pg 4.0.0-rc.115`: every SQLSTATE
@@ -80,6 +80,7 @@ const liveDrift = (props: PostgresDatabaseProps, live: PostgresDatabaseAttribute
80
80
  export const reconcileWithClient = (
81
81
  pg: PgExecutor,
82
82
  props: PostgresDatabaseProps,
83
+ template?: string,
83
84
  ): Effect.Effect<
84
85
  PostgresDatabaseAttributes,
85
86
  PostgresDatabaseOwnerMissing | PostgresDatabaseDrift | PostgresDatabaseCreateVanished | SqlError
@@ -93,7 +94,7 @@ export const reconcileWithClient = (
93
94
  new PostgresDatabaseOwnerMissing({ database: props.name, owner: props.owner }),
94
95
  );
95
96
  }
96
- yield* pg.unsafe(buildCreateDatabaseSql(props)).pipe(
97
+ yield* pg.unsafe(buildCreateDatabaseSql(props, template)).pipe(
97
98
  Effect.asVoid,
98
99
  Effect.catchTag('SqlError', (error) =>
99
100
  isDuplicateDatabaseRace(error) ? Effect.succeed(undefined) : Effect.fail(error),
@@ -183,7 +184,7 @@ export const postgresDatabaseHandlers = PostgresDatabase.Provider.of({
183
184
  new PostgresDatabaseNameRefused({ name: news.name, ...nameRefusal }),
184
185
  );
185
186
  }
186
- return yield* withPg((pg) => reconcileWithClient(pg, news));
187
+ return yield* withPg((pg, ctx) => reconcileWithClient(pg, news, ctx.template));
187
188
  }),
188
189
 
189
190
  delete: ({ olds }) => Effect.fail(new PostgresDatabaseDropRefused({ database: olds.name })),
@@ -5,8 +5,15 @@
5
5
  * ⛔ THIS BARREL IS THE PUBLIC API, deliberately smaller than the directory: `fake-sql.ts` is a
6
6
  * test double, not something a consuming stack should import.
7
7
  */
8
- export type { PostgresConnectionConfig } from './connection.ts';
9
- export { PostgresConnection, postgresConnection, withPg } from './connection.ts';
8
+ export type { PostgresConnectionConfig, PostgresRunnerConfig } from './connection.ts';
9
+ export {
10
+ PostgresConnection,
11
+ postgresConnection,
12
+ postgresRunnerConnection,
13
+ withPg,
14
+ } from './connection.ts';
15
+ export type { PsqlResult, PsqlRunner } from './psql-executor.ts';
16
+ export { inlineParams, makePsqlExecutor } from './psql-executor.ts';
10
17
  export type { PostgresDatabaseAttributes, PostgresDatabaseProps } from './database-attrs.ts';
11
18
  export { POSTGRES_NAME_MAX_BYTES, nameByteRefusal, utf8ByteLength } from './database-attrs.ts';
12
19
  export {
@@ -27,4 +34,4 @@ export {
27
34
  PostgresDatabaseRenameRefused,
28
35
  type PostgresDatabaseError,
29
36
  } from './errors.ts';
30
- export { postgresProviders } from './providers.ts';
37
+ export { postgresProviders, postgresRunnerProviders } from './providers.ts';
@@ -9,8 +9,21 @@
9
9
  * the results under whatever logical grouping it wants.
10
10
  */
11
11
  import * as Layer from 'effect/Layer';
12
- import { type PostgresConnectionConfig, postgresConnection } from './connection.ts';
12
+ import {
13
+ type PostgresConnectionConfig,
14
+ type PostgresRunnerConfig,
15
+ postgresConnection,
16
+ postgresRunnerConnection,
17
+ } from './connection.ts';
13
18
  import { PostgresDatabaseProvider } from './database.ts';
14
19
 
20
+ // ⚠️ `Layer.provideMerge`, NEVER plain `Layer.provide`: the handlers' `withPg` reads
21
+ // `PostgresConnection` when the ENGINE later calls `read`/`reconcile`, and plain `provide`
22
+ // seals it away ("Service not found: Postgres.Connection" — measured for `caddyProviders`,
23
+ // see `caddy/providers.ts`).
15
24
  export const postgresProviders = (config: PostgresConnectionConfig) =>
16
- PostgresDatabaseProvider().pipe(Layer.provide(postgresConnection(config)));
25
+ PostgresDatabaseProvider().pipe(Layer.provideMerge(postgresConnection(config)));
26
+
27
+ /** The same provider over a command runner (`psql-executor.ts`) — for a loopback-only cluster. */
28
+ export const postgresRunnerProviders = (config: PostgresRunnerConfig) =>
29
+ PostgresDatabaseProvider().pipe(Layer.provideMerge(postgresRunnerConnection(config)));
@@ -0,0 +1,106 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import * as Effect from 'effect/Effect';
3
+ import { postgresRunnerProviders } from './providers.ts';
4
+ import { postgresRunnerConnection, withPg } from './connection.ts';
5
+ import { reconcileWithClient } from './database.ts';
6
+ import { buildCreateDatabaseSql, isDuplicateDatabaseRace } from './database-sql.ts';
7
+ import { type PsqlRunner, inlineParams, makePsqlExecutor } from './psql-executor.ts';
8
+
9
+ const target = { database: 'postgres', username: 'postgres' };
10
+ const ok = (stdout: string) => Promise.resolve({ code: 0, stdout, stderr: '' });
11
+
12
+ describe('psql executor', () => {
13
+ test('inlines string params as literals and refuses non-strings', () => {
14
+ expect(inlineParams('SELECT $1, $2', ["it's", 'b'])).toBe("SELECT 'it''s', 'b'");
15
+ expect(() => inlineParams('SELECT $1', [5])).toThrow('must be a string');
16
+ });
17
+
18
+ test('a SELECT is wrapped in json_agg and parsed to rows', async () => {
19
+ const calls: { argv: readonly string[]; stdin: string }[] = [];
20
+ const run: PsqlRunner = (call) => {
21
+ calls.push(call);
22
+ return ok('[{"oid":"16400","name":"x"}]\n');
23
+ };
24
+ const rows = await Effect.runPromise(
25
+ makePsqlExecutor(run, target).unsafe('SELECT 1 FROM pg_database WHERE datname = $1', ['x']),
26
+ );
27
+ expect(rows).toEqual([{ oid: 16400, name: 'x' }]);
28
+ expect(calls[0]?.argv.slice(0, 1)).toEqual(['psql']);
29
+ expect(calls[0]?.stdin).toContain('json_agg');
30
+ expect(calls[0]?.stdin).toContain("datname = 'x'");
31
+ });
32
+
33
+ test('a duplicate-database failure stays recognisable as the 42P04 race', async () => {
34
+ const run: PsqlRunner = () =>
35
+ Promise.resolve({
36
+ code: 3,
37
+ stdout: '',
38
+ stderr: 'ERROR: 42P04: database "x" already exists\n',
39
+ });
40
+ const error = await Effect.runPromise(
41
+ Effect.flip(makePsqlExecutor(run, target).unsafe('CREATE DATABASE "x"')),
42
+ );
43
+ expect(isDuplicateDatabaseRace(error)).toBe(true);
44
+ });
45
+
46
+ test('a runner that never reached psql is a ConnectionError', async () => {
47
+ const run: PsqlRunner = () =>
48
+ Promise.resolve({ code: 255, stdout: '', stderr: 'ssh: refused' });
49
+ const error = await Effect.runPromise(
50
+ Effect.flip(makePsqlExecutor(run, target).unsafe('SELECT 1')),
51
+ );
52
+ expect(error.reason._tag).toBe('ConnectionError');
53
+ });
54
+ });
55
+
56
+ describe('runner transport through withPg', () => {
57
+ test('create uses template0 by default and the same adopt path on an existing row', async () => {
58
+ const stdins: string[] = [];
59
+ const row = {
60
+ oid: 1,
61
+ name: 'lite',
62
+ owner: 'litellm',
63
+ encoding: 'UTF8',
64
+ localeProvider: 'libc',
65
+ collate: 'C',
66
+ ctype: 'C',
67
+ allowConnections: true,
68
+ connectionLimit: -1,
69
+ isTemplate: false,
70
+ tablespace: 'pg_default',
71
+ };
72
+ let created = false;
73
+ const run: PsqlRunner = ({ stdin }) => {
74
+ stdins.push(stdin);
75
+ if (stdin.includes('pg_roles')) return ok('[{"present":1}]');
76
+ if (stdin.includes('FROM pg_database')) return ok(created ? JSON.stringify([row]) : '[]');
77
+ if (stdin.startsWith('CREATE DATABASE')) created = true;
78
+ return ok('');
79
+ };
80
+ const layer = postgresRunnerConnection({ run, ...target });
81
+ const props = { name: 'lite', owner: 'litellm' };
82
+ const attrs = await Effect.runPromise(
83
+ withPg((pg, ctx) => reconcileWithClient(pg, props, ctx.template)).pipe(Effect.provide(layer)),
84
+ );
85
+ expect(attrs.name).toBe('lite');
86
+ expect(stdins.find((s) => s.startsWith('CREATE DATABASE'))).toBe(
87
+ `${buildCreateDatabaseSql(props, 'template0')};`,
88
+ );
89
+ expect(stdins.find((s) => s.startsWith('CREATE DATABASE'))).toContain('TEMPLATE "template0"');
90
+ const before = stdins.length;
91
+ await Effect.runPromise(
92
+ withPg((pg, ctx) => reconcileWithClient(pg, props, ctx.template)).pipe(Effect.provide(layer)),
93
+ );
94
+ expect(stdins.slice(before).some((s) => s.startsWith('CREATE DATABASE'))).toBe(false);
95
+ });
96
+
97
+ test("postgresRunnerProviders keeps the connection live for the engine's later handler calls", async () => {
98
+ const run: PsqlRunner = () => ok('[]');
99
+ const rows = await Effect.runPromise(
100
+ withPg((pg) => pg.unsafe('SELECT 1')).pipe(
101
+ Effect.provide(postgresRunnerProviders({ run, ...target })),
102
+ ),
103
+ );
104
+ expect(rows).toEqual([]);
105
+ });
106
+ });