@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.
- package/dist/litellm/budget-errors.d.ts +36 -0
- package/dist/litellm/budget-errors.d.ts.map +1 -0
- package/dist/litellm/budget-form.d.ts +50 -0
- package/dist/litellm/budget-form.d.ts.map +1 -0
- package/dist/litellm/budget-operations.d.ts +20 -0
- package/dist/litellm/budget-operations.d.ts.map +1 -0
- package/dist/litellm/budget.d.ts +61 -0
- package/dist/litellm/budget.d.ts.map +1 -0
- package/dist/litellm/fake-litellm.d.ts +5 -0
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +2 -0
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +125 -2
- package/dist/litellm/index.js.map +9 -5
- package/dist/litellm/operations.d.ts +22 -0
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +1 -1
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-wire.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +9 -6
- package/dist/openbao/index.js.map +4 -4
- package/dist/postgres/connection.d.ts +20 -2
- package/dist/postgres/connection.d.ts.map +1 -1
- package/dist/postgres/database-sql.d.ts +1 -1
- package/dist/postgres/database-sql.d.ts.map +1 -1
- package/dist/postgres/database.d.ts +1 -1
- package/dist/postgres/database.d.ts.map +1 -1
- package/dist/postgres/index.d.ts +5 -3
- package/dist/postgres/index.d.ts.map +1 -1
- package/dist/postgres/index.js +124 -38
- package/dist/postgres/index.js.map +8 -7
- package/dist/postgres/providers.d.ts +4 -2
- package/dist/postgres/providers.d.ts.map +1 -1
- package/dist/postgres/psql-executor.d.ts +21 -0
- package/dist/postgres/psql-executor.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag-types.d.ts +57 -0
- package/dist/proxmox/ceph-flag-types.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag-wire.d.ts +77 -0
- package/dist/proxmox/ceph-flag-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag.d.ts +15 -50
- package/dist/proxmox/ceph-flag.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +10 -13
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/index.js +772 -740
- package/dist/proxmox/index.js.map +8 -7
- package/docs/litellm-budget.md +55 -0
- package/docs/litellm.md +7 -6
- package/docs/postgres.md +22 -0
- package/docs/proxmox-sdk-migration.md +45 -0
- package/package.json +3 -3
- package/src/litellm/budget-errors.ts +38 -0
- package/src/litellm/budget-form.ts +94 -0
- package/src/litellm/budget-operations.ts +51 -0
- package/src/litellm/budget.test.ts +105 -0
- package/src/litellm/budget.ts +127 -0
- package/src/litellm/fake-litellm.ts +50 -1
- package/src/litellm/index.ts +13 -0
- package/src/litellm/operations.ts +1 -1
- package/src/litellm/providers.ts +4 -1
- package/src/openbao/auth-role-form.test.ts +37 -0
- package/src/openbao/auth-role-form.ts +12 -1
- package/src/openbao/auth-role-wire.test.ts +1 -0
- package/src/openbao/auth-role-wire.ts +13 -1
- package/src/openbao/distilled-retry.test.ts +1 -0
- package/src/openbao/fake-engines-roles.ts +7 -1
- package/src/postgres/connection.ts +37 -3
- package/src/postgres/database-sql.ts +4 -3
- package/src/postgres/database.ts +3 -2
- package/src/postgres/index.ts +10 -3
- package/src/postgres/providers.ts +15 -2
- package/src/postgres/psql-executor.test.ts +106 -0
- package/src/postgres/psql-executor.ts +130 -0
- package/src/proxmox/ceph-flag-types.ts +70 -0
- package/src/proxmox/ceph-flag-wire.test.ts +72 -0
- package/src/proxmox/ceph-flag-wire.ts +123 -0
- package/src/proxmox/ceph-flag.ts +77 -130
- 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 {
|
|
164
|
+
return {
|
|
165
|
+
budgets: () => [...budgets],
|
|
166
|
+
fetch,
|
|
167
|
+
requests: () => [...requests],
|
|
168
|
+
rows: () => [...rows],
|
|
169
|
+
};
|
|
121
170
|
};
|
package/src/litellm/index.ts
CHANGED
|
@@ -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>(
|
package/src/litellm/providers.ts
CHANGED
|
@@ -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(
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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
|
});
|
|
@@ -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
|
-
? {
|
|
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
|
-
|
|
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
|
package/src/postgres/database.ts
CHANGED
|
@@ -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 })),
|
package/src/postgres/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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.
|
|
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
|
+
});
|