@drkmttr/adhere 0.9.5 → 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/README.md +406 -90
- package/package.json +7 -7
- package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
- package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
- package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
- package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
- package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
- package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
- package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
- package/presets/alchemy/data/single-migration-owner.md +6 -2
- package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
- package/presets/alchemy/durable/io-inside-steps.md +6 -2
- package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
- package/presets/alchemy/durable/stable-step-names.md +6 -2
- package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
- package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
- package/presets/alchemy/providers/idempotent-delete.md +8 -3
- package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
- package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
- package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
- package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
- package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
- package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
- package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
- package/presets/alchemy/resources/stable-logical-ids.md +6 -2
- package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
- package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
- package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
- package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
- package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
- package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
- package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
- package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
- package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
- package/presets/alchemy/secrets/redacted-until-use.md +6 -2
- package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
- package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
- package/presets/alchemy/security/github-secret-not-variable.md +6 -2
- package/presets/alchemy/security/identity-from-verified-session.md +6 -2
- package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
- package/presets/alchemy/security/least-privilege-bindings.md +6 -2
- package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
- package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
- package/presets/effect/basics/gen-for-sequencing.md +7 -3
- package/presets/effect/basics/instrument-with-pipe.md +6 -2
- package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
- package/presets/effect/config/secrets-are-redacted.md +6 -2
- package/presets/effect/config/tests-provide-values-directly.md +7 -2
- package/presets/effect/config/validate-with-schema.md +6 -2
- package/presets/effect/data/brand-meaningful-primitives.md +7 -3
- package/presets/effect/data/variants-are-tagged-unions.md +7 -3
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
- package/presets/effect/errors/defects-for-bugs.md +6 -2
- package/presets/effect/errors/wrap-external-errors.md +6 -2
- package/presets/effect/services/dependencies-through-layers.md +7 -3
- package/presets/effect/services/fresh-layer-per-test.md +7 -2
- package/presets/effect/services/memoize-parameterized-layers.md +6 -2
- package/presets/effect/services/no-mutable-state.md +6 -2
- package/presets/effect/services/operations-have-no-requirements.md +6 -2
- package/presets/effect/services/test-layers-are-in-memory.md +7 -2
- package/presets/effect/testing/test-clock-for-time.md +7 -2
- package/presets/react/effects/clean-up-what-they-start.md +23 -0
- package/presets/react/effects/event-logic-in-handlers.md +29 -0
- package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
- package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
- package/presets/react/effects/notify-parents-in-handlers.md +29 -0
- package/presets/react/server/no-private-data-to-client.md +23 -0
- package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
- package/presets/react/state/no-contradictory-state.md +17 -0
- package/presets/react/state/no-props-copied-into-state.md +23 -0
- package/presets/react/state/store-ids-not-copies.md +20 -0
- package/presets/security/constant-time-secret-compare.md +22 -0
- package/presets/security/no-secrets-in-output.md +19 -0
- package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
- package/presets/security/parameterized-queries.md +16 -0
- package/presets/typescript/async/concurrent-independent-work.md +23 -0
- package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
- package/presets/typescript/design/no-argument-mutation.md +20 -0
- package/presets/typescript/errors/no-swallowed-errors.md +29 -0
- package/presets/typescript/errors/release-on-every-path.md +29 -0
- package/presets/typescript/testing/assert-outcomes.md +22 -0
- package/presets/typescript/testing/independent-tests.md +41 -0
- package/presets/typescript/types/assertions-only-when-proven.md +20 -0
- package/presets/typescript/types/derive-dont-restate.md +24 -0
- package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
- package/presets/typescript/types/validate-external-data.md +21 -0
- package/skills/adhere/SKILL.md +41 -12
- package/skills/adhere-fix/SKILL.md +180 -0
- package/src/cli.ts +59 -14
- package/src/comments.ts +36 -0
- package/src/config.ts +126 -14
- package/src/contradictions.ts +10 -2
- package/src/excerpt.ts +78 -58
- package/src/highlight.ts +2 -0
- package/src/index.ts +1 -1
- package/src/init.ts +119 -12
- package/src/main.ts +0 -1
- package/src/markdown.ts +83 -14
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +12 -3
- package/src/rules.ts +36 -3
- package/src/services/AdhereConfig.ts +36 -13
- package/src/services/AuditCache.ts +31 -5
- package/src/services/Jev.http.ts +76 -11
- package/src/services/Jev.ts +191 -73
- package/src/services/SourceWalker.ts +51 -13
- package/src/suppress.ts +119 -0
- package/src/version.ts +25 -1
- package/src/workflows/audit.ts +192 -40
- package/src/workflows/format.ts +71 -24
- package/presets/alchemy/data/parameterized-sql.md +0 -13
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -15
- package/presets/effect/basics/external-calls-are-resilient.md +0 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"start": "bun src/main.ts",
|
|
33
33
|
"test": "vp test",
|
|
34
34
|
"typecheck": "tsc --noEmit && bun run lint",
|
|
35
|
-
"build": "bun build --compile --minify --sourcemap src/main.ts --asset ./presets --outfile dist/adhere",
|
|
35
|
+
"build": "bun build --compile --minify --sourcemap --define ADHERE_BUILD=\"\\\"$(git describe --tags --dirty --always)\\\"\" src/main.ts --asset ./presets --outfile dist/adhere",
|
|
36
36
|
"build:npm": "bun scripts/npm-packages.ts build",
|
|
37
37
|
"release": "release-it"
|
|
38
38
|
},
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
|
-
"@drkmttr/adhere-darwin-arm64": "0.
|
|
72
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
73
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
74
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
75
|
-
"@drkmttr/adhere-win32-x64": "0.
|
|
71
|
+
"@drkmttr/adhere-darwin-arm64": "0.11.0",
|
|
72
|
+
"@drkmttr/adhere-darwin-x64": "0.11.0",
|
|
73
|
+
"@drkmttr/adhere-linux-arm64": "0.11.0",
|
|
74
|
+
"@drkmttr/adhere-linux-x64": "0.11.0",
|
|
75
|
+
"@drkmttr/adhere-win32-x64": "0.11.0"
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
description: Errors that crossed RPC or a workflow replay must be matched by their tag and data fields, never with instanceof, prototype methods, or object identity, which do not survive serialization, and RPC arguments must be serializable values, never functions or class instances.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const value = yield* stub.get(key).pipe(
|
|
7
9
|
Effect.catchTag("KeyMissing", (error) => Effect.succeed(`missing: ${error.key}`)),
|
|
8
10
|
);
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
## Never
|
|
14
|
+
|
|
15
|
+
```ts
|
|
12
16
|
const value = yield* stub.get(key).pipe(
|
|
13
17
|
Effect.catchAll((error) => (error instanceof KeyMissing ? Effect.succeed("missing") : Effect.fail(error))),
|
|
14
18
|
);
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
description: Input from outside the stack, such as browsers, partners, or public URLs, should be validated with a schema through Effect RPC or HttpApi, while calls between the stack's own runtimes should use schemaless RPC through bindings, and unvalidated payloads should not cross a trust boundary.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Should
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const Tasks = HttpApiGroup.make("tasks").add(
|
|
7
9
|
HttpApiEndpoint.post("create", "/tasks", { payload: Schema.Struct({ title: Schema.String }) }),
|
|
8
10
|
);
|
|
9
11
|
const greeter = yield* Cloudflare.Workers.bindWorker(Greeter); // internal call
|
|
10
12
|
```
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
## Should not
|
|
15
|
+
|
|
16
|
+
```ts
|
|
13
17
|
fetch: Effect.gen(function* () {
|
|
14
18
|
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
15
19
|
const body = (yield* request.json) as { title: string; ownerId: string }; // public URL, no validation
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
description: Migrations must be generated and reviewed ahead of time, committed with the schema, and applied from the committed files; a deploy, deploy hook, or Action must never generate migration SQL, and schema changes must never run from an HTTP route.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
// package.json: "db:generate": "drizzle-kit generate" (run locally, then commit ./drizzle)
|
|
7
9
|
const db = yield* Cloudflare.D1.Database("AppDb", { migrations: "./drizzle" });
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
## Never
|
|
13
|
+
|
|
14
|
+
```ts
|
|
11
15
|
// package.json: "deploy": "drizzle-kit generate && alchemy deploy"
|
|
12
16
|
fetch: Effect.gen(function* () {
|
|
13
17
|
if (url.pathname === "/setup") yield* sql`CREATE TABLE users (id text primary key)`;
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
description: A Hyperdrive connection's origin should be the database's direct endpoint, and clients that connect without Hyperdrive should use the provider's pooled endpoint; Hyperdrive should not point at a provider pooler, which stacks two poolers.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Should
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("AppHyperdrive", {
|
|
7
9
|
origin: branch.origin,
|
|
8
10
|
dev: branch.pooledOrigin,
|
|
9
11
|
});
|
|
10
12
|
```
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
## Should not
|
|
15
|
+
|
|
16
|
+
```ts
|
|
13
17
|
const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("AppHyperdrive", { origin: role.pooledOrigin });
|
|
14
18
|
```
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
description: A migration runner must connect with the database's direct URL, never Hyperdrive's runtime connection string or an Accelerate or pooled URL.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
export default defineConfig({ dialect: "postgresql", dbCredentials: { url: process.env.DIRECT_DATABASE_URL! } });
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## Never
|
|
12
|
+
|
|
13
|
+
```ts
|
|
10
14
|
export default defineConfig({ dialect: "postgresql", dbCredentials: { url: process.env.ACCELERATE_URL! } });
|
|
11
15
|
```
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
description: A database client or migration config must verify the server's TLS certificate, or connect inside the private network, never setting rejectUnauthorized: false or sslmode=no-verify to make a connection work.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const url = new URL(connection.connectionString);
|
|
7
9
|
url.searchParams.set("sslmode", "verify-full");
|
|
8
10
|
const db = yield* Drizzle.Postgres(url.toString());
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
## Never
|
|
14
|
+
|
|
15
|
+
```ts
|
|
12
16
|
export default defineConfig({
|
|
13
17
|
dialect: "postgresql",
|
|
14
18
|
dbCredentials: { url: process.env.DATABASE_URL!, ssl: { rejectUnauthorized: false } },
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
description: Service code should reach its database through the bound private connection, and should not use the public connection URI or TCP proxy, which is meant for laptops and CI.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Should
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const conn = yield* Railway.ConnectPostgres(Db);
|
|
7
9
|
const db = yield* Drizzle.Postgres(conn.connectionString);
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
## Should not
|
|
13
|
+
|
|
14
|
+
```ts
|
|
11
15
|
const db = yield* Drizzle.Postgres(Db.publicConnectionUri);
|
|
12
16
|
```
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
description: Each database must have one migration owner: when a resource's migrations prop applies the directory, scripts and CI must never also run drizzle-kit migrate or prisma migrate against the same database.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const branch = yield* Neon.Branch("Main", { project, migrations: "./migrations" });
|
|
7
9
|
// package.json: "deploy": "alchemy deploy"
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
## Never
|
|
13
|
+
|
|
14
|
+
```ts
|
|
11
15
|
const branch = yield* Neon.Branch("Main", { project, migrations: "./migrations" });
|
|
12
16
|
// package.json: "deploy": "drizzle-kit migrate && alchemy deploy"
|
|
13
17
|
```
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
description: A handler for events delivered at least once, such as Stripe webhooks, scheduled callbacks, or bucket and queue events, must be idempotent, upserting by a stable ID or deduplicating by event ID, never doing blind inserts, increments, or charges.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
yield* Stripe.consumeEvents([CustomerSubscriptionUpdated], (event) =>
|
|
7
9
|
kv.put(`entitlement:${event.data.object.customer}`, JSON.stringify(entitlementOf(event))),
|
|
8
10
|
);
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
## Never
|
|
14
|
+
|
|
15
|
+
```ts
|
|
12
16
|
yield* Stripe.consumeEvents([InvoicePaid], (event) =>
|
|
13
17
|
sql`UPDATE accounts SET credits = credits + 100 WHERE customer = ${event.data.object.customer}`,
|
|
14
18
|
);
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: In a Workflow or Durable Function, every side effect, such as an HTTP call, binding call, or write, must run inside a task or step, and code outside steps must be deterministic, never doing I/O or reading the clock or randomness, because the body replays.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
export default Cloudflare.Workflow("Notify", Effect.fn(function* (input: { roomId: string; message: string }) {
|
|
7
9
|
const stored = yield* Cloudflare.Workflows.task("store", kv.put(`notify:${input.roomId}`, input.message));
|
|
8
10
|
yield* Cloudflare.Workflows.sleep("cooldown", "30 seconds");
|
|
@@ -10,7 +12,9 @@ export default Cloudflare.Workflow("Notify", Effect.fn(function* (input: { roomI
|
|
|
10
12
|
}));
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## Never
|
|
16
|
+
|
|
17
|
+
```ts
|
|
14
18
|
export default Cloudflare.Workflow("Notify", Effect.fn(function* (input: { roomId: string; message: string }) {
|
|
15
19
|
yield* kv.put(`notify:${input.roomId}:${Date.now()}`, input.message); // repeats on every replay
|
|
16
20
|
}));
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
description: A bucket-event handler that writes back into the bucket it listens to should filter its subscription by prefix or suffix so its own writes do not match, and should not subscribe to every object created in that bucket.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Should
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
yield* AWS.S3.consumeBucketEvents(bucket, { events: ["s3:ObjectCreated:*"], prefix: "incoming/" }, (stream) =>
|
|
7
9
|
stream.pipe(Stream.runForEach((event) => putObject({ Key: `processed/${event.key}`, Body: "..." }))),
|
|
8
10
|
);
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
## Should not
|
|
14
|
+
|
|
15
|
+
```ts
|
|
12
16
|
yield* AWS.S3.consumeBucketEvents(bucket, { events: ["s3:ObjectCreated:*"] }, (stream) =>
|
|
13
17
|
stream.pipe(Stream.runForEach((event) => putObject({ Key: `processed/${event.key}`, Body: "..." }))), // triggers itself
|
|
14
18
|
);
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
description: Workflow task, sleep, and waitForEvent names are replay keys, so they must be stable across replays, never built from the clock, randomness, or other values that change between runs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
yield* Cloudflare.Workflows.task("charge-card", chargeCard(input.orderId));
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## Never
|
|
12
|
+
|
|
13
|
+
```ts
|
|
10
14
|
yield* Cloudflare.Workflows.task(`charge-${Date.now()}`, chargeCard(input.orderId));
|
|
11
15
|
```
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
description: Access to paid features must be gated on state recorded from Stripe webhooks in the app's own store, never granted because the user reached a success_url or return_url.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const entitlement = yield* kv.get(`entitlement:${customerId}`);
|
|
7
9
|
if (entitlement === null) return HttpServerResponse.empty({ status: 402 });
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
## Never
|
|
13
|
+
|
|
14
|
+
```ts
|
|
11
15
|
if (url.pathname === "/welcome") {
|
|
12
16
|
yield* kv.put(`entitlement:${customerId}`, "active"); // reached the success URL, not paid
|
|
13
17
|
}
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
description: A custom provider's diff must check isResolved(news) and return undefined before comparing props that may still be unresolved Outputs, never comparing them directly.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
diff: Effect.fn(function* ({ news, olds }) {
|
|
7
9
|
if (!isResolved(news)) return undefined;
|
|
8
10
|
if (news.region !== olds.region) return { action: "replace" } as const;
|
|
9
11
|
}),
|
|
10
12
|
```
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
## Never
|
|
15
|
+
|
|
16
|
+
```ts
|
|
13
17
|
diff: Effect.fn(function* ({ news, olds }) {
|
|
14
18
|
if (news.region !== olds.region) return { action: "replace" } as const;
|
|
15
19
|
}),
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: The delete handler of a resource provider, the `delete:` lifecycle method given the resource's `output`, must treat a resource that is already gone as success, catching the not-found error, never failing when it runs again. Code outside a provider's delete handler, such as a runtime binding, a Worker route, or a storage client, is not in scope.
|
|
3
|
+
level: warning
|
|
3
4
|
---
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```ts
|
|
6
9
|
delete: ({ output }) =>
|
|
7
10
|
Effect.tryPromise(() => stripe.products.del(output.productId)).pipe(
|
|
8
11
|
Effect.catchIf(isNotFound, () => Effect.void),
|
|
9
12
|
),
|
|
10
13
|
```
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
## Never
|
|
16
|
+
|
|
17
|
+
```ts
|
|
13
18
|
delete: ({ output }) => Effect.promise(() => stripe.products.del(output.productId)),
|
|
14
19
|
```
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
description: A custom provider, credentials, or state-store Layer must defer resolving credentials and opening connections until an operation runs, never resolving credentials, connecting, or prompting while the Layer is built, because every CLI command builds it.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
export const PostgresState = Layer.effect(State, Effect.cached(makePostgresState(props)));
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## Never
|
|
12
|
+
|
|
13
|
+
```ts
|
|
10
14
|
export const PostgresState = Layer.effect(State, Effect.gen(function* () {
|
|
11
15
|
const sql = postgres(process.env.DATABASE_URL!); // connects on every CLI command
|
|
12
16
|
return makeState(sql);
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: A custom provider's reconcile must read the resource's live state and converge it in one flow that serves create, update, and adoption, never branching into separate create and update bodies on output === undefined or trusting olds as proof the resource exists.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
7
9
|
let live = yield* observe(output);
|
|
8
10
|
if (live === undefined) live = yield* create(news);
|
|
@@ -11,7 +13,9 @@ reconcile: Effect.fn(function* ({ news, output }) {
|
|
|
11
13
|
}),
|
|
12
14
|
```
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
## Never
|
|
17
|
+
|
|
18
|
+
```ts
|
|
15
19
|
reconcile: Effect.fn(function* ({ news, olds, output }) {
|
|
16
20
|
if (output === undefined) return yield* create(news);
|
|
17
21
|
yield* updateTags(output.id, olds.tags, news.tags);
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
description: A path prop such as main or rootDir on a resource declared outside the directory alchemy runs from must be anchored to the declaring file with import.meta, never given as a bare relative string, which resolves from the root instead.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
// backend/src/api.ts
|
|
7
9
|
export default Cloudflare.Worker("Api", { main: import.meta.url }, program);
|
|
8
10
|
export const Site = Cloudflare.Website.Vite("Site", { rootDir: path.resolve(import.meta.dirname, "../frontend") });
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
## Never
|
|
14
|
+
|
|
15
|
+
```ts
|
|
12
16
|
// backend/src/api.ts
|
|
13
17
|
export default Cloudflare.Worker("Api", { main: "src/api.ts" }, program); // resolves to <root>/src/api.ts
|
|
14
18
|
```
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: An Action body and a SQL file listed in importFiles must be safe to run again, using IF NOT EXISTS, ON CONFLICT, or upserts, never one-shot inserts or calls that duplicate data or fail when an interrupted deploy retries them.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
export const Migrate = Alchemy.Action("Migrate", Effect.fn(function* () {
|
|
7
9
|
const db = yield* D1Client;
|
|
8
10
|
yield* db.exec("CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)");
|
|
@@ -10,7 +12,9 @@ export const Migrate = Alchemy.Action("Migrate", Effect.fn(function* () {
|
|
|
10
12
|
}));
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## Never
|
|
16
|
+
|
|
17
|
+
```ts
|
|
14
18
|
export const Seed = Alchemy.Action("Seed", Effect.fn(function* () {
|
|
15
19
|
const db = yield* D1Client;
|
|
16
20
|
yield* db.exec("CREATE TABLE users (id TEXT, name TEXT)");
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
description: Preview stages should reference a long-lived database or cluster with ref and create only cheap per-stage pieces such as a branch, and should not provision a whole project or cluster for every preview.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Should
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const project = stage.startsWith("pr-")
|
|
7
9
|
? yield* Neon.Project.ref("AppDb", { stage: "staging" })
|
|
8
10
|
: yield* Neon.Project("AppDb", { region: "aws-us-east-1" });
|
|
9
11
|
const branch = yield* Neon.Branch("Branch", { project });
|
|
10
12
|
```
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
## Should not
|
|
15
|
+
|
|
16
|
+
```ts
|
|
13
17
|
const project = yield* Neon.Project("AppDb", { region: "aws-us-east-1" }); // a new project per PR
|
|
14
18
|
const branch = yield* Neon.Branch("Branch", { project });
|
|
15
19
|
```
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
description: A bucket or database holding user or business data should be retained with RemovalPolicy.retain(), at least in production, and forceDestroy should be set only on buckets whose contents are disposable; irreplaceable data should not depend on the default destroy policy.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Should
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const uploads = yield* Cloudflare.R2.Bucket("Uploads").pipe(RemovalPolicy.retain(stage === "prod"));
|
|
7
9
|
const cache = yield* Cloudflare.R2.Bucket("Cache", { forceDestroy: true });
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
## Should not
|
|
13
|
+
|
|
14
|
+
```ts
|
|
11
15
|
const userUploads = yield* Cloudflare.R2.Bucket("UserUploads", { forceDestroy: true });
|
|
12
16
|
```
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: A stack deployed from CI or by more than one machine must use a shared state store such as Cloudflare.state() or AWS.state(), never Alchemy.localState(), which lives on one machine's disk where CI runners cannot see it.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
export default Alchemy.Stack("App", { providers: Cloudflare.providers(), state: Cloudflare.state() },
|
|
7
9
|
Effect.gen(function* () {
|
|
8
10
|
const api = yield* Api;
|
|
@@ -11,7 +13,9 @@ export default Alchemy.Stack("App", { providers: Cloudflare.providers(), state:
|
|
|
11
13
|
);
|
|
12
14
|
```
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
## Never
|
|
17
|
+
|
|
18
|
+
```ts
|
|
15
19
|
// deployed by .github/workflows/deploy.yml
|
|
16
20
|
export default Alchemy.Stack("App", { providers: Cloudflare.providers(), state: Alchemy.localState() },
|
|
17
21
|
Effect.gen(function* () {
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
description: A resource's logical ID, its first argument, must stay the same from one deploy to the next, never built from timestamps, random values, UUIDs, or commit SHAs, because a new logical ID replaces the resource and deletes the old one with its data.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const uploads = yield* Cloudflare.R2.Bucket("Uploads");
|
|
7
9
|
const jobs = yield* AWS.SQS.Queue("Jobs");
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
## Never
|
|
13
|
+
|
|
14
|
+
```ts
|
|
11
15
|
const uploads = yield* Cloudflare.R2.Bucket(`Uploads-${Date.now()}`);
|
|
12
16
|
const db = yield* Neon.Project(`Db-${process.env.GITHUB_SHA}`);
|
|
13
17
|
```
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: In a stack deployed to several stages, a resource whose physical name is global to an account or org should omit name, so alchemy derives one per stage, or derive it from the stage, and should not hardcode a single name every stage shares.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Should
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const traces = yield* Axiom.Dataset("Traces", Stack.useSync(({ stage }) => ({
|
|
7
9
|
name: `${stage}-traces`,
|
|
8
10
|
kind: "otel:traces:v1" as const,
|
|
@@ -10,6 +12,8 @@ const traces = yield* Axiom.Dataset("Traces", Stack.useSync(({ stage }) => ({
|
|
|
10
12
|
const branch = yield* Neon.Branch("Preview", { project });
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## Should not
|
|
16
|
+
|
|
17
|
+
```ts
|
|
14
18
|
const traces = yield* Axiom.Dataset("Traces", { name: "app-traces", kind: "otel:traces:v1" });
|
|
15
19
|
```
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: When a runtime is split into a Tag class and a make Layer, other runtimes must import and bind only the Tag, and only the Stack provides the Layer; another Worker or Function must never import or provide that implementation Layer.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
// src/Admin.ts
|
|
7
9
|
import { Api } from "./Api.ts";
|
|
8
10
|
|
|
@@ -14,7 +16,9 @@ export default Cloudflare.Worker("Admin", { main: import.meta.url },
|
|
|
14
16
|
);
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
## Never
|
|
20
|
+
|
|
21
|
+
```ts
|
|
18
22
|
// src/Admin.ts
|
|
19
23
|
import ApiLive, { Api } from "./Api.ts";
|
|
20
24
|
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
description: A Config default or fallback used by a runtime must be deterministic, because it is evaluated at deploy time and at runtime, never computed from the clock, randomness, or per-process values.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
const port = yield* Config.Number("PORT").pipe(Config.withDefault(3000));
|
|
7
9
|
const region = yield* Config.String("REGION").pipe(Config.withDefault("us-east-1"));
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
## Never
|
|
13
|
+
|
|
14
|
+
```ts
|
|
11
15
|
const instanceId = yield* Config.String("INSTANCE_ID").pipe(Config.withDefault(crypto.randomUUID()));
|
|
12
16
|
const startedAt = yield* Config.String("STARTED_AT").pipe(Config.withDefault(new Date().toISOString()));
|
|
13
17
|
```
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: Connections, pools, sockets, and anything else with a finalizer must be acquired inside a handler or method, where the event's scope releases them, never at module scope or in a runtime's construction Effect, whose finalizers may never run.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
Effect.gen(function* () {
|
|
7
9
|
const url = yield* Config.Redacted("DATABASE_URL");
|
|
8
10
|
return {
|
|
@@ -18,7 +20,9 @@ Effect.gen(function* () {
|
|
|
18
20
|
});
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
## Never
|
|
24
|
+
|
|
25
|
+
```ts
|
|
22
26
|
Effect.gen(function* () {
|
|
23
27
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL }); // one pool for the isolate
|
|
24
28
|
yield* Effect.addFinalizer(() => Effect.promise(() => pool.end())); // workerd never runs this
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: In a Lambda handler, anything that must not be lost must be written durably, to a queue or a table, inside the handler before it responds, never left to a dangling promise, a forked fiber, or a finalizer, because Lambda freezes the instance after the response.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
fetch: Effect.gen(function* () {
|
|
7
9
|
const event = yield* HttpServerRequest.schemaBodyJson(AuditEvent);
|
|
8
10
|
yield* sendMessage({ MessageBody: JSON.stringify(event) });
|
|
@@ -10,7 +12,9 @@ fetch: Effect.gen(function* () {
|
|
|
10
12
|
}),
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## Never
|
|
16
|
+
|
|
17
|
+
```ts
|
|
14
18
|
fetch: Effect.gen(function* () {
|
|
15
19
|
const event = yield* HttpServerRequest.schemaBodyJson(AuditEvent);
|
|
16
20
|
void fetch(AUDIT_URL, { method: "POST", body: JSON.stringify(event) }); // may never run
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
description: A runtime's construction Effect runs at deploy time and again at cold start, so it must only build resources, bindings, layers, and handler groups, never do per-request work such as queries, writes, bound calls, or starting consumers.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
6
8
|
Effect.gen(function* () {
|
|
7
9
|
const table = yield* AWS.DynamoDB.Table("Jobs", { partitionKey: "id", attributes: { id: "S" } });
|
|
8
10
|
const putItem = yield* AWS.DynamoDB.PutItem(table);
|
|
@@ -15,7 +17,9 @@ Effect.gen(function* () {
|
|
|
15
17
|
});
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
## Never
|
|
21
|
+
|
|
22
|
+
```ts
|
|
19
23
|
Effect.gen(function* () {
|
|
20
24
|
const putItem = yield* AWS.DynamoDB.PutItem(table);
|
|
21
25
|
yield* putItem({ Item: { id: { S: "seed" } } }); // runs at deploy and on every cold start
|