@samebase/alchemy-convex 0.9.0 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samebase/alchemy-convex",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The Convex plane for Alchemy v2: projects, deployments, environment variables, and the push of the functions",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -51,24 +51,25 @@
51
51
  "@convex-dev/platform": "0.1.11"
52
52
  },
53
53
  "devDependencies": {
54
+ "@alchemy.run/cloudflare": "2.0.0-beta.84",
54
55
  "@arethetypeswrong/core": "0.18.5",
55
- "@effect/platform-node": "4.0.4",
56
- "@effect/vitest": "4.0.4",
56
+ "@effect/platform-node": "4.0.5",
57
+ "@effect/vitest": "4.0.5",
57
58
  "@types/node": "24.19.2",
58
- "alchemy": "2.0.0-beta.83",
59
+ "alchemy": "2.0.0-beta.84",
59
60
  "convex": "1.48.0",
60
61
  "convex-test": "0.0.60",
61
- "effect": "4.0.4",
62
+ "effect": "4.0.5",
62
63
  "publint": "0.3.25",
63
64
  "typescript": "6.0.3",
64
65
  "vite-plus": "1.1.0",
65
66
  "vitest": "5.0.3"
66
67
  },
67
68
  "peerDependencies": {
68
- "@effect/platform-node": "^4.0.0",
69
- "alchemy": "2.0.0-beta.83",
69
+ "@effect/platform-node": "^4.0.5",
70
+ "alchemy": "2.0.0-beta.84",
70
71
  "convex": "^1.48.0",
71
- "effect": "^4.0.0"
72
+ "effect": "^4.0.5"
72
73
  },
73
74
  "engines": {
74
75
  "node": ">=22"
@@ -0,0 +1,54 @@
1
+ import { toPath } from "alchemy/FQN";
2
+ import type { NamespaceNode } from "alchemy/Namespace";
3
+ import { Stack } from "alchemy/Stack";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Schema from "effect/Schema";
6
+ import type { Deployment } from "./Deployment.ts";
7
+
8
+ /** A component's resources occupy this namespace in the deployment's Alchemy stack and stage. */
9
+ export interface ComponentBinding {
10
+ readonly type: "component";
11
+ readonly path: string;
12
+ readonly namespace: readonly string[];
13
+ }
14
+
15
+ /**
16
+ * Associate a native namespace with an installed component. `component` is the
17
+ * installed name from the app's `convex.config.ts`; a nested install joins the
18
+ * names with slashes, such as `"workspace/mail"`. An undefined namespace
19
+ * associates the stack root. This records display metadata; it does not install
20
+ * the component, set its environment, or change removal policies.
21
+ */
22
+ export const bindComponent = (options: {
23
+ deployment: Deployment;
24
+ component: string;
25
+ namespace: NamespaceNode | undefined;
26
+ }) =>
27
+ Effect.gen(function* () {
28
+ if (options.component === "") {
29
+ return yield* Effect.die(new Error("The component name is empty."));
30
+ }
31
+ const namespace = toPath(options.namespace);
32
+ const sid = `convex.component:${JSON.stringify(namespace)}`;
33
+ const stack = yield* Stack;
34
+ const previous = stack.bindings[options.deployment.FQN]?.find((binding) => binding.sid === sid);
35
+ // Alchemy keeps the last binding with a given sid. Reject conflicting owners before that happens.
36
+ if (previous !== undefined) {
37
+ const existing = Schema.decodeUnknownSync(Schema.Struct({ path: Schema.String }))(
38
+ previous.data,
39
+ );
40
+ if (existing.path !== options.component) {
41
+ return yield* Effect.die(
42
+ new Error(
43
+ `Alchemy namespace ${JSON.stringify(namespace)} is already bound to ${existing.path}.`,
44
+ ),
45
+ );
46
+ }
47
+ return;
48
+ }
49
+ yield* options.deployment.bind(sid, {
50
+ type: "component",
51
+ path: options.component,
52
+ namespace,
53
+ });
54
+ });
package/src/Deployment.ts CHANGED
@@ -54,7 +54,7 @@
54
54
  import { posix, win32 } from "node:path";
55
55
  import { Resource } from "alchemy";
56
56
  import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
57
- import { isResolved } from "alchemy/Diff";
57
+ import { diffBindings, isResolved } from "alchemy/Diff";
58
58
  import type { InputProps } from "alchemy/Input";
59
59
  import * as Provider from "alchemy/Provider";
60
60
  import * as Effect from "effect/Effect";
@@ -63,6 +63,7 @@ import * as Redacted from "effect/Redacted";
63
63
  import * as Schema from "effect/Schema";
64
64
  import { shouldAdopt } from "./Adoption.ts";
65
65
  import { Credentials } from "./Credentials.ts";
66
+ import type { ComponentBinding } from "./Component.ts";
66
67
  import {
67
68
  absentAsUndefined,
68
69
  type ConvexApiError,
@@ -145,7 +146,7 @@ export type Deployment = Resource<
145
146
  "Convex.Deployment",
146
147
  DeploymentProps,
147
148
  DeploymentAttributes,
148
- never,
149
+ ComponentBinding,
149
150
  Providers
150
151
  >;
151
152
  /** The resource class. Stacks call {@link Deployment}, which writes `rootDir` into the props. */
@@ -597,9 +598,10 @@ export const DeploymentProvider = () =>
597
598
  // the refused props. Only `rootDir` compares with `olds`, because it
598
599
  // is in the props only. A refused update leaves the row `updating`,
599
600
  // and the next plan updates it again anyway.
600
- diff: Effect.fn(function* ({ news, olds, output }) {
601
+ diff: Effect.fn(function* ({ news, olds, output, oldBindings, newBindings }) {
601
602
  if (!isResolved(news) || output === undefined) return undefined;
602
603
  yield* checkIdentity(output, news);
604
+ if (!isResolved(newBindings)) return undefined;
603
605
  // State from 0.7 has no rootDir; the constructor always writes it.
604
606
  const rootDir = news.rootDir ?? ".";
605
607
  // Also here, so that a wrong rootDir stops the plan before Apply
@@ -617,9 +619,21 @@ export const DeploymentProvider = () =>
617
619
  const current = yield* live(access, output);
618
620
  if (current?.name !== output.name) return { action: "update" } as const;
619
621
  const hash = yield* wantedHash(news, rootDir, output.name);
620
- return hash === output.hash
621
- ? ({ action: "noop" } as const)
622
- : ({ action: "update" } as const);
622
+ if (hash !== output.hash) return { action: "update" } as const;
623
+ if (diffBindings(oldBindings, newBindings).every((binding) => binding.action === "noop"))
624
+ return { action: "noop" } as const;
625
+ // Binding metadata does not change deployment outputs. Keep unchanged
626
+ // values available to consumers so the plan does not update them too.
627
+ return {
628
+ action: "update" as const,
629
+ stables: [
630
+ "hash",
631
+ "envNames",
632
+ ...(
633
+ ["projectId", "type", "previewName", "name", "reference", "url", "siteUrl"] as const
634
+ ).filter((key) => current[key] === output[key]),
635
+ ],
636
+ };
623
637
  }),
624
638
 
625
639
  read: Effect.fn(function* ({ olds, output }) {
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // @samebase/alchemy-convex: the Convex plane for Alchemy v2.
2
+ export { bindComponent, type ComponentBinding } from "./Component.ts";
2
3
  export {
3
4
  ACCESS_TOKEN_ENV,
4
5
  Credentials,