@ocel/transforms 0.0.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/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @ocel/transforms
2
+
3
+ Patch the underlying cloud resources ocel provisions, from a module ocel runs
4
+ before it provisions anything.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pnpm add -D @ocel/transforms
10
+ ```
11
+
12
+ ## Use
13
+
14
+ Write a module, and list it in `ocel.json`:
15
+
16
+ ```json
17
+ {
18
+ "transforms": ["./transforms/network.transform.ts"]
19
+ }
20
+ ```
21
+
22
+ ```ts
23
+ import { defineTransform } from "@ocel/transforms";
24
+
25
+ export default defineTransform(({ bindings, envClass }) => ({
26
+ aws: {
27
+ function: {
28
+ lambda: {
29
+ memorySize: envClass === "production" ? 2048 : 512,
30
+ vpcConfig: { subnetIds: bindings.custom.network.subnetIds },
31
+ },
32
+ },
33
+ },
34
+ }));
35
+ ```
36
+
37
+ The keys under `aws` are the ocel resources; the keys under those are the
38
+ Pulumi resources the provider constructs for them, typed from `@pulumi/aws`.
39
+
40
+ `bindings.<type>.<name>.<property>` reads a record your own infrastructure
41
+ published — `<type>.<name>` is the key your `ocel.json` binds under, and
42
+ `custom.<name>` reads a record nothing declared. Run `ocel bindings generate`
43
+ to write those names and their types down.
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,83 @@
1
+ import type { cloudwatch, ec2, iam, lambda, rds, s3 } from "@pulumi/aws";
2
+ import type { Unwrap } from "@pulumi/pulumi";
3
+ import type { Patch } from "../patch";
4
+ /**
5
+ * One key per Pulumi resource the AWS provider constructs for an ocel resource,
6
+ * named after the pulumi-aws module it comes from.
7
+ */
8
+ export interface AwsResourceArgs {
9
+ function: {
10
+ lambda: lambda.FunctionArgs;
11
+ url: lambda.FunctionUrlArgs;
12
+ urlPermission: lambda.PermissionArgs;
13
+ logGroup: cloudwatch.LogGroupArgs;
14
+ role: iam.RoleArgs;
15
+ };
16
+ bucket: {
17
+ bucket: s3.BucketV2Args;
18
+ publicAccessBlock: s3.BucketPublicAccessBlockArgs;
19
+ cors: s3.BucketCorsConfigurationV2Args;
20
+ uploadCompleterRole: iam.RoleArgs;
21
+ uploadCompleterS3Policy: iam.RolePolicyArgs;
22
+ uploadCompleterSessionsPolicy: iam.RolePolicyArgs;
23
+ uploadCompleterLogsPolicy: iam.RolePolicyAttachmentArgs;
24
+ uploadCompleterLogGroup: cloudwatch.LogGroupArgs;
25
+ uploadCompleter: lambda.FunctionArgs;
26
+ uploadCompleterPermission: lambda.PermissionArgs;
27
+ notification: s3.BucketNotificationArgs;
28
+ };
29
+ postgres: {
30
+ securityGroup: ec2.SecurityGroupArgs;
31
+ subnetGroup: rds.SubnetGroupArgs;
32
+ cluster: rds.ClusterArgs;
33
+ instance: rds.ClusterInstanceArgs;
34
+ };
35
+ }
36
+ /** The ocel resource types the AWS provider renders patchable resources for. */
37
+ export type AwsResourceType = keyof AwsResourceArgs;
38
+ /**
39
+ * The fields ocel fills from its own state — the names it minted, the ARNs it
40
+ * created, the code it uploaded and the runtime that code was built for, the
41
+ * environment it sealed, the tags it sweeps by. A patch naming one is refused
42
+ * where it is written, and the deploy refuses it again by name.
43
+ */
44
+ export declare const awsOwnedFields: {
45
+ readonly function: {
46
+ readonly lambda: readonly ["architectures", "code", "environment", "handler", "imageUri", "layers", "loggingConfig", "name", "packageType", "role", "runtime", "s3Bucket", "s3Key", "s3ObjectVersion", "sourceCodeHash", "tags"];
47
+ readonly url: readonly ["authorizationType", "functionName", "qualifier"];
48
+ readonly urlPermission: readonly ["action", "function", "functionUrlAuthType", "principal", "qualifier"];
49
+ readonly logGroup: readonly ["name", "namePrefix", "tags"];
50
+ readonly role: readonly ["assumeRolePolicy", "inlinePolicies", "managedPolicyArns", "name", "namePrefix", "permissionsBoundary", "tags"];
51
+ };
52
+ readonly bucket: {
53
+ readonly bucket: readonly ["bucket", "bucketPrefix", "tags"];
54
+ readonly publicAccessBlock: readonly ["bucket"];
55
+ readonly cors: readonly ["bucket"];
56
+ readonly uploadCompleterRole: readonly ["assumeRolePolicy", "inlinePolicies", "managedPolicyArns", "name", "namePrefix", "permissionsBoundary", "tags"];
57
+ readonly uploadCompleterS3Policy: readonly ["name", "namePrefix", "policy", "role"];
58
+ readonly uploadCompleterSessionsPolicy: readonly ["name", "namePrefix", "policy", "role"];
59
+ readonly uploadCompleterLogsPolicy: readonly ["policyArn", "role"];
60
+ readonly uploadCompleterLogGroup: readonly ["name", "namePrefix", "tags"];
61
+ readonly uploadCompleter: readonly ["architectures", "code", "environment", "handler", "imageUri", "loggingConfig", "name", "packageType", "role", "runtime", "s3Bucket", "s3Key", "s3ObjectVersion", "sourceCodeHash", "tags"];
62
+ readonly uploadCompleterPermission: readonly ["action", "function", "principal", "qualifier", "sourceArn"];
63
+ readonly notification: readonly ["bucket", "lambdaFunctions"];
64
+ };
65
+ readonly postgres: {
66
+ readonly securityGroup: readonly ["name", "namePrefix", "tags", "vpcId"];
67
+ readonly subnetGroup: readonly ["name", "namePrefix", "subnetIds", "tags"];
68
+ readonly cluster: readonly ["clusterIdentifier", "clusterIdentifierPrefix", "databaseName", "dbSubnetGroupName", "engine", "manageMasterUserPassword", "masterPassword", "masterUsername", "tags", "vpcSecurityGroupIds"];
69
+ readonly instance: readonly ["clusterIdentifier", "engine", "engineVersion", "identifier", "identifierPrefix", "tags"];
70
+ };
71
+ };
72
+ type AwsOwned = typeof awsOwnedFields;
73
+ type OwnedNames<L> = L extends readonly (infer F)[] ? Extract<F, string> : never;
74
+ /**
75
+ * What a rule may patch under `aws`, typed from the args pulumi-aws takes with
76
+ * the fields ocel owns removed. A binding output stands in for any leaf.
77
+ */
78
+ export type AwsSurfaces = {
79
+ [T in AwsResourceType]: {
80
+ [K in keyof AwsResourceArgs[T]]: Patch<Omit<Unwrap<AwsResourceArgs[T][K]>, OwnedNames<AwsOwned[T][K & keyof AwsOwned[T]]>>>;
81
+ };
82
+ };
83
+ export {};
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The fields ocel fills from its own state — the names it minted, the ARNs it
3
+ * created, the code it uploaded and the runtime that code was built for, the
4
+ * environment it sealed, the tags it sweeps by. A patch naming one is refused
5
+ * where it is written, and the deploy refuses it again by name.
6
+ */
7
+ export const awsOwnedFields = {
8
+ function: {
9
+ lambda: [
10
+ "architectures",
11
+ "code",
12
+ "environment",
13
+ "handler",
14
+ "imageUri",
15
+ "layers",
16
+ "loggingConfig",
17
+ "name",
18
+ "packageType",
19
+ "role",
20
+ "runtime",
21
+ "s3Bucket",
22
+ "s3Key",
23
+ "s3ObjectVersion",
24
+ "sourceCodeHash",
25
+ "tags",
26
+ ],
27
+ url: ["authorizationType", "functionName", "qualifier"],
28
+ urlPermission: ["action", "function", "functionUrlAuthType", "principal", "qualifier"],
29
+ logGroup: ["name", "namePrefix", "tags"],
30
+ role: [
31
+ "assumeRolePolicy",
32
+ "inlinePolicies",
33
+ "managedPolicyArns",
34
+ "name",
35
+ "namePrefix",
36
+ "permissionsBoundary",
37
+ "tags",
38
+ ],
39
+ },
40
+ bucket: {
41
+ bucket: ["bucket", "bucketPrefix", "tags"],
42
+ publicAccessBlock: ["bucket"],
43
+ cors: ["bucket"],
44
+ uploadCompleterRole: [
45
+ "assumeRolePolicy",
46
+ "inlinePolicies",
47
+ "managedPolicyArns",
48
+ "name",
49
+ "namePrefix",
50
+ "permissionsBoundary",
51
+ "tags",
52
+ ],
53
+ uploadCompleterS3Policy: ["name", "namePrefix", "policy", "role"],
54
+ uploadCompleterSessionsPolicy: ["name", "namePrefix", "policy", "role"],
55
+ uploadCompleterLogsPolicy: ["policyArn", "role"],
56
+ uploadCompleterLogGroup: ["name", "namePrefix", "tags"],
57
+ uploadCompleter: [
58
+ "architectures",
59
+ "code",
60
+ "environment",
61
+ "handler",
62
+ "imageUri",
63
+ "loggingConfig",
64
+ "name",
65
+ "packageType",
66
+ "role",
67
+ "runtime",
68
+ "s3Bucket",
69
+ "s3Key",
70
+ "s3ObjectVersion",
71
+ "sourceCodeHash",
72
+ "tags",
73
+ ],
74
+ uploadCompleterPermission: ["action", "function", "principal", "qualifier", "sourceArn"],
75
+ notification: ["bucket", "lambdaFunctions"],
76
+ },
77
+ postgres: {
78
+ securityGroup: ["name", "namePrefix", "tags", "vpcId"],
79
+ subnetGroup: ["name", "namePrefix", "subnetIds", "tags"],
80
+ cluster: [
81
+ "clusterIdentifier",
82
+ "clusterIdentifierPrefix",
83
+ "databaseName",
84
+ "dbSubnetGroupName",
85
+ "engine",
86
+ "manageMasterUserPassword",
87
+ "masterPassword",
88
+ "masterUsername",
89
+ "tags",
90
+ "vpcSecurityGroupIds",
91
+ ],
92
+ instance: [
93
+ "clusterIdentifier",
94
+ "engine",
95
+ "engineVersion",
96
+ "identifier",
97
+ "identifierPrefix",
98
+ "tags",
99
+ ],
100
+ },
101
+ };
@@ -0,0 +1,71 @@
1
+ import type { AwsSurfaces } from "./aws";
2
+ import type { TransformBindings } from "./index";
3
+ import type { VpsSurfaces } from "./vps";
4
+ /** The environment classes a deploy can target. */
5
+ export type EnvClass = "development" | "preview" | "production";
6
+ /**
7
+ * What a rule's `if` gate is allowed to decide on: the environment being
8
+ * deployed and the app a candidate resource belongs to. Resources shared
9
+ * across apps carry no `app`, so `ctx.app === "api"` is false for them.
10
+ */
11
+ export interface GateContext {
12
+ readonly envClass: EnvClass;
13
+ readonly env: string;
14
+ readonly app: string | undefined;
15
+ }
16
+ /** Decides whether a rule applies, from ambient context alone. */
17
+ export type Gate = (ctx: GateContext) => boolean;
18
+ /**
19
+ * Tags a rule unions into every taggable resource it reaches. Keys under the
20
+ * `ocel:` prefix are ocel's own and are rejected at deploy.
21
+ */
22
+ export type TagMap = Record<string, string>;
23
+ /** The prefix ocel reserves for the tags it writes itself. */
24
+ export declare const reservedTagPrefix = "ocel:";
25
+ /** What a provider renders that a transform may patch, keyed by provider name. */
26
+ export interface ProviderSurfaces {
27
+ aws: AwsSurfaces;
28
+ vps: VpsSurfaces;
29
+ }
30
+ /** The providers that render patchable resources. */
31
+ export type ProviderName = keyof ProviderSurfaces;
32
+ /**
33
+ * One rule: an optional gate, tags to union into every resource it reaches,
34
+ * and a patch per underlying resource, under the provider that renders it.
35
+ */
36
+ export type TransformRule = {
37
+ readonly if?: Gate;
38
+ readonly tags?: TagMap;
39
+ } & {
40
+ readonly [P in ProviderName]?: {
41
+ readonly [T in keyof ProviderSurfaces[P]]?: {
42
+ readonly [K in keyof ProviderSurfaces[P][T]]?: ProviderSurfaces[P][T][K];
43
+ };
44
+ };
45
+ };
46
+ /** Keys a rule may carry besides the providers it targets. */
47
+ export declare const ruleKeywords: readonly ["if", "tags"];
48
+ /** What a callback form of `defineTransform` is handed, and nothing besides. */
49
+ export interface TransformInputs {
50
+ readonly bindings: TransformBindings;
51
+ readonly envClass: EnvClass;
52
+ readonly env: string;
53
+ }
54
+ /** The rules a module contributes, written down or returned from the callback. */
55
+ export type TransformRules = TransformRule | readonly TransformRule[];
56
+ /** A module's default export: the rules it contributes, once the deploy asks. */
57
+ export interface TransformDefinition {
58
+ readonly rules: (inputs: TransformInputs) => readonly TransformRule[];
59
+ }
60
+ /**
61
+ * Declares the rules a transform module contributes. Rules apply in the order
62
+ * written, and modules in the order `transforms` lists them, later winning.
63
+ *
64
+ * The callback form is handed the environment being deployed and `bindings`,
65
+ * the placeholders for the records bound to this project:
66
+ * `bindings.custom.network.subnetIds` is filled by the deploy, so the rules
67
+ * stay data a reviewer can read.
68
+ */
69
+ export declare function defineTransform(rules: TransformRules | ((inputs: TransformInputs) => TransformRules)): TransformDefinition;
70
+ /** Whether a module's default export came from `defineTransform`. */
71
+ export declare function isTransformDefinition(value: unknown): value is TransformDefinition;
package/dist/define.js ADDED
@@ -0,0 +1,29 @@
1
+ /** The prefix ocel reserves for the tags it writes itself. */
2
+ export const reservedTagPrefix = "ocel:";
3
+ /** Keys a rule may carry besides the providers it targets. */
4
+ export const ruleKeywords = ["if", "tags"];
5
+ /**
6
+ * Declares the rules a transform module contributes. Rules apply in the order
7
+ * written, and modules in the order `transforms` lists them, later winning.
8
+ *
9
+ * The callback form is handed the environment being deployed and `bindings`,
10
+ * the placeholders for the records bound to this project:
11
+ * `bindings.custom.network.subnetIds` is filled by the deploy, so the rules
12
+ * stay data a reviewer can read.
13
+ */
14
+ export function defineTransform(rules) {
15
+ return {
16
+ rules: (inputs) => {
17
+ const authored = typeof rules === "function" ? rules(inputs) : rules;
18
+ return Array.isArray(authored)
19
+ ? authored
20
+ : [authored];
21
+ },
22
+ };
23
+ }
24
+ /** Whether a module's default export came from `defineTransform`. */
25
+ export function isTransformDefinition(value) {
26
+ return (typeof value === "object" &&
27
+ value !== null &&
28
+ typeof value.rules === "function");
29
+ }
@@ -0,0 +1,42 @@
1
+ import { type EnvClass, type TagMap, type TransformDefinition } from "./define";
2
+ /**
3
+ * What one ocel resource's rules came to: a patch per underlying resource the
4
+ * provider constructs for it, keyed by the same key the rules were written
5
+ * under and carrying the provider SDK's own property names.
6
+ */
7
+ export type Patches = Record<string, Record<string, unknown>>;
8
+ /** One resource the deploy offers the modules, as the deploy addresses it. */
9
+ export interface RequestResource {
10
+ readonly type: string;
11
+ readonly name: string;
12
+ readonly app?: string;
13
+ }
14
+ /** What a deploy asks the modules about: the environment, and every candidate in it. */
15
+ export interface EvaluateRequest {
16
+ readonly provider: string;
17
+ readonly envClass: EnvClass;
18
+ readonly env: string;
19
+ readonly resources: readonly RequestResource[];
20
+ }
21
+ /** What the modules came to for one candidate, in the order the request listed it. */
22
+ export interface EvaluatedResource {
23
+ readonly name: string;
24
+ readonly patches: Patches;
25
+ readonly tags: TagMap;
26
+ }
27
+ /** The answer to one request, one entry per candidate the request carried. */
28
+ export interface EvaluateResponse {
29
+ readonly resources: readonly EvaluatedResource[];
30
+ }
31
+ /** A loaded module, paired with the specifier a refusal names it by. */
32
+ export interface TransformModule {
33
+ readonly specifier: string;
34
+ readonly definition: TransformDefinition;
35
+ }
36
+ /**
37
+ * Applies the modules in order to every candidate the deploy offers, later
38
+ * modules winning. A rule that names a field ocel owns, a resource this
39
+ * provider does not construct, or a provider this project does not deploy to
40
+ * throws where it was written, and the deploy stops.
41
+ */
42
+ export declare function evaluate(request: EvaluateRequest, modules: readonly TransformModule[]): EvaluateResponse;
@@ -0,0 +1,135 @@
1
+ import { awsOwnedFields } from "./aws";
2
+ import { reservedTagPrefix, ruleKeywords, } from "./define";
3
+ import { bindings, isBindingOutput } from "./output";
4
+ import { vpsOwnedFields } from "./vps";
5
+ const ownedFields = {
6
+ aws: awsOwnedFields,
7
+ vps: vpsOwnedFields,
8
+ };
9
+ /**
10
+ * Applies the modules in order to every candidate the deploy offers, later
11
+ * modules winning. A rule that names a field ocel owns, a resource this
12
+ * provider does not construct, or a provider this project does not deploy to
13
+ * throws where it was written, and the deploy stops.
14
+ */
15
+ export function evaluate(request, modules) {
16
+ const loaded = modules.map((module) => load(request, module));
17
+ return {
18
+ resources: request.resources.map((resource) => evaluateResource(request, resource, loaded)),
19
+ };
20
+ }
21
+ function load(request, module) {
22
+ const rules = module.definition.rules({
23
+ bindings: bindings,
24
+ envClass: request.envClass,
25
+ env: request.env,
26
+ });
27
+ const branches = new Set();
28
+ for (const rule of rules) {
29
+ for (const key of Object.keys(rule)) {
30
+ if (ruleKeywords.includes(key))
31
+ continue;
32
+ branches.add(key);
33
+ }
34
+ }
35
+ if (!branches.has(request.provider)) {
36
+ throw new Error(`${module.specifier} patches ${listed(branches)} and this project deploys to ${request.provider}; give the module an ${request.provider} branch, or drop it from "transforms"`);
37
+ }
38
+ return { specifier: module.specifier, rules };
39
+ }
40
+ function listed(branches) {
41
+ const names = [...branches].sort();
42
+ if (names.length === 0)
43
+ return "no provider";
44
+ return names.join(", ");
45
+ }
46
+ function evaluateResource(request, resource, modules) {
47
+ const patches = {};
48
+ const tags = {};
49
+ const ctx = Object.freeze({
50
+ envClass: request.envClass,
51
+ env: request.env,
52
+ app: resource.app,
53
+ });
54
+ for (const module of modules) {
55
+ for (const rule of module.rules) {
56
+ checkRuleKeys(module.specifier, rule, request.provider);
57
+ if (rule.if !== undefined && !rule.if(ctx))
58
+ continue;
59
+ collectTags(module.specifier, rule.tags, tags);
60
+ const branch = rule[request.provider];
61
+ if (branch === undefined)
62
+ continue;
63
+ const group = readGroup(module.specifier, request.provider, branch, resource.type);
64
+ if (group === undefined)
65
+ continue;
66
+ for (const [key, patch] of Object.entries(group)) {
67
+ if (patch === undefined)
68
+ continue;
69
+ applyPatch(module.specifier, request.provider, resource.type, key, patch, patches);
70
+ }
71
+ }
72
+ }
73
+ return { name: resource.name, patches, tags };
74
+ }
75
+ function checkRuleKeys(specifier, rule, provider) {
76
+ for (const key of Object.keys(rule)) {
77
+ if (ruleKeywords.includes(key))
78
+ continue;
79
+ if (key === provider)
80
+ continue;
81
+ if (Object.hasOwn(ownedFields, key))
82
+ continue;
83
+ throw new Error(`${specifier}: a rule targets ${key}, which is neither a provider nor ${ruleKeywords.join(", ")}`);
84
+ }
85
+ }
86
+ function collectTags(specifier, authored, tags) {
87
+ if (authored === undefined)
88
+ return;
89
+ for (const [key, value] of Object.entries(authored)) {
90
+ if (key.startsWith(reservedTagPrefix)) {
91
+ throw new Error(`${specifier}: tag ${key} is ocel's own — the ${reservedTagPrefix} prefix is reserved`);
92
+ }
93
+ tags[key] = value;
94
+ }
95
+ }
96
+ function readGroup(specifier, provider, branch, type) {
97
+ const known = ownedFields[provider];
98
+ if (known === undefined) {
99
+ throw new Error(`${specifier}: this build renders nothing for ${provider}`);
100
+ }
101
+ const rendered = branch;
102
+ for (const key of Object.keys(rendered)) {
103
+ if (!Object.hasOwn(known, key)) {
104
+ throw new Error(`${specifier}: a rule patches ${provider}.${key}, which is not a resource this provider renders (it renders ${Object.keys(known).join(", ")})`);
105
+ }
106
+ }
107
+ const group = rendered[type];
108
+ return group === undefined ? undefined : group;
109
+ }
110
+ function applyPatch(specifier, provider, type, key, patch, into) {
111
+ const owned = ownedFields[provider]?.[type]?.[key];
112
+ if (owned === undefined) {
113
+ const rendered = Object.keys(ownedFields[provider]?.[type] ?? {}).join(", ");
114
+ throw new Error(`${specifier}: a rule patches ${provider}.${type}.${key}, which this provider does not construct (it constructs ${rendered})`);
115
+ }
116
+ const fields = patch;
117
+ for (const field of Object.keys(fields)) {
118
+ if (owned.includes(field)) {
119
+ throw new Error(`${specifier}: a rule sets ${provider}.${type}.${key}.${field}, which ocel fills from what this deploy built; drop it and let ocel own it`);
120
+ }
121
+ }
122
+ into[key] = mergePatch(into[key] ?? {}, fields);
123
+ }
124
+ function mergePatch(base, over) {
125
+ if (!plainObject(base) || !plainObject(over))
126
+ return over;
127
+ const merged = { ...base };
128
+ for (const [key, value] of Object.entries(over)) {
129
+ merged[key] = mergePatch(merged[key], value);
130
+ }
131
+ return merged;
132
+ }
133
+ function plainObject(value) {
134
+ return (typeof value === "object" && value !== null && !Array.isArray(value) && !isBindingOutput(value));
135
+ }
@@ -0,0 +1,31 @@
1
+ export type { EnvClass, Gate, GateContext, ProviderName, ProviderSurfaces, TagMap, TransformDefinition, TransformInputs, TransformRule, TransformRules, } from "./define";
2
+ export { defineTransform } from "./define";
3
+ export type { Patch } from "./patch";
4
+ import { type BindingPlaceholdersOf } from "./output";
5
+ /**
6
+ * The records this project's config binds, keyed by resource type and then by
7
+ * the name the config keys them under, with `custom` holding the records
8
+ * nothing declared. `ocel bindings generate` writes an augmentation of this
9
+ * interface from the records themselves; until something does, every name is
10
+ * open and the deploy is what checks it.
11
+ */
12
+ export interface Bindings {
13
+ }
14
+ /**
15
+ * Whether `ocel bindings generate` has written the records down. It augments this
16
+ * separately from `Bindings`, so a coordinate that published nothing still closes
17
+ * `Bindings` to the empty set instead of reading as never generated.
18
+ */
19
+ export interface BindingsGenerated {
20
+ }
21
+ /** The placeholders a transform module reads, narrowed by whatever was generated. */
22
+ export type TransformBindings = BindingPlaceholdersOf<Bindings, BindingsGenerated>;
23
+ /**
24
+ * The records a transform module reads, one placeholder per property named.
25
+ * Nothing is resolved here: `bindings.custom.network.subnetIds` is the
26
+ * instruction the deploy carries out against the records published to the
27
+ * environment it targets.
28
+ */
29
+ export declare const bindings: TransformBindings;
30
+ export type { BindingNames, BindingOutput, BindingOutputRef, BindingPlaceholders, BindingPlaceholdersOf, BindingProperties, Linked, } from "./output";
31
+ export { isBindingOutput } from "./output";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { defineTransform } from "./define";
2
+ import { bindings as openBindings } from "./output";
3
+ /**
4
+ * The records a transform module reads, one placeholder per property named.
5
+ * Nothing is resolved here: `bindings.custom.network.subnetIds` is the
6
+ * instruction the deploy carries out against the records published to the
7
+ * environment it targets.
8
+ */
9
+ export const bindings = openBindings;
10
+ export { isBindingOutput } from "./output";
@@ -0,0 +1,57 @@
1
+ /** The key a binding output rides under from a transform module to the deploy. */
2
+ export declare const outputPlaceholderKey = "$ocelOutput";
3
+ /** What the deploy resolves an output from: one property of one bound record. */
4
+ export interface BindingOutputRef {
5
+ readonly type: string;
6
+ readonly name: string;
7
+ readonly property: string;
8
+ }
9
+ declare const resolvedValue: unique symbol;
10
+ /**
11
+ * A value the deploy reads from a record your own infrastructure published, in
12
+ * place of one a transform module could write down. It is resolved provider-side
13
+ * against the records published to the environment being deployed, so a module
14
+ * never holds the value itself.
15
+ */
16
+ export interface BindingOutput<T = unknown> {
17
+ readonly [outputPlaceholderKey]: BindingOutputRef;
18
+ readonly [resolvedValue]?: T;
19
+ }
20
+ /** A leaf a transform may fill with either an authored value or a binding output. */
21
+ export type Linked<T> = T | BindingOutput<T>;
22
+ /** The properties one record carries, each read as a binding output. */
23
+ export type BindingProperties = {
24
+ readonly [property: string]: BindingOutput<any>;
25
+ };
26
+ /** The records of one resource type, addressed by the name the config keys them under. */
27
+ export type BindingNames = {
28
+ readonly [name: string]: BindingProperties;
29
+ };
30
+ /** Every readable record, addressed by resource type and then by name. */
31
+ export type BindingPlaceholders = {
32
+ readonly [type: string]: BindingNames;
33
+ };
34
+ /**
35
+ * The placeholders `B` describes: one property of one record per leaf, each
36
+ * carrying the type that record publishes it as. `G` is what marks `B` as
37
+ * written down; while nothing has augmented it — nothing has run
38
+ * `ocel bindings generate` — every name stays open and the deploy is the check.
39
+ */
40
+ export type BindingPlaceholdersOf<B, G> = keyof G extends never ? BindingPlaceholders : {
41
+ readonly [T in keyof B]: {
42
+ readonly [N in keyof B[T]]: {
43
+ readonly [P in keyof B[T][N]]: BindingOutput<B[T][N][P]>;
44
+ };
45
+ };
46
+ };
47
+ /** Whether a value names a property of a bound record. */
48
+ export declare function isBindingOutput(value: unknown): value is BindingOutput;
49
+ /**
50
+ * The records a transform module reads, one placeholder per property named.
51
+ * Nothing is resolved here: `bindings.postgres.orders.host` is the instruction
52
+ * the deploy carries out against the records published to the environment it
53
+ * targets, and `bindings.custom.network.subnetIds` reads a record nothing
54
+ * declared.
55
+ */
56
+ export declare const bindings: BindingPlaceholders;
57
+ export {};
package/dist/output.js ADDED
@@ -0,0 +1,49 @@
1
+ /** The key a binding output rides under from a transform module to the deploy. */
2
+ export const outputPlaceholderKey = "$ocelOutput";
3
+ /** Whether a value names a property of a bound record. */
4
+ export function isBindingOutput(value) {
5
+ return typeof value === "object" && value !== null && Object.hasOwn(value, outputPlaceholderKey);
6
+ }
7
+ /**
8
+ * The records a transform module reads, one placeholder per property named.
9
+ * Nothing is resolved here: `bindings.postgres.orders.host` is the instruction
10
+ * the deploy carries out against the records published to the environment it
11
+ * targets, and `bindings.custom.network.subnetIds` reads a record nothing
12
+ * declared.
13
+ */
14
+ export const bindings = new Proxy({}, {
15
+ get(_target, type) {
16
+ return unnameable(type) ? undefined : namesOf(type);
17
+ },
18
+ });
19
+ function namesOf(type) {
20
+ return new Proxy({}, {
21
+ get(_target, name) {
22
+ return unnameable(name) ? undefined : propertiesOf(type, name);
23
+ },
24
+ });
25
+ }
26
+ function propertiesOf(type, name) {
27
+ return new Proxy({}, {
28
+ get(_target, property) {
29
+ return unnameable(property) ? undefined : placeholder(type, name, property);
30
+ },
31
+ });
32
+ }
33
+ function unnameable(key) {
34
+ return typeof key === "symbol" || key === "then";
35
+ }
36
+ function placeholder(type, name, property) {
37
+ if (type === "") {
38
+ throw new Error("a binding output names no resource type — read it as `bindings.<type>.<name>.<property>`");
39
+ }
40
+ if (name === "") {
41
+ throw new Error(`a binding output of ${type} names no binding — name the resource your config binds, or the record your own infrastructure published under \`custom\``);
42
+ }
43
+ if (property === "") {
44
+ throw new Error(`a binding output of ${type}.${name} names no property — name the property that record carries`);
45
+ }
46
+ return Object.freeze({
47
+ [outputPlaceholderKey]: Object.freeze({ type, name, property }),
48
+ });
49
+ }
@@ -0,0 +1,8 @@
1
+ import type { Linked } from "./output";
2
+ /**
3
+ * A deep-partial of one underlying resource's args, with every leaf open to a
4
+ * binding output as well as a written-down value.
5
+ */
6
+ export type Patch<T> = T extends undefined | null ? never : T extends readonly (infer E)[] ? Linked<Patch<E>[]> : T extends object ? {
7
+ readonly [K in keyof T]?: Patch<T[K]>;
8
+ } : Linked<T>;
package/dist/patch.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/run.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { type TransformModule } from "./evaluate";
2
+ /**
3
+ * Pairs a module's authored specifier with its default export, so a deploy
4
+ * that rejects a rule can name the file the author wrote it in.
5
+ */
6
+ export declare function loadModule(specifier: string, exported: unknown): TransformModule;
7
+ /** The descriptor the deploy opens for the answer, leaving stdout to the modules. */
8
+ export declare const resultDescriptor = 3;
9
+ /**
10
+ * Runs the deploy-time pass: reads the candidate resources ocel offers on
11
+ * stdin, applies the modules in order, and writes the answer down the
12
+ * descriptor the deploy opened for it. A rejected rule rides down the same
13
+ * descriptor as a refusal, so a module that crashes or fails to import is not
14
+ * read as one, and anything a module prints stays on stdout where it belongs.
15
+ */
16
+ export declare function runEvaluate(modules: readonly TransformModule[]): Promise<void>;
package/dist/run.js ADDED
@@ -0,0 +1,40 @@
1
+ import { writeSync } from "node:fs";
2
+ import { isTransformDefinition } from "./define";
3
+ import { evaluate } from "./evaluate";
4
+ /**
5
+ * Pairs a module's authored specifier with its default export, so a deploy
6
+ * that rejects a rule can name the file the author wrote it in.
7
+ */
8
+ export function loadModule(specifier, exported) {
9
+ if (!isTransformDefinition(exported)) {
10
+ throw new Error(`${specifier}: a transform module must export a default \`defineTransform(...)\` result`);
11
+ }
12
+ return { specifier, definition: exported };
13
+ }
14
+ async function readRequest() {
15
+ const chunks = [];
16
+ for await (const chunk of process.stdin) {
17
+ chunks.push(chunk);
18
+ }
19
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
20
+ }
21
+ /** The descriptor the deploy opens for the answer, leaving stdout to the modules. */
22
+ export const resultDescriptor = 3;
23
+ /**
24
+ * Runs the deploy-time pass: reads the candidate resources ocel offers on
25
+ * stdin, applies the modules in order, and writes the answer down the
26
+ * descriptor the deploy opened for it. A rejected rule rides down the same
27
+ * descriptor as a refusal, so a module that crashes or fails to import is not
28
+ * read as one, and anything a module prints stays on stdout where it belongs.
29
+ */
30
+ export async function runEvaluate(modules) {
31
+ try {
32
+ const result = evaluate(await readRequest(), modules);
33
+ writeSync(resultDescriptor, JSON.stringify({ result }));
34
+ }
35
+ catch (error) {
36
+ const refusal = error instanceof Error ? error.message : String(error);
37
+ writeSync(resultDescriptor, JSON.stringify({ refusal }));
38
+ process.exitCode = 1;
39
+ }
40
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The container a box runs for an ocel resource, as `docker run` takes it.
3
+ */
4
+ export interface VpsContainerArgs {
5
+ /** The name the container runs under, which is also the host an app dials. */
6
+ name: string;
7
+ /** The project network the container joins, and the only thing that reaches it. */
8
+ network: string;
9
+ /** The labels a teardown and a class destroy find the container by. */
10
+ labels: Record<string, string>;
11
+ /** Ports published on the box. A resource publishes none. */
12
+ publish: string[];
13
+ /** What is mounted into the container: its own volume, and nothing else. */
14
+ mounts: string[];
15
+ /**
16
+ * The image to run, pinned by digest (`…@sha256:…`). Swap it for a build of
17
+ * the same engine that carries what you need, such as pgvector.
18
+ */
19
+ image: string;
20
+ /** Arguments handed to the image's entrypoint, such as `["-c", "max_connections=200"]`. */
21
+ args: string[];
22
+ /** Environment for the container. The names the engine's credentials ride under stay ocel's. */
23
+ env: Record<string, string>;
24
+ /** A memory ceiling, as `docker run --memory` takes it: `"2g"`. */
25
+ memory: string;
26
+ /** A CPU ceiling, as `docker run --cpus` takes it: `"1.5"`. */
27
+ cpus: string;
28
+ /** The size of `/dev/shm`, as `docker run --shm-size` takes it: `"256m"`. */
29
+ shmSize: string;
30
+ }
31
+ /**
32
+ * The volume a box keeps an ocel resource's data on, as `docker volume create` takes it.
33
+ */
34
+ export interface VpsVolumeArgs {
35
+ /** The volume's name, which a teardown removes it by. */
36
+ name: string;
37
+ /** The labels a class destroy finds the volume by. */
38
+ labels: Record<string, string>;
39
+ /** The volume driver. Defaults to the engine's own `local`. */
40
+ driver: string;
41
+ /** Options for the driver, such as a bind onto a disk mounted at `/mnt/pg`. */
42
+ driverOpts: Record<string, string>;
43
+ }
44
+ /**
45
+ * One key per thing the vps provider stands up on the box for an ocel resource.
46
+ */
47
+ export interface VpsResourceArgs {
48
+ postgres: {
49
+ container: VpsContainerArgs;
50
+ volume: VpsVolumeArgs;
51
+ };
52
+ bucket: {
53
+ container: VpsContainerArgs;
54
+ volume: VpsVolumeArgs;
55
+ };
56
+ }
57
+ /** The ocel resource types the vps provider renders patchable resources for. */
58
+ export type VpsResourceType = keyof VpsResourceArgs;
59
+ /**
60
+ * The fields ocel fills itself — the name an app dials, the network that is
61
+ * the resource's whole boundary, the labels it is found and removed by, and
62
+ * what is published and mounted. A patch naming one is refused where it is
63
+ * written, and the deploy refuses it again by name.
64
+ */
65
+ export declare const vpsOwnedFields: {
66
+ readonly postgres: {
67
+ readonly container: readonly ["name", "network", "labels", "publish", "mounts"];
68
+ readonly volume: readonly ["name", "labels"];
69
+ };
70
+ readonly bucket: {
71
+ readonly container: readonly ["name", "network", "labels", "publish", "mounts"];
72
+ readonly volume: readonly ["name", "labels"];
73
+ };
74
+ };
75
+ type VpsOwned = typeof vpsOwnedFields;
76
+ type OwnedNames<L> = L extends readonly (infer F)[] ? Extract<F, string> : never;
77
+ /**
78
+ * What a rule may patch under `vps`: the box's own args with the fields ocel
79
+ * owns removed. Every leaf is a written-down value; a binding output has
80
+ * nothing on a box to resolve against.
81
+ */
82
+ export type VpsSurfaces = {
83
+ [T in VpsResourceType]: {
84
+ [K in keyof VpsResourceArgs[T]]: Partial<Omit<VpsResourceArgs[T][K], OwnedNames<VpsOwned[T][K & keyof VpsOwned[T]]>>>;
85
+ };
86
+ };
87
+ export {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The fields ocel fills itself — the name an app dials, the network that is
3
+ * the resource's whole boundary, the labels it is found and removed by, and
4
+ * what is published and mounted. A patch naming one is refused where it is
5
+ * written, and the deploy refuses it again by name.
6
+ */
7
+ export const vpsOwnedFields = {
8
+ postgres: {
9
+ container: ["name", "network", "labels", "publish", "mounts"],
10
+ volume: ["name", "labels"],
11
+ },
12
+ bucket: {
13
+ container: ["name", "network", "labels", "publish", "mounts"],
14
+ volume: ["name", "labels"],
15
+ },
16
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@ocel/transforms",
3
+ "version": "0.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Patches the underlying cloud resources ocel provisions, typed from the provider's own SDK",
7
+ "files": [
8
+ "dist/",
9
+ "README.md"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./aws": {
17
+ "types": "./dist/aws/index.d.ts",
18
+ "import": "./dist/aws/index.js"
19
+ },
20
+ "./vps": {
21
+ "types": "./dist/vps/index.d.ts",
22
+ "import": "./dist/vps/index.js"
23
+ },
24
+ "./run": {
25
+ "types": "./dist/run.d.ts",
26
+ "import": "./dist/run.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "dependencies": {
31
+ "@pulumi/aws": "7.36.0",
32
+ "@pulumi/pulumi": "^3.259.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20",
36
+ "typescript": "^5.9.3",
37
+ "vite": "^8.1.3",
38
+ "vitest": "^4.1.9"
39
+ },
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/ocelhq/ocel.git",
44
+ "directory": "packages/ocel-transforms"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "registry": "https://registry.npmjs.org/"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc",
52
+ "test": "vitest run",
53
+ "typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p tsconfig.typetests.json && tsc --noEmit -p tsconfig.typetests-empty.json && tsc --noEmit -p tsconfig.typetests-open.json"
54
+ }
55
+ }