pipework 0.252.0 → 0.253.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/CHANGELOG.md +10 -0
- package/dist/infra/support/handle.d.ts +8 -1
- package/dist/infra/support/handle.d.ts.map +1 -1
- package/dist/infra/support/handle.js +9 -2
- package/dist/infra/support/handle.js.map +1 -1
- package/dist/infra/support/namespace.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/async/jobs/abortable-delay.ts +18 -0
- package/src/async/jobs/claims.ts +52 -0
- package/src/async/jobs/cron.ts +179 -0
- package/src/async/jobs/errors.ts +56 -0
- package/src/async/jobs/exclusion.ts +117 -0
- package/src/async/jobs/execute.ts +219 -0
- package/src/async/jobs/index.ts +10 -0
- package/src/async/jobs/listener.ts +135 -0
- package/src/async/jobs/namespace.ts +20 -0
- package/src/async/jobs/queue-door.ts +44 -0
- package/src/async/jobs/queue.ts +1012 -0
- package/src/async/jobs/run-script.ts +67 -0
- package/src/async/jobs/scheduler.ts +218 -0
- package/src/async/jobs/schedules-table.ts +37 -0
- package/src/async/jobs/serialize-error.ts +48 -0
- package/src/async/jobs/table.ts +191 -0
- package/src/async/pipeline/define.ts +53 -0
- package/src/async/pipeline/errors.ts +35 -0
- package/src/async/pipeline/execute.ts +53 -0
- package/src/async/pipeline/index.ts +13 -0
- package/src/async/pipeline/namespace.ts +12 -0
- package/src/async/pipeline/resume.ts +67 -0
- package/src/async/pipeline/run-steps.ts +98 -0
- package/src/async/pipeline/types.ts +57 -0
- package/src/async/state-machine/define.ts +64 -0
- package/src/async/state-machine/errors.ts +29 -0
- package/src/async/state-machine/index.ts +3 -0
- package/src/async/state-machine/namespace.ts +12 -0
- package/src/async/state-machine/transition.ts +90 -0
- package/src/async/state-machine/types.ts +37 -0
- package/src/async/steps/errors.ts +31 -0
- package/src/async/steps/graph.ts +1214 -0
- package/src/async/steps/hash.ts +40 -0
- package/src/async/steps/index.ts +7 -0
- package/src/async/steps/namespace.ts +7 -0
- package/src/async/steps/table.ts +97 -0
- package/src/async/steps/types.ts +318 -0
- package/src/audit/chain/index.ts +9 -0
- package/src/audit/chain/install.ts +113 -0
- package/src/audit/chain/retire.ts +52 -0
- package/src/audit/chain/verify.ts +257 -0
- package/src/audit/query/cursor.ts +37 -0
- package/src/audit/query/index.ts +9 -0
- package/src/audit/query/query.ts +223 -0
- package/src/audit/schema/audit-record-fields.ts +68 -0
- package/src/audit/schema/audit-record.ts +23 -0
- package/src/auth/auth/audit.ts +31 -0
- package/src/auth/auth/chain.ts +84 -0
- package/src/auth/auth/cookie.ts +136 -0
- package/src/auth/auth/email-links.ts +311 -0
- package/src/auth/auth/guard.ts +128 -0
- package/src/auth/auth/index.ts +18 -0
- package/src/auth/auth/multi-org.ts +337 -0
- package/src/auth/auth/namespace.ts +34 -0
- package/src/auth/auth/parse-expiry.ts +13 -0
- package/src/auth/auth/passwords.ts +110 -0
- package/src/auth/auth/schema.ts +110 -0
- package/src/auth/auth/sessions.ts +565 -0
- package/src/auth/auth/sign-in.ts +156 -0
- package/src/auth/auth/signing-keys.ts +69 -0
- package/src/auth/auth/types.ts +26 -0
- package/src/auth/auth/uuid.ts +10 -0
- package/src/auth/rbac/cache.ts +55 -0
- package/src/auth/rbac/check.ts +30 -0
- package/src/auth/rbac/create.ts +35 -0
- package/src/auth/rbac/index.ts +5 -0
- package/src/auth/rbac/middleware.ts +20 -0
- package/src/auth/rbac/namespace.ts +15 -0
- package/src/auth/rbac/resolve.ts +31 -0
- package/src/auth/rbac/schema.sql +31 -0
- package/src/auth/rbac/seed.ts +67 -0
- package/src/auth/rbac/tables.ts +32 -0
- package/src/auth/rbac/types.ts +65 -0
- package/src/auth/tenant/extract.ts +33 -0
- package/src/auth/tenant/in-tenant.ts +49 -0
- package/src/auth/tenant/index.ts +7 -0
- package/src/auth/tenant/namespace.ts +23 -0
- package/src/auth/tenant/propagate.ts +20 -0
- package/src/auth/tenant/rls.ts +120 -0
- package/src/auth/tenant/scope.ts +33 -0
- package/src/auth/tenant/scoped-db.ts +581 -0
- package/src/auth/tenant/types.ts +14 -0
- package/src/auth/tenant/validate.ts +27 -0
- package/src/cli/commands/add.ts +158 -0
- package/src/cli/commands/alerts.ts +98 -0
- package/src/cli/commands/check.ts +1418 -0
- package/src/cli/commands/codemod.ts +78 -0
- package/src/cli/commands/db.ts +356 -0
- package/src/cli/commands/dev.ts +101 -0
- package/src/cli/commands/generate.ts +75 -0
- package/src/cli/commands/init.ts +36 -0
- package/src/cli/commands/install.ts +28 -0
- package/src/cli/commands/lint.ts +62 -0
- package/src/cli/commands/migrate.ts +132 -0
- package/src/cli/commands/openapi.ts +63 -0
- package/src/cli/commands/remove.ts +72 -0
- package/src/cli/commands/run.ts +50 -0
- package/src/cli/commands/scope.ts +54 -0
- package/src/cli/commands/serve.ts +68 -0
- package/src/cli/commands/shard.ts +31 -0
- package/src/cli/commands/shared/config-pipework-version.ts +28 -0
- package/src/cli/commands/shared/find-config.ts +22 -0
- package/src/cli/commands/shared/load-config.ts +35 -0
- package/src/cli/commands/shared/pnpm-install.ts +95 -0
- package/src/cli/commands/shared/report-removed.ts +8 -0
- package/src/cli/commands/sync.ts +32 -0
- package/src/cli/commands/test.ts +307 -0
- package/src/cli/commands/upgrade.ts +48 -0
- package/src/cli/dev-bootstrap.ts +24 -0
- package/src/cli/errors.ts +10 -0
- package/src/cli/index.ts +303 -0
- package/src/cli/output.ts +17 -0
- package/src/cli/port.ts +15 -0
- package/src/cli/surface-kinds.ts +20 -0
- package/src/client/index.ts +4 -0
- package/src/client/poll.ts +130 -0
- package/src/client/report-fault.ts +119 -0
- package/src/core/config/app-pool.ts +19 -0
- package/src/core/config/aws-env.ts +29 -0
- package/src/core/config/base-dir.ts +24 -0
- package/src/core/config/discover.ts +64 -0
- package/src/core/config/env-resolve.ts +87 -0
- package/src/core/config/env-types.ts +116 -0
- package/src/core/config/env.ts +55 -0
- package/src/core/config/errors.ts +9 -0
- package/src/core/config/import-config.ts +40 -0
- package/src/core/config/index.ts +8 -0
- package/src/core/config/load.ts +417 -0
- package/src/core/config/namespace.ts +14 -0
- package/src/core/config/resolve.ts +195 -0
- package/src/core/config/schema.ts +488 -0
- package/src/core/config/statement-observer.ts +48 -0
- package/src/core/config/ts-inline-hooks.ts +75 -0
- package/src/core/config/ts-register.ts +4 -0
- package/src/core/config/ts-resolve-hooks.ts +101 -0
- package/src/core/errors/base.ts +49 -0
- package/src/core/errors/http.ts +146 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/namespace.ts +31 -0
- package/src/core/lifecycle/drain.ts +33 -0
- package/src/core/lifecycle/health.ts +72 -0
- package/src/core/lifecycle/index.ts +4 -0
- package/src/core/lifecycle/namespace.ts +7 -0
- package/src/core/lifecycle/startup.ts +102 -0
- package/src/core/lifecycle/types.ts +36 -0
- package/src/core/logging/context.ts +60 -0
- package/src/core/logging/error-chain.ts +68 -0
- package/src/core/logging/index.ts +5 -0
- package/src/core/logging/logger.ts +65 -0
- package/src/core/logging/namespace.ts +16 -0
- package/src/core/logging/proxy.ts +22 -0
- package/src/core/logging/redact-match.ts +24 -0
- package/src/core/logging/types.ts +47 -0
- package/src/core/pipework.ts +566 -0
- package/src/core/runtime/broker.ts +69 -0
- package/src/core/surface/http.ts +133 -0
- package/src/core/surface/index.ts +11 -0
- package/src/core/surface/job-timeout.ts +40 -0
- package/src/core/surface/namespace.ts +13 -0
- package/src/core/surface/script.ts +33 -0
- package/src/core/surface/start.ts +142 -0
- package/src/core/surface/types.ts +225 -0
- package/src/core/surface/validate.ts +88 -0
- package/src/core/surface/worker.ts +528 -0
- package/src/core/validation/connectivity.ts +49 -0
- package/src/core/validation/domain.ts +99 -0
- package/src/core/validation/index.ts +5 -0
- package/src/core/validation/queue-tables.ts +312 -0
- package/src/core/validation/rls-role.ts +138 -0
- package/src/core/validation/startup.ts +51 -0
- package/src/core/validation/surfaces.ts +65 -0
- package/src/core/validation/types.ts +25 -0
- package/src/data/db/advisory-lock.ts +55 -0
- package/src/data/db/aggregates.ts +172 -0
- package/src/data/db/array-elements.ts +111 -0
- package/src/data/db/bulk-set.ts +53 -0
- package/src/data/db/col/index.ts +14 -0
- package/src/data/db/composed.ts +53 -0
- package/src/data/db/db.ts +276 -0
- package/src/data/db/excluded.ts +16 -0
- package/src/data/db/expressions.ts +473 -0
- package/src/data/db/filter.ts +90 -0
- package/src/data/db/function-source.ts +114 -0
- package/src/data/db/identifiers.ts +16 -0
- package/src/data/db/idx/index.ts +9 -0
- package/src/data/db/in-list.ts +49 -0
- package/src/data/db/index.ts +9 -0
- package/src/data/db/insert-split.ts +137 -0
- package/src/data/db/instrumentation.ts +82 -0
- package/src/data/db/maintenance.ts +28 -0
- package/src/data/db/namespace.ts +107 -0
- package/src/data/db/owner-labels.ts +9 -0
- package/src/data/db/pool.ts +187 -0
- package/src/data/db/query-label.ts +13 -0
- package/src/data/db/query-watch.ts +116 -0
- package/src/data/db/raw-client-warning.ts +37 -0
- package/src/data/db/readonly.ts +23 -0
- package/src/data/db/reservation.ts +151 -0
- package/src/data/db/schema-features.ts +14 -0
- package/src/data/db/serializable.ts +83 -0
- package/src/data/db/sql.ts +1 -0
- package/src/data/db/timeouts.ts +76 -0
- package/src/data/db/types.ts +18 -0
- package/src/data/db/unbound.ts +33 -0
- package/src/data/db/values.ts +29 -0
- package/src/data/domain/brand.ts +15 -0
- package/src/data/domain/build-factory.ts +193 -0
- package/src/data/domain/build-schema.ts +161 -0
- package/src/data/domain/build-table.ts +355 -0
- package/src/data/domain/define.ts +239 -0
- package/src/data/domain/external-registry.ts +34 -0
- package/src/data/domain/external.ts +77 -0
- package/src/data/domain/field.ts +279 -0
- package/src/data/domain/index.ts +13 -0
- package/src/data/domain/naming.ts +6 -0
- package/src/data/domain/projections.ts +57 -0
- package/src/data/domain/trace-safe-projection.ts +31 -0
- package/src/data/domain/traits.ts +108 -0
- package/src/data/domain/types.ts +289 -0
- package/src/data/jsonb/composed.ts +18 -0
- package/src/data/jsonb/index.ts +1 -0
- package/src/data/jsonb/namespace.ts +30 -0
- package/src/data/jsonb/query.ts +86 -0
- package/src/data/migrate/append-only-generate.ts +47 -0
- package/src/data/migrate/applied.ts +47 -0
- package/src/data/migrate/apply.ts +309 -0
- package/src/data/migrate/check.ts +119 -0
- package/src/data/migrate/diff.ts +357 -0
- package/src/data/migrate/door-generate.ts +130 -0
- package/src/data/migrate/framework-rls.ts +58 -0
- package/src/data/migrate/generate.ts +201 -0
- package/src/data/migrate/history.ts +215 -0
- package/src/data/migrate/index.ts +9 -0
- package/src/data/migrate/internal-definitions.ts +175 -0
- package/src/data/migrate/post-process.ts +73 -0
- package/src/data/migrate/pragma.ts +73 -0
- package/src/data/migrate/provision-framework.ts +147 -0
- package/src/data/migrate/rls-generate.ts +61 -0
- package/src/data/migrate/run.ts +124 -0
- package/src/data/migrate/safety.ts +167 -0
- package/src/data/migrate/snapshot.ts +368 -0
- package/src/data/migrate/sql-emit.ts +392 -0
- package/src/data/migrate/statements.ts +312 -0
- package/src/data/query/alias.ts +126 -0
- package/src/data/query/batch.ts +8 -0
- package/src/data/query/cache/core/cache.ts +78 -0
- package/src/data/query/cache/core/index.ts +1 -0
- package/src/data/query/cache/core/types.ts +29 -0
- package/src/data/query/cache/readme.md +230 -0
- package/src/data/query/casing.ts +76 -0
- package/src/data/query/collation.ts +11 -0
- package/src/data/query/column-builder.ts +370 -0
- package/src/data/query/column.ts +150 -0
- package/src/data/query/entity.ts +42 -0
- package/src/data/query/errors.ts +33 -0
- package/src/data/query/index.ts +14 -0
- package/src/data/query/logger.ts +47 -0
- package/src/data/query/operations.ts +45 -0
- package/src/data/query/pg-core/alias.ts +12 -0
- package/src/data/query/pg-core/checks.ts +32 -0
- package/src/data/query/pg-core/columns/all.ts +71 -0
- package/src/data/query/pg-core/columns/bigint.ts +110 -0
- package/src/data/query/pg-core/columns/bigserial.ts +128 -0
- package/src/data/query/pg-core/columns/boolean.ts +43 -0
- package/src/data/query/pg-core/columns/char.ts +85 -0
- package/src/data/query/pg-core/columns/cidr.ts +43 -0
- package/src/data/query/pg-core/columns/common.ts +366 -0
- package/src/data/query/pg-core/columns/custom.ts +232 -0
- package/src/data/query/pg-core/columns/date.common.ts +15 -0
- package/src/data/query/pg-core/columns/date.ts +112 -0
- package/src/data/query/pg-core/columns/double-precision.ts +55 -0
- package/src/data/query/pg-core/columns/enum.ts +201 -0
- package/src/data/query/pg-core/columns/index.ts +35 -0
- package/src/data/query/pg-core/columns/inet.ts +43 -0
- package/src/data/query/pg-core/columns/int.common.ts +57 -0
- package/src/data/query/pg-core/columns/integer.ts +53 -0
- package/src/data/query/pg-core/columns/interval.ts +83 -0
- package/src/data/query/pg-core/columns/json.ts +58 -0
- package/src/data/query/pg-core/columns/jsonb.ts +56 -0
- package/src/data/query/pg-core/columns/line.ts +117 -0
- package/src/data/query/pg-core/columns/macaddr.ts +43 -0
- package/src/data/query/pg-core/columns/macaddr8.ts +43 -0
- package/src/data/query/pg-core/columns/numeric.ts +227 -0
- package/src/data/query/pg-core/columns/point.ts +127 -0
- package/src/data/query/pg-core/columns/postgis_extension/geometry.ts +119 -0
- package/src/data/query/pg-core/columns/postgis_extension/utils.ts +47 -0
- package/src/data/query/pg-core/columns/real.ts +58 -0
- package/src/data/query/pg-core/columns/serial.ts +55 -0
- package/src/data/query/pg-core/columns/smallint.ts +53 -0
- package/src/data/query/pg-core/columns/smallserial.ts +60 -0
- package/src/data/query/pg-core/columns/text.ts +67 -0
- package/src/data/query/pg-core/columns/time.ts +72 -0
- package/src/data/query/pg-core/columns/timestamp.ts +163 -0
- package/src/data/query/pg-core/columns/uuid.ts +51 -0
- package/src/data/query/pg-core/columns/varchar.ts +89 -0
- package/src/data/query/pg-core/columns/vector_extension/bit.ts +68 -0
- package/src/data/query/pg-core/columns/vector_extension/halfvec.ts +80 -0
- package/src/data/query/pg-core/columns/vector_extension/sparsevec.ts +67 -0
- package/src/data/query/pg-core/columns/vector_extension/vector.ts +80 -0
- package/src/data/query/pg-core/db.ts +674 -0
- package/src/data/query/pg-core/dialect.ts +1404 -0
- package/src/data/query/pg-core/expressions.ts +25 -0
- package/src/data/query/pg-core/foreign-keys.ts +130 -0
- package/src/data/query/pg-core/index.ts +21 -0
- package/src/data/query/pg-core/indexes.ts +276 -0
- package/src/data/query/pg-core/policies.ts +56 -0
- package/src/data/query/pg-core/primary-keys.ts +62 -0
- package/src/data/query/pg-core/query-builders/count.ts +86 -0
- package/src/data/query/pg-core/query-builders/delete.ts +293 -0
- package/src/data/query/pg-core/query-builders/index.ts +7 -0
- package/src/data/query/pg-core/query-builders/insert.ts +452 -0
- package/src/data/query/pg-core/query-builders/query-builder.ts +123 -0
- package/src/data/query/pg-core/query-builders/query.ts +157 -0
- package/src/data/query/pg-core/query-builders/raw.ts +49 -0
- package/src/data/query/pg-core/query-builders/refresh-materialized-view.ts +108 -0
- package/src/data/query/pg-core/query-builders/select.ts +1346 -0
- package/src/data/query/pg-core/query-builders/select.types.ts +437 -0
- package/src/data/query/pg-core/query-builders/update.ts +626 -0
- package/src/data/query/pg-core/roles.ts +41 -0
- package/src/data/query/pg-core/schema.ts +72 -0
- package/src/data/query/pg-core/sequence.ts +41 -0
- package/src/data/query/pg-core/session.ts +292 -0
- package/src/data/query/pg-core/subquery.ts +46 -0
- package/src/data/query/pg-core/table.ts +252 -0
- package/src/data/query/pg-core/unique-constraint.ts +73 -0
- package/src/data/query/pg-core/utils/array.ts +95 -0
- package/src/data/query/pg-core/utils/index.ts +1 -0
- package/src/data/query/pg-core/utils.ts +104 -0
- package/src/data/query/pg-core/view-base.ts +14 -0
- package/src/data/query/pg-core/view-common.ts +1 -0
- package/src/data/query/pg-core/view.ts +435 -0
- package/src/data/query/pg-core/with-builder.ts +41 -0
- package/src/data/query/postgres-js/README.md +46 -0
- package/src/data/query/postgres-js/array-parser.ts +45 -0
- package/src/data/query/postgres-js/driver-error-boundary.ts +149 -0
- package/src/data/query/postgres-js/driver.ts +163 -0
- package/src/data/query/postgres-js/index.ts +2 -0
- package/src/data/query/postgres-js/session.ts +234 -0
- package/src/data/query/primary-key.ts +11 -0
- package/src/data/query/query-builders/query-builder.ts +19 -0
- package/src/data/query/query-builders/select.types.ts +175 -0
- package/src/data/query/query-promise.ts +35 -0
- package/src/data/query/relations.ts +725 -0
- package/src/data/query/runnable-query.ts +12 -0
- package/src/data/query/selection-proxy.ts +121 -0
- package/src/data/query/session.ts +8 -0
- package/src/data/query/sql/expressions/conditions.ts +741 -0
- package/src/data/query/sql/expressions/index.ts +2 -0
- package/src/data/query/sql/expressions/select.ts +45 -0
- package/src/data/query/sql/functions/aggregate.ts +129 -0
- package/src/data/query/sql/functions/index.ts +2 -0
- package/src/data/query/sql/functions/vector.ts +178 -0
- package/src/data/query/sql/index.ts +3 -0
- package/src/data/query/sql/sql.ts +794 -0
- package/src/data/query/subquery.ts +53 -0
- package/src/data/query/table.ts +208 -0
- package/src/data/query/table.utils.ts +2 -0
- package/src/data/query/tracing-utils.ts +3 -0
- package/src/data/query/tracing.ts +11 -0
- package/src/data/query/utils.ts +357 -0
- package/src/data/query/version.ts +4 -0
- package/src/data/query/view-common.ts +1 -0
- package/src/data/schema/branded.ts +13 -0
- package/src/data/schema/generated/coerce.ts +5 -0
- package/src/data/schema/generated/combinator.ts +6 -0
- package/src/data/schema/generated/encoding.ts +5 -0
- package/src/data/schema/generated/error.ts +6 -0
- package/src/data/schema/generated/format.ts +5 -0
- package/src/data/schema/generated/type-constructor.ts +10 -0
- package/src/data/schema/generated/type.ts +6 -0
- package/src/data/schema/index.ts +6 -0
- package/src/data/schema/json-schema.ts +33 -0
- package/src/data/schema/namespace.ts +55 -0
- package/src/data/schema/parse.ts +39 -0
- package/src/data/schema/types.ts +39 -0
- package/src/data/temporal/append-only.ts +134 -0
- package/src/data/temporal/close.ts +27 -0
- package/src/data/temporal/columns.ts +10 -0
- package/src/data/temporal/definition-queries.ts +70 -0
- package/src/data/temporal/errors.ts +10 -0
- package/src/data/temporal/get-current.ts +22 -0
- package/src/data/temporal/index.ts +9 -0
- package/src/data/temporal/namespace.ts +36 -0
- package/src/data/temporal/query.ts +25 -0
- package/src/data/temporal/resolve.ts +17 -0
- package/src/data/temporal/revise.ts +67 -0
- package/src/data/temporal/types.ts +22 -0
- package/src/data/valve/artifact.ts +33 -0
- package/src/data/valve/contract-diff.ts +21 -0
- package/src/data/valve/cross.ts +57 -0
- package/src/data/valve/crossing-ledger.ts +50 -0
- package/src/data/valve/decision.ts +44 -0
- package/src/data/valve/durable-crossing.ts +52 -0
- package/src/data/valve/execute-crossing.ts +77 -0
- package/src/data/valve/faucet.ts +56 -0
- package/src/data/valve/gate.ts +114 -0
- package/src/data/valve/graph.ts +77 -0
- package/src/data/valve/index.ts +53 -0
- package/src/data/valve/namespace.ts +57 -0
- package/src/data/valve/registry.ts +50 -0
- package/src/data/valve/schema/valve-crossing-fields.ts +23 -0
- package/src/data/valve/schema/valve-crossing.ts +9 -0
- package/src/data/valve/supply.ts +154 -0
- package/src/data/valve/types.ts +143 -0
- package/src/data/vector/columns.ts +27 -0
- package/src/data/vector/index.ts +4 -0
- package/src/data/vector/namespace.ts +33 -0
- package/src/data/vector/query.ts +85 -0
- package/src/data/vector/validate.ts +16 -0
- package/src/data/write/audit/actor.ts +15 -0
- package/src/data/write/audit/audit-columns.ts +18 -0
- package/src/data/write/audit/audit-select.ts +111 -0
- package/src/data/write/audit/bound-transaction.ts +14 -0
- package/src/data/write/audit/cte-compile.ts +32 -0
- package/src/data/write/audit/decode-returning.ts +47 -0
- package/src/data/write/audit/final-select.ts +43 -0
- package/src/data/write/audit/infra-audit.ts +36 -0
- package/src/data/write/audit/primary-key.ts +65 -0
- package/src/data/write/audit/projection.ts +41 -0
- package/src/data/write/audit/read-touches.ts +86 -0
- package/src/data/write/audit/touch-match.ts +78 -0
- package/src/data/write/audit/verb-delete.ts +62 -0
- package/src/data/write/audit/verb-insert.ts +36 -0
- package/src/data/write/audit/verb-select.ts +47 -0
- package/src/data/write/audit/verb-update.ts +60 -0
- package/src/data/write/audit/verb-upsert.ts +104 -0
- package/src/data/write/conflict.ts +43 -0
- package/src/data/write/delete.ts +38 -0
- package/src/data/write/execute.ts +243 -0
- package/src/data/write/index.ts +11 -0
- package/src/data/write/insert.ts +63 -0
- package/src/data/write/state.ts +43 -0
- package/src/data/write/update.ts +54 -0
- package/src/data/write/upsert.ts +58 -0
- package/src/data/write/values.ts +19 -0
- package/src/index.ts +230 -0
- package/src/infra/access-links/code.ts +70 -0
- package/src/infra/access-links/handle.ts +892 -0
- package/src/infra/access-links/index.ts +9 -0
- package/src/infra/access-links/namespace.ts +30 -0
- package/src/infra/access-links/person-check.ts +177 -0
- package/src/infra/access-links/schema.ts +71 -0
- package/src/infra/alerts/async-sources.ts +231 -0
- package/src/infra/alerts/audit.ts +36 -0
- package/src/infra/alerts/beat.ts +123 -0
- package/src/infra/alerts/client-faults.ts +111 -0
- package/src/infra/alerts/faults.ts +156 -0
- package/src/infra/alerts/handle.ts +778 -0
- package/src/infra/alerts/http.ts +192 -0
- package/src/infra/alerts/index.ts +12 -0
- package/src/infra/alerts/infra-sources.ts +180 -0
- package/src/infra/alerts/namespace.ts +81 -0
- package/src/infra/alerts/redact.ts +21 -0
- package/src/infra/alerts/request-faults.ts +41 -0
- package/src/infra/alerts/schema.ts +124 -0
- package/src/infra/alerts/sinks.ts +125 -0
- package/src/infra/alerts/source-options.ts +26 -0
- package/src/infra/alerts/sources.ts +503 -0
- package/src/infra/alerts/subsystem-sources.ts +137 -0
- package/src/infra/audit/emit.ts +87 -0
- package/src/infra/audit/index.ts +2 -0
- package/src/infra/audit/namespace.ts +22 -0
- package/src/infra/audit/schema.sql +35 -0
- package/src/infra/audit/types.ts +18 -0
- package/src/infra/aws/credentials.ts +147 -0
- package/src/infra/aws/sigv4.ts +140 -0
- package/src/infra/cache/create.ts +58 -0
- package/src/infra/cache/entry.ts +81 -0
- package/src/infra/cache/index.ts +4 -0
- package/src/infra/cache/namespace.ts +10 -0
- package/src/infra/cache/tenant.ts +93 -0
- package/src/infra/cache/types.ts +24 -0
- package/src/infra/crawl/audit.ts +35 -0
- package/src/infra/crawl/errors.ts +25 -0
- package/src/infra/crawl/handle.ts +693 -0
- package/src/infra/crawl/index.ts +9 -0
- package/src/infra/crawl/links.ts +160 -0
- package/src/infra/crawl/namespace.ts +36 -0
- package/src/infra/crawl/robots.ts +100 -0
- package/src/infra/crawl/schema.ts +89 -0
- package/src/infra/credit-line/handle.ts +883 -0
- package/src/infra/credit-line/index.ts +6 -0
- package/src/infra/credit-line/namespace.ts +7 -0
- package/src/infra/credit-line/schema.ts +96 -0
- package/src/infra/credits/handle.ts +706 -0
- package/src/infra/credits/index.ts +5 -0
- package/src/infra/credits/namespace.ts +7 -0
- package/src/infra/credits/schema.ts +83 -0
- package/src/infra/email/audit.ts +36 -0
- package/src/infra/email/inbound.ts +622 -0
- package/src/infra/email/index.ts +11 -0
- package/src/infra/email/mail.ts +517 -0
- package/src/infra/email/mime.ts +447 -0
- package/src/infra/email/namespace.ts +22 -0
- package/src/infra/email/providers.ts +111 -0
- package/src/infra/email/schema.ts +96 -0
- package/src/infra/email/sns.ts +200 -0
- package/src/infra/email/unsubscribe.ts +44 -0
- package/src/infra/fetch/address.ts +88 -0
- package/src/infra/fetch/connect-retry.ts +87 -0
- package/src/infra/fetch/handle.ts +329 -0
- package/src/infra/fetch/index.ts +7 -0
- package/src/infra/fetch/namespace.ts +70 -0
- package/src/infra/idempotency/index.ts +3 -0
- package/src/infra/idempotency/middleware.ts +87 -0
- package/src/infra/idempotency/store.ts +92 -0
- package/src/infra/idempotency/types.ts +30 -0
- package/src/infra/join-requests/handle.ts +312 -0
- package/src/infra/join-requests/index.ts +2 -0
- package/src/infra/join-requests/namespace.ts +7 -0
- package/src/infra/join-requests/schema.ts +33 -0
- package/src/infra/outbox/core.ts +165 -0
- package/src/infra/quota/errors.ts +32 -0
- package/src/infra/quota/handle.ts +215 -0
- package/src/infra/quota/index.ts +5 -0
- package/src/infra/quota/namespace.ts +7 -0
- package/src/infra/quota/schema.ts +15 -0
- package/src/infra/storage/backend.ts +34 -0
- package/src/infra/storage/handle.ts +513 -0
- package/src/infra/storage/index.ts +9 -0
- package/src/infra/storage/local.ts +104 -0
- package/src/infra/storage/namespace.ts +13 -0
- package/src/infra/storage/s3.ts +257 -0
- package/src/infra/storage/schema.ts +24 -0
- package/src/infra/support/handle.ts +869 -0
- package/src/infra/support/http.ts +229 -0
- package/src/infra/support/index.ts +7 -0
- package/src/infra/support/namespace.ts +12 -0
- package/src/infra/support/schema.ts +96 -0
- package/src/infra/webhook/inbound.ts +45 -0
- package/src/infra/webhook/index.ts +5 -0
- package/src/infra/webhook/namespace.ts +24 -0
- package/src/infra/webhook/outbound.ts +315 -0
- package/src/infra/webhook/sign.ts +32 -0
- package/src/infra/webhook/verify.ts +82 -0
- package/src/invariants/assert.ts +26 -0
- package/src/invariants/index.ts +1 -0
- package/src/lint/config.ts +56 -0
- package/src/lint/doctrine.ts +163 -0
- package/src/lint/include.ts +97 -0
- package/src/lint/index.ts +3 -0
- package/src/lint/preset.ts +10 -0
- package/src/lint/resolver.ts +218 -0
- package/src/lint/rules/no-reexports.ts +46 -0
- package/src/request/behavior/audited.ts +85 -0
- package/src/request/behavior/cached.ts +62 -0
- package/src/request/behavior/compose.ts +28 -0
- package/src/request/behavior/index.ts +5 -0
- package/src/request/behavior/namespace.ts +16 -0
- package/src/request/behavior/types.ts +26 -0
- package/src/request/behavior/versioned.ts +54 -0
- package/src/request/context/create.ts +103 -0
- package/src/request/context/guard.ts +49 -0
- package/src/request/context/index.ts +4 -0
- package/src/request/context/run-in-transaction.ts +200 -0
- package/src/request/context/store.ts +28 -0
- package/src/request/context/types.ts +50 -0
- package/src/request/di/builder.ts +535 -0
- package/src/request/di/index.ts +8 -0
- package/src/request/di/resolve.ts +271 -0
- package/src/request/di/types.ts +262 -0
- package/src/request/fitting/namespace.ts +7 -0
- package/src/request/fixture/batch.ts +71 -0
- package/src/request/fixture/builder.ts +26 -0
- package/src/request/fixture/errors.ts +10 -0
- package/src/request/fixture/index.ts +8 -0
- package/src/request/fixture/namespace.ts +23 -0
- package/src/request/fixture/paginate.ts +44 -0
- package/src/request/fixture/to-handlers.ts +260 -0
- package/src/request/fixture/types.ts +79 -0
- package/src/request/flow/act-as.ts +78 -0
- package/src/request/flow/namespace.ts +17 -0
- package/src/request/http/error-step.ts +42 -0
- package/src/request/http/file.ts +141 -0
- package/src/request/http/index.ts +4 -0
- package/src/request/http/middleware.ts +141 -0
- package/src/request/http/namespace.ts +5 -0
- package/src/request/http/rate-limit-classes.ts +170 -0
- package/src/request/http/response.ts +82 -0
- package/src/request/http/server.ts +916 -0
- package/src/request/http/sniff.ts +261 -0
- package/src/request/http/types.ts +250 -0
- package/src/request/http/upload.ts +284 -0
- package/src/request/internal/text.ts +6 -0
- package/src/request/openapi/generate.ts +205 -0
- package/src/request/openapi/index.ts +2 -0
- package/src/request/openapi/namespace.ts +7 -0
- package/src/test/auth.ts +13 -0
- package/src/test/auto-setup.ts +45 -0
- package/src/test/context.ts +91 -0
- package/src/test/crawl-fixture.ts +157 -0
- package/src/test/discover-fork.ts +17 -0
- package/src/test/fingerprint.ts +135 -0
- package/src/test/globals.d.ts +7 -0
- package/src/test/index.ts +19 -0
- package/src/test/namespace.ts +87 -0
- package/src/test/plugin.ts +189 -0
- package/src/test/progress.ts +185 -0
- package/src/test/reap-after-run.ts +28 -0
- package/src/test/reap.ts +286 -0
- package/src/test/reset.ts +253 -0
- package/src/test/savepoints.ts +172 -0
- package/src/test/setup.ts +1422 -0
- package/src/test/system-channel.ts +50 -0
- package/src/test/vitest-plugin.ts +142 -0
- package/src/test/vitest-workspace.ts +507 -0
- package/src/test/vitest.ts +333 -0
- package/src/test/watch.ts +105 -0
- package/src/trace/capture/bounded-serializer.ts +146 -0
- package/src/trace/cardinality/analyze.ts +173 -0
- package/src/trace/cardinality/branch-context.ts +214 -0
- package/src/trace/cardinality/check.ts +33 -0
- package/src/trace/cardinality/classify-edge.ts +64 -0
- package/src/trace/cardinality/structural-max.ts +107 -0
- package/src/trace/check/coverage-check.ts +188 -0
- package/src/trace/check/flow-check.ts +103 -0
- package/src/trace/check/flow-step-site.ts +234 -0
- package/src/trace/check/io-pattern.ts +116 -0
- package/src/trace/codemod/add-cardinality.ts +152 -0
- package/src/trace/codemod/run.ts +80 -0
- package/src/trace/entry/child-trace.ts +21 -0
- package/src/trace/entry/close-trace.ts +87 -0
- package/src/trace/entry/finalize-trace.ts +28 -0
- package/src/trace/entry/open-trace.ts +138 -0
- package/src/trace/entry/parent-trace.ts +20 -0
- package/src/trace/entry/parse-traceparent.ts +39 -0
- package/src/trace/entry/process-start.ts +38 -0
- package/src/trace/entry/trace-context.ts +26 -0
- package/src/trace/entry/tracing-enabled.ts +10 -0
- package/src/trace/external/record.ts +195 -0
- package/src/trace/external/report.ts +66 -0
- package/src/trace/namespace.ts +19 -0
- package/src/trace/partition/day.ts +47 -0
- package/src/trace/partition/index.ts +14 -0
- package/src/trace/partition/partition-name.ts +23 -0
- package/src/trace/partition/plan.ts +82 -0
- package/src/trace/partition/schedule.ts +26 -0
- package/src/trace/partition/startup-hook.ts +39 -0
- package/src/trace/partition/sync.ts +154 -0
- package/src/trace/partition/tables.ts +53 -0
- package/src/trace/read/tree.ts +83 -0
- package/src/trace/retain/copy-on-error.ts +76 -0
- package/src/trace/runtime/expire-stale-traces.ts +54 -0
- package/src/trace/runtime/flow-step.ts +147 -0
- package/src/trace/runtime/flush-steps.ts +31 -0
- package/src/trace/runtime/pending-writes.ts +54 -0
- package/src/trace/runtime/step-buffer.ts +298 -0
- package/src/trace/runtime/step-error.ts +21 -0
- package/src/trace/runtime/step-meta.ts +55 -0
- package/src/trace/runtime/thenable.ts +12 -0
- package/src/trace/runtime/truncate-trace.ts +62 -0
- package/src/trace/schema/trace-fields.ts +52 -0
- package/src/trace/schema/trace-retained.ts +22 -0
- package/src/trace/schema/trace-step-fields.ts +44 -0
- package/src/trace/schema/trace-step-retained.ts +25 -0
- package/src/trace/schema/trace-step.ts +18 -0
- package/src/trace/schema/trace.ts +12 -0
- package/src/trace/transformer/apply.ts +79 -0
- package/src/trace/transformer/build-meta-literal.ts +30 -0
- package/src/trace/transformer/find-flow-step-sites.ts +85 -0
- package/src/trace/transformer/inject-flow-step-import.ts +39 -0
- package/src/trace/transformer/transform-error.ts +14 -0
- package/src/trace/transformer/transform-flow-steps.ts +123 -0
- package/src/trace/transformer/wrap-function-declaration.ts +62 -0
- package/src/trace/transformer/wrap-variable-declaration.ts +67 -0
- package/src/workspace/affected.ts +110 -0
- package/src/workspace/changed-files.ts +163 -0
- package/src/workspace/check-deps.ts +213 -0
- package/src/workspace/checksum.ts +20 -0
- package/src/workspace/dependency-graph.ts +67 -0
- package/src/workspace/detect-pipework-copies.ts +93 -0
- package/src/workspace/discover.ts +206 -0
- package/src/workspace/generate.ts +401 -0
- package/src/workspace/index.ts +7 -0
- package/src/workspace/module-imports-pipework.ts +39 -0
- package/src/workspace/module-package-name.ts +12 -0
- package/src/workspace/scrub-git-env.ts +23 -0
- package/src/workspace/source-paths.ts +36 -0
- package/src/workspace/sync.ts +182 -0
- package/dist/REFERENCE.md +0 -1477
- package/dist/infra/access-links/rate-limit.d.ts +0 -10
- package/dist/infra/access-links/rate-limit.d.ts.map +0 -1
- package/dist/infra/access-links/rate-limit.js +0 -28
- package/dist/infra/access-links/rate-limit.js.map +0 -1
- package/dist/infra/email/sigv4.d.ts +0 -24
- package/dist/infra/email/sigv4.d.ts.map +0 -1
- package/dist/infra/email/sigv4.js +0 -55
- package/dist/infra/email/sigv4.js.map +0 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { request as httpRequest, type ClientRequest, type IncomingMessage } from 'node:http'
|
|
2
|
+
import { request as httpsRequest } from 'node:https'
|
|
3
|
+
import { lookup as dnsLookup } from 'node:dns/promises'
|
|
4
|
+
import { isIP, type Socket } from 'node:net'
|
|
5
|
+
import { connect as tlsConnect } from 'node:tls'
|
|
6
|
+
import { __flowStep } from '../../trace/runtime/flow-step.js'
|
|
7
|
+
import { isPublicAddress } from './address.js'
|
|
8
|
+
|
|
9
|
+
/** What the app is shown of a response before the body is kept. */
|
|
10
|
+
export interface FetchHead {
|
|
11
|
+
status: number
|
|
12
|
+
contentType: string | null
|
|
13
|
+
/** The first bytes of the body: up to 1024, fewer when the body is shorter. */
|
|
14
|
+
firstBytes: Uint8Array
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The connection the fetch is about to open, after the address check passed. */
|
|
18
|
+
export interface FetchTarget {
|
|
19
|
+
address: string
|
|
20
|
+
port: number
|
|
21
|
+
hostname: string
|
|
22
|
+
protocol: 'http:' | 'https:'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Options for `untrustedFetch.get`. The three limits are required: the app says. */
|
|
26
|
+
export interface UntrustedFetchOptions {
|
|
27
|
+
/** The most body bytes kept; the fetch stops the moment the total passes it. */
|
|
28
|
+
maxBytes: number
|
|
29
|
+
/** One time limit for the whole fetch, DNS and redirects included. */
|
|
30
|
+
timeoutMs: number
|
|
31
|
+
/** The most redirects followed. */
|
|
32
|
+
maxRedirects: number
|
|
33
|
+
/** Called with the status, content type and first bytes before the rest of the body is kept. Return false, or a message string, to refuse. */
|
|
34
|
+
accept?: (head: FetchHead) => boolean | string
|
|
35
|
+
/**
|
|
36
|
+
* Called before each redirect hop with the hop's absolute URL and the URL it came from. Return true to follow it; false, or a
|
|
37
|
+
* message string, refuses with `rejected`. The address check still runs on every hop that is followed.
|
|
38
|
+
*/
|
|
39
|
+
followRedirect?: (to: string, from: string) => boolean | string
|
|
40
|
+
/** Headers sent to the first URL's origin only, never to another origin after a redirect. */
|
|
41
|
+
headers?: Record<string, string>
|
|
42
|
+
/** When true the full URL is recorded in the trace step; otherwise only scheme and host. */
|
|
43
|
+
urlIsSafeToTrace?: boolean
|
|
44
|
+
/** For tests: replaces DNS. Its answer is still address-checked. */
|
|
45
|
+
resolve?: (hostname: string) => Promise<string[]>
|
|
46
|
+
/** For tests: replaces the socket, after the address check passed for `target.address`. */
|
|
47
|
+
connect?: (target: FetchTarget) => Socket
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options for `untrustedFetch.post`: the same rules and limits as `get`, plus the body sent. */
|
|
51
|
+
export interface UntrustedPostOptions extends UntrustedFetchOptions {
|
|
52
|
+
/** The request body, sent as given; the app sets its own `content-type` in `headers`. */
|
|
53
|
+
body: string | Uint8Array
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What is sent: GET has no body. */
|
|
57
|
+
interface Spec { method: 'GET' | 'POST'; body: Buffer | null }
|
|
58
|
+
|
|
59
|
+
export type FetchRefusal = 'scheme' | 'address' | 'redirects' | 'too_large' | 'timeout' | 'status' | 'rejected' | 'network'
|
|
60
|
+
|
|
61
|
+
/** A fetched body, or the rule that refused. Never thrown. */
|
|
62
|
+
export type UntrustedFetchResult =
|
|
63
|
+
| { ok: true; url: string; status: number; contentType: string | null; etag: string | null; lastModified: string | null; body: Uint8Array; bytes: number }
|
|
64
|
+
| { ok: false; reason: FetchRefusal; message: string; status?: number }
|
|
65
|
+
|
|
66
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308])
|
|
67
|
+
const HEAD_BYTES = 1024
|
|
68
|
+
|
|
69
|
+
class Refusal extends Error {
|
|
70
|
+
constructor(readonly reason: FetchRefusal, message: string, readonly status?: number) { super(message) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const MESSAGES = {
|
|
74
|
+
scheme: 'Only http and https links can be fetched.',
|
|
75
|
+
address: 'The link points to a private network address.',
|
|
76
|
+
redirects: 'The link redirects too many times.',
|
|
77
|
+
too_large: 'The file is larger than allowed.',
|
|
78
|
+
timeout: 'The server took too long to answer.',
|
|
79
|
+
network: 'The server could not be reached.',
|
|
80
|
+
} as const
|
|
81
|
+
|
|
82
|
+
async function defaultResolve(hostname: string): Promise<string[]> {
|
|
83
|
+
return (await dnsLookup(hostname, { all: true, verbatim: true })).map(a => a.address)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Run { aborted: boolean; current: ClientRequest | null }
|
|
87
|
+
|
|
88
|
+
function bareHost(url: URL): string {
|
|
89
|
+
return url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchOnce(
|
|
93
|
+
run: Run, url: URL, options: UntrustedFetchOptions, headers: Record<string, string>, spec: Spec,
|
|
94
|
+
): Promise<{ redirect: string; status: number } | { final: IncomingMessage; req: ClientRequest }> {
|
|
95
|
+
const hostname = bareHost(url)
|
|
96
|
+
const protocol = url.protocol as 'http:' | 'https:'
|
|
97
|
+
let addresses: string[]
|
|
98
|
+
if (isIP(hostname) !== 0) {
|
|
99
|
+
addresses = [hostname]
|
|
100
|
+
} else {
|
|
101
|
+
try {
|
|
102
|
+
addresses = await (options.resolve ?? defaultResolve)(hostname)
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Refusal('network', MESSAGES.network)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (run.aborted) throw new Refusal('timeout', MESSAGES.timeout)
|
|
108
|
+
if (addresses.length === 0) throw new Refusal('network', MESSAGES.network)
|
|
109
|
+
if (!addresses.every(isPublicAddress)) throw new Refusal('address', MESSAGES.address)
|
|
110
|
+
const address = addresses[0]!
|
|
111
|
+
const port = url.port === '' ? (protocol === 'https:' ? 443 : 80) : Number(url.port)
|
|
112
|
+
|
|
113
|
+
const base = {
|
|
114
|
+
method: spec.method, hostname, port, path: url.pathname + url.search,
|
|
115
|
+
headers: {
|
|
116
|
+
'accept-encoding': 'identity', ...headers, host: url.host,
|
|
117
|
+
...(spec.body !== null ? { 'content-length': String(spec.body.length) } : {}),
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
const doRequest = protocol === 'https:' ? httpsRequest : httpRequest
|
|
121
|
+
const req = options.connect !== undefined
|
|
122
|
+
? doRequest({
|
|
123
|
+
...base, agent: undefined,
|
|
124
|
+
createConnection: () => {
|
|
125
|
+
const socket = options.connect!({ address, port, hostname, protocol })
|
|
126
|
+
return protocol === 'https:' ? tlsConnect({ socket, ...(isIP(hostname) === 0 ? { servername: hostname } : {}) }) : socket
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
: doRequest({
|
|
130
|
+
...base, agent: false,
|
|
131
|
+
lookup: (_h, opts, cb) => {
|
|
132
|
+
const entry = { address, family: isIP(address) }
|
|
133
|
+
if ((opts as { all?: boolean }).all === true) (cb as unknown as (e: null, a: unknown) => void)(null, [entry])
|
|
134
|
+
else cb(null, address, entry.family)
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
run.current = req
|
|
138
|
+
return await new Promise((resolve, reject) => {
|
|
139
|
+
req.on('error', () => { reject(run.aborted ? new Refusal('timeout', MESSAGES.timeout) : new Refusal('network', MESSAGES.network)) })
|
|
140
|
+
req.on('response', res => {
|
|
141
|
+
const status = res.statusCode ?? 0
|
|
142
|
+
const location = res.headers['location']
|
|
143
|
+
if (REDIRECTS.has(status) && typeof location === 'string') {
|
|
144
|
+
res.destroy()
|
|
145
|
+
req.destroy()
|
|
146
|
+
resolve({ redirect: location, status })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
resolve({ final: res, req })
|
|
150
|
+
})
|
|
151
|
+
if (spec.body !== null) req.end(spec.body)
|
|
152
|
+
else req.end()
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readBody(
|
|
157
|
+
res: IncomingMessage, req: ClientRequest, options: UntrustedFetchOptions, run: Run, spec: Spec,
|
|
158
|
+
): Promise<{ body: Uint8Array; contentType: string | null; etag: string | null; lastModified: string | null }> {
|
|
159
|
+
const status = res.statusCode ?? 0
|
|
160
|
+
const rawType = res.headers['content-type']
|
|
161
|
+
const contentType = typeof rawType === 'string' ? rawType : null
|
|
162
|
+
const header = (name: string): string | null => { const v = res.headers[name]; return typeof v === 'string' ? v : null }
|
|
163
|
+
const etag = header('etag')
|
|
164
|
+
const lastModified = header('last-modified')
|
|
165
|
+
const stop = (refusal: Refusal): never => { req.destroy(); res.destroy(); throw refusal }
|
|
166
|
+
if (status < 200 || status > 299) return stop(new Refusal('status', `The server answered with HTTP status ${status}.`, status))
|
|
167
|
+
const declared = Number(res.headers['content-length'])
|
|
168
|
+
const tooLarge = (): Refusal => new Refusal('too_large', MESSAGES.too_large, spec.method === 'POST' ? status : undefined)
|
|
169
|
+
if (Number.isFinite(declared) && declared > options.maxBytes) return stop(tooLarge())
|
|
170
|
+
|
|
171
|
+
return await new Promise((resolve, reject) => {
|
|
172
|
+
const chunks: Buffer[] = []
|
|
173
|
+
let total = 0
|
|
174
|
+
let accepted = options.accept === undefined
|
|
175
|
+
const fail = (r: Refusal): void => { reject(r); req.destroy(); res.destroy() }
|
|
176
|
+
const runAccept = (): boolean => {
|
|
177
|
+
accepted = true
|
|
178
|
+
const first = Buffer.concat(chunks).subarray(0, HEAD_BYTES)
|
|
179
|
+
let verdict: boolean | string
|
|
180
|
+
try {
|
|
181
|
+
verdict = options.accept!({ status, contentType, firstBytes: new Uint8Array(first) })
|
|
182
|
+
} catch (err) {
|
|
183
|
+
reject(err)
|
|
184
|
+
req.destroy()
|
|
185
|
+
res.destroy()
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
if (verdict === true) return true
|
|
189
|
+
fail(new Refusal('rejected', typeof verdict === 'string' ? verdict : 'The file was not accepted.'))
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
res.on('data', (chunk: Buffer) => {
|
|
193
|
+
total += chunk.length
|
|
194
|
+
if (total > options.maxBytes) { fail(tooLarge()); return }
|
|
195
|
+
chunks.push(chunk)
|
|
196
|
+
if (!accepted && total >= HEAD_BYTES) runAccept()
|
|
197
|
+
})
|
|
198
|
+
res.on('end', () => {
|
|
199
|
+
if (!accepted && !runAccept()) return
|
|
200
|
+
resolve({ body: new Uint8Array(Buffer.concat(chunks)), contentType, etag, lastModified })
|
|
201
|
+
})
|
|
202
|
+
const broken = (): void => reject(run.aborted ? new Refusal('timeout', MESSAGES.timeout) : new Refusal('network', MESSAGES.network))
|
|
203
|
+
res.on('error', broken)
|
|
204
|
+
res.on('aborted', broken)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function runFetch(
|
|
209
|
+
run: Run, rawUrl: string, options: UntrustedFetchOptions, first: Spec,
|
|
210
|
+
): Promise<Extract<UntrustedFetchResult, { ok: true }>> {
|
|
211
|
+
let url: URL
|
|
212
|
+
try {
|
|
213
|
+
url = new URL(rawUrl)
|
|
214
|
+
} catch {
|
|
215
|
+
throw new Refusal('scheme', MESSAGES.scheme)
|
|
216
|
+
}
|
|
217
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Refusal('scheme', MESSAGES.scheme)
|
|
218
|
+
const firstOrigin = url.origin
|
|
219
|
+
let spec = first
|
|
220
|
+
for (let hops = 0; ; hops++) {
|
|
221
|
+
let headers: Record<string, string> = url.origin === firstOrigin ? (options.headers ?? {}) : {}
|
|
222
|
+
if (spec.body === null && first.body !== null) {
|
|
223
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([k]) => !k.toLowerCase().startsWith('content-')))
|
|
224
|
+
}
|
|
225
|
+
const step = await fetchOnce(run, url, options, headers, spec)
|
|
226
|
+
if ('final' in step) {
|
|
227
|
+
const status = step.final.statusCode ?? 0
|
|
228
|
+
const { body, contentType, etag, lastModified } = await readBody(step.final, step.req, options, run, spec)
|
|
229
|
+
return { ok: true, url: url.href, status, contentType, etag, lastModified, body, bytes: body.length }
|
|
230
|
+
}
|
|
231
|
+
if (hops >= options.maxRedirects) throw new Refusal('redirects', MESSAGES.redirects)
|
|
232
|
+
let next: URL
|
|
233
|
+
try {
|
|
234
|
+
next = new URL(step.redirect, url)
|
|
235
|
+
} catch {
|
|
236
|
+
throw new Refusal('scheme', MESSAGES.scheme)
|
|
237
|
+
}
|
|
238
|
+
if (next.protocol !== 'http:' && next.protocol !== 'https:') throw new Refusal('scheme', MESSAGES.scheme)
|
|
239
|
+
if (options.followRedirect !== undefined) {
|
|
240
|
+
const verdict = options.followRedirect(next.href, url.href)
|
|
241
|
+
if (verdict !== true) throw new Refusal('rejected', typeof verdict === 'string' ? verdict : 'The redirect was not followed.')
|
|
242
|
+
}
|
|
243
|
+
// 301, 302 and 303 turn a POST into a GET without a body; 307 and 308 resend it.
|
|
244
|
+
if (spec.method === 'POST' && step.status !== 307 && step.status !== 308) spec = { method: 'GET', body: null }
|
|
245
|
+
url = next
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
interface TraceInfo { host: string | null; url?: string }
|
|
250
|
+
interface TraceOutcome { host: string | null; status: number | null; bytes: number | null; reason: FetchRefusal | null }
|
|
251
|
+
|
|
252
|
+
/** Records the outcome as the captured input of a child step: a step's own capture is limited to its arguments. */
|
|
253
|
+
const recordOutcome = __flowStep({ ref: 'pipework/fetch:outcome', capture: [null] }, (o: TraceOutcome): TraceOutcome => o)
|
|
254
|
+
|
|
255
|
+
/** Fail-closed projection: the raw URL and options are never captured. */
|
|
256
|
+
const DROP = (): undefined => undefined
|
|
257
|
+
|
|
258
|
+
async function runTraced(info: TraceInfo, rawUrl: string, options: UntrustedFetchOptions, spec: Spec): Promise<UntrustedFetchResult> {
|
|
259
|
+
const run: Run = { aborted: false, current: null }
|
|
260
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
261
|
+
const timedOut = new Promise<never>((_, reject) => {
|
|
262
|
+
timer = setTimeout(() => {
|
|
263
|
+
run.aborted = true
|
|
264
|
+
run.current?.destroy()
|
|
265
|
+
reject(new Refusal('timeout', MESSAGES.timeout))
|
|
266
|
+
}, options.timeoutMs)
|
|
267
|
+
})
|
|
268
|
+
let result: UntrustedFetchResult
|
|
269
|
+
try {
|
|
270
|
+
const work = runFetch(run, rawUrl, options, spec)
|
|
271
|
+
work.catch(() => undefined)
|
|
272
|
+
result = await Promise.race([work, timedOut])
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (!(err instanceof Refusal)) throw err
|
|
275
|
+
result = { ok: false, reason: err.reason, message: err.message, ...(err.status !== undefined ? { status: err.status } : {}) }
|
|
276
|
+
} finally {
|
|
277
|
+
clearTimeout(timer)
|
|
278
|
+
}
|
|
279
|
+
recordOutcome({
|
|
280
|
+
host: info.host,
|
|
281
|
+
status: result.ok ? result.status : (result.status ?? null),
|
|
282
|
+
bytes: result.ok ? result.bytes : null,
|
|
283
|
+
reason: result.ok ? null : result.reason,
|
|
284
|
+
})
|
|
285
|
+
return result
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const tracedGet = __flowStep(
|
|
289
|
+
{ ref: 'pipework/fetch:get', capture: [null, DROP, DROP] },
|
|
290
|
+
(info: TraceInfo, rawUrl: string, options: UntrustedFetchOptions): Promise<UntrustedFetchResult> =>
|
|
291
|
+
runTraced(info, rawUrl, options, { method: 'GET', body: null }),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const tracedPost = __flowStep(
|
|
295
|
+
{ ref: 'pipework/fetch:post', capture: [null, DROP, DROP] },
|
|
296
|
+
(info: TraceInfo, rawUrl: string, options: UntrustedPostOptions): Promise<UntrustedFetchResult> =>
|
|
297
|
+
runTraced(info, rawUrl, options, { method: 'POST', body: Buffer.from(options.body) }),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
function traceInfo(url: string, options: UntrustedFetchOptions): TraceInfo {
|
|
301
|
+
let host: string | null = null
|
|
302
|
+
try {
|
|
303
|
+
host = new URL(url).host
|
|
304
|
+
} catch { /* the fetch refuses it as scheme */ }
|
|
305
|
+
return options.urlIsSafeToTrace === true ? { host, url } : { host }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Fetches one URL a stranger gave, with GET: http(s) only, public addresses only (every address the name resolves
|
|
310
|
+
* to is checked and the connection goes to a checked one), redirects up to `maxRedirects` with each hop checked
|
|
311
|
+
* as the first, a byte limit stopped the moment it is passed, one time limit for the whole fetch, and the
|
|
312
|
+
* content type and first bytes shown to `accept` before the body is kept. A refusal is returned with the rule
|
|
313
|
+
* that refused (`reason`) and a plain message that never carries an address or anything the server sent; it is
|
|
314
|
+
* not thrown. Recorded as the trace step `pipework/fetch:get` with a child step `pipework/fetch:outcome`
|
|
315
|
+
* holding host, status, bytes and reason; an ok result also carries the `ETag` and `Last-Modified` the server sent (null when absent); the full URL only when `urlIsSafeToTrace` is true.
|
|
316
|
+
*/
|
|
317
|
+
export function untrustedGet(url: string, options: UntrustedFetchOptions): Promise<UntrustedFetchResult> {
|
|
318
|
+
return tracedGet(traceInfo(url, options), url, options)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `untrustedGet` with POST: every rule of `get` applies. A 307 or 308 redirect resends the body; 301, 302 and 303
|
|
323
|
+
* become a GET without a body. The app's `headers` go to the first origin only. A non-2xx answer is returned as
|
|
324
|
+
* `reason: 'status'` with the status and no body; a 2xx body over `maxBytes` is `too_large` and carries the status.
|
|
325
|
+
* Recorded as the trace step `pipework/fetch:post`.
|
|
326
|
+
*/
|
|
327
|
+
export function untrustedPost(url: string, options: UntrustedPostOptions): Promise<UntrustedFetchResult> {
|
|
328
|
+
return tracedPost(traceInfo(url, options), url, options)
|
|
329
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { untrustedGet, untrustedPost } from './handle.js'
|
|
2
|
+
export { isPublicAddress } from './address.js'
|
|
3
|
+
export type {
|
|
4
|
+
UntrustedFetchOptions, UntrustedPostOptions, UntrustedFetchResult, FetchHead, FetchTarget, FetchRefusal,
|
|
5
|
+
} from './handle.js'
|
|
6
|
+
export { isUnsentFault, retryUnsent, fetchRetryingUnsent } from './connect-retry.js'
|
|
7
|
+
export type { ConnectRetryOptions } from './connect-retry.js'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { untrustedGet, untrustedPost } from './handle.js'
|
|
2
|
+
import { retryUnsent, fetchRetryingUnsent, isUnsentFault } from './connect-retry.js'
|
|
3
|
+
|
|
4
|
+
/** Untrusted fetch — fetch one URL a stranger gave without letting them reach the app's private network. */
|
|
5
|
+
export const untrustedFetch = {
|
|
6
|
+
/**
|
|
7
|
+
* Fetches `url` with GET and returns `{ ok: true, url, status, contentType, etag, lastModified, body, bytes }` (`etag` and `lastModified` are the response headers, null when absent), or `{ ok: false, reason, message }`
|
|
8
|
+
* naming the rule that refused: `scheme`, `address`, `redirects`, `too_large`, `timeout`, `status`, `rejected` or `network`.
|
|
9
|
+
* It does not throw for these.
|
|
10
|
+
*
|
|
11
|
+
* Rules, none of which can be switched off: only `http:` and `https:`; the name is resolved and every address is checked
|
|
12
|
+
* (loopback, private, link-local including cloud metadata, CGNAT, unique-local and site-local IPv6, multicast, reserved,
|
|
13
|
+
* unspecified, and IPv4-mapped or NAT64 forms of these; literal IP URLs likewise) and the connection goes to a checked
|
|
14
|
+
* address; redirects are followed up to `maxRedirects` and each hop is checked as the first, and the app's `headers` go only to the first origin;
|
|
15
|
+
* the body is stopped the moment it passes `maxBytes`, whatever Content-Length says; `timeoutMs` covers the whole fetch.
|
|
16
|
+
* `accept({ status, contentType, firstBytes })` sees the first 1024 bytes before the body is kept: return false, or a string
|
|
17
|
+
* (your own message), to refuse with `rejected`. Messages never contain an address or anything a server sent.
|
|
18
|
+
*
|
|
19
|
+
* `followRedirect(to, from)` is asked before each redirect hop: true follows it; false, or a string (your message), refuses with `rejected`.
|
|
20
|
+
* A 304 answer is a `status` refusal with `status: 304`; send `If-None-Match` / `If-Modified-Since` through `headers`.
|
|
21
|
+
*
|
|
22
|
+
* `resolve` and `connect` are for tests: `resolve(hostname)` replaces DNS and `connect(target)` replaces the socket after the
|
|
23
|
+
* address check passed, so a test sends a fake public address to a local server. The address check always runs on what `resolve` returns.
|
|
24
|
+
*
|
|
25
|
+
* The call is the trace step `pipework/fetch:get`; its child `pipework/fetch:outcome` carries host, status, bytes and reason.
|
|
26
|
+
* The full URL is recorded only when `urlIsSafeToTrace` is true; otherwise only the host.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* const got = await untrustedFetch.get(pasted, { maxBytes: 5_000_000, timeoutMs: 10_000, maxRedirects: 3,
|
|
30
|
+
* accept: h => h.firstBytes[0] === 0x25 || 'That does not look like a PDF.' })
|
|
31
|
+
* if (!got.ok) return tell(got.message)
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
get: untrustedGet,
|
|
35
|
+
/**
|
|
36
|
+
* Posts `body` to `url` with every rule of `get`: scheme, every resolved address checked and the connection made to a checked one,
|
|
37
|
+
* each redirect hop re-checked, `maxBytes`, one `timeoutMs` for the whole fetch, `headers` to the first origin only.
|
|
38
|
+
* A 307 or 308 resends the body; 301, 302 and 303 become a GET without a body; `maxRedirects: 0` refuses any redirect.
|
|
39
|
+
* A non-2xx answer is `{ ok: false, reason: 'status', status }` with no body, as in `get`; a 2xx body over `maxBytes` is
|
|
40
|
+
* `too_large` and carries `status`. `resolve` and `connect` are for tests, as in `get`.
|
|
41
|
+
* The call is the trace step `pipework/fetch:post`; the full URL is recorded only when `urlIsSafeToTrace` is true.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* const sent = await untrustedFetch.post(endpoint.url, { body: json, headers: { 'content-type': 'application/json' },
|
|
45
|
+
* maxBytes: 65_536, timeoutMs: 10_000, maxRedirects: 0 })
|
|
46
|
+
* if (!sent.ok) return record(sent.reason)
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
post: untrustedPost,
|
|
50
|
+
} as const
|
|
51
|
+
|
|
52
|
+
/** Connect retry — send again only when the connection could not be made, so nothing reached the server. */
|
|
53
|
+
export const connectRetry = {
|
|
54
|
+
/**
|
|
55
|
+
* `fetch(input, init, opts)` that retries a fault in the connect phase (undici `UND_ERR_CONNECT_TIMEOUT`, `EAI_AGAIN`,
|
|
56
|
+
* `ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`), waiting `delaysMs` between tries (default 5 s, 15 s, 40 s: four
|
|
57
|
+
* tries over about a minute), then throws the last fault with its cause chain. A fault after the request was written
|
|
58
|
+
* (`ECONNRESET`, `UND_ERR_SOCKET`, a timeout, an abort) and a response of any status are never retried, because a POST may have taken effect.
|
|
59
|
+
* `signal` ends the waiting; `onRetry({ error, attempt, delayMs })` is called before each wait; `fetch` replaces the global fetch.
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* const res = await connectRetry.fetch('https://api.github.com/repos/o/r/issues', { method: 'POST', body })
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
fetch: fetchRetryingUnsent,
|
|
66
|
+
/** Runs any `send()` with the same rule: retried only when `isUnsentFault(error)`; the last fault is thrown unchanged. */
|
|
67
|
+
call: retryUnsent,
|
|
68
|
+
/** True when the error, its `cause` chain or an `AggregateError`'s errors show a connect-phase fault and no sent-phase one. */
|
|
69
|
+
isUnsentFault,
|
|
70
|
+
} as const
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { IdempotencyConfig, IdempotencyStore } from './types.js'
|
|
2
|
+
import type { DB } from '../../data/db/index.js'
|
|
3
|
+
import type { Manifold } from '../../core/pipework.js'
|
|
4
|
+
import { createStore } from './store.js'
|
|
5
|
+
|
|
6
|
+
export interface IdempotencyMiddleware {
|
|
7
|
+
readonly store: IdempotencyStore
|
|
8
|
+
readonly config: Required<IdempotencyConfig>
|
|
9
|
+
|
|
10
|
+
check(
|
|
11
|
+
db: DB,
|
|
12
|
+
tenantId: string,
|
|
13
|
+
key: string,
|
|
14
|
+
method: string,
|
|
15
|
+
path: string,
|
|
16
|
+
): Promise<{ status: 'new' } | { status: 'locked' } | { status: 'completed'; statusCode: number; body: unknown }>
|
|
17
|
+
|
|
18
|
+
complete(db: DB, tenantId: string, key: string, statusCode: number, body: unknown): Promise<void>
|
|
19
|
+
fail(db: DB, tenantId: string, key: string): Promise<void>
|
|
20
|
+
cleanup(db: DB): Promise<number>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULTS = {
|
|
24
|
+
table: '__pipework_idempotency',
|
|
25
|
+
headerName: 'Idempotency-Key',
|
|
26
|
+
ttlSeconds: 86400,
|
|
27
|
+
lockTimeoutMs: 30_000,
|
|
28
|
+
methods: ['POST', 'PATCH', 'DELETE'] as const,
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
export function createIdempotency(instance: Manifold, config: IdempotencyConfig): IdempotencyMiddleware {
|
|
32
|
+
const resolved: Required<IdempotencyConfig> = {
|
|
33
|
+
database: config.database,
|
|
34
|
+
table: config.table ?? DEFAULTS.table,
|
|
35
|
+
headerName: config.headerName ?? DEFAULTS.headerName,
|
|
36
|
+
ttlSeconds: config.ttlSeconds ?? DEFAULTS.ttlSeconds,
|
|
37
|
+
lockTimeoutMs: config.lockTimeoutMs ?? DEFAULTS.lockTimeoutMs,
|
|
38
|
+
methods: config.methods ?? [...DEFAULTS.methods],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const store = createStore(resolved.table)
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
store,
|
|
45
|
+
config: resolved,
|
|
46
|
+
|
|
47
|
+
async check(db, tenantId, key, method, path) {
|
|
48
|
+
const existing = await store.find(db, tenantId, key)
|
|
49
|
+
|
|
50
|
+
if (existing === null) {
|
|
51
|
+
const locked = await store.lock(db, tenantId, key, method, path, resolved.lockTimeoutMs)
|
|
52
|
+
if (!locked) {
|
|
53
|
+
return { status: 'locked' }
|
|
54
|
+
}
|
|
55
|
+
return { status: 'new' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (existing.statusCode !== null) {
|
|
59
|
+
return { status: 'completed', statusCode: existing.statusCode, body: existing.responseBody }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (existing.lockedUntil !== null && existing.lockedUntil.getTime() > Date.now()) {
|
|
63
|
+
return { status: 'locked' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Lock expired — re-lock and retry
|
|
67
|
+
await store.remove(db, tenantId, key)
|
|
68
|
+
const locked = await store.lock(db, tenantId, key, method, path, resolved.lockTimeoutMs)
|
|
69
|
+
if (!locked) {
|
|
70
|
+
return { status: 'locked' }
|
|
71
|
+
}
|
|
72
|
+
return { status: 'new' }
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async complete(db, tenantId, key, statusCode, body) {
|
|
76
|
+
await store.complete(db, tenantId, key, statusCode, body)
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async fail(db, tenantId, key) {
|
|
80
|
+
await store.remove(db, tenantId, key)
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async cleanup(db) {
|
|
84
|
+
return store.cleanup(db, resolved.ttlSeconds)
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { sql } from '../../data/db/sql.js'
|
|
2
|
+
import type { DB } from '../../data/db/index.js'
|
|
3
|
+
import type { IdempotencyStore, IdempotencyRecord } from './types.js'
|
|
4
|
+
import { unscopedExecutor, scopedExecutor, requireUnscopedExecutor } from '../../auth/tenant/scoped-db.js'
|
|
5
|
+
|
|
6
|
+
export function createStore(table: string): IdempotencyStore {
|
|
7
|
+
return {
|
|
8
|
+
async ensureTable(handle: DB): Promise<void> {
|
|
9
|
+
const db = unscopedExecutor(handle).db
|
|
10
|
+
await db.execute(sql.raw(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
12
|
+
key TEXT NOT NULL,
|
|
13
|
+
tenant_id TEXT NOT NULL,
|
|
14
|
+
method TEXT NOT NULL,
|
|
15
|
+
path TEXT NOT NULL,
|
|
16
|
+
status_code INTEGER,
|
|
17
|
+
response_body JSONB,
|
|
18
|
+
locked_until TIMESTAMPTZ,
|
|
19
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
20
|
+
PRIMARY KEY (tenant_id, key)
|
|
21
|
+
)
|
|
22
|
+
`))
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async find(handle: DB, tenantId: string, key: string): Promise<IdempotencyRecord | null> {
|
|
26
|
+
const db = scopedExecutor(handle, tenantId, 'idempotency.find()').db
|
|
27
|
+
const rows: unknown[] = await db.execute(
|
|
28
|
+
sql`SELECT key, tenant_id, method, path, status_code, response_body, locked_until, created_at
|
|
29
|
+
FROM ${sql.raw(table)}
|
|
30
|
+
WHERE tenant_id = ${tenantId} AND key = ${key}`,
|
|
31
|
+
)
|
|
32
|
+
const row = rows[0] as Record<string, unknown> | undefined
|
|
33
|
+
if (row === undefined) return null
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
key: row['key'] as string,
|
|
37
|
+
tenantId: row['tenant_id'] as string,
|
|
38
|
+
method: row['method'] as string,
|
|
39
|
+
path: row['path'] as string,
|
|
40
|
+
statusCode: row['status_code'] as number | null,
|
|
41
|
+
responseBody: row['response_body'],
|
|
42
|
+
lockedUntil: row['locked_until'] !== null ? new Date(row['locked_until'] as string) : null,
|
|
43
|
+
createdAt: new Date(row['created_at'] as string),
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async lock(handle: DB, tenantId: string, key: string, method: string, path: string, lockMs: number): Promise<boolean> {
|
|
48
|
+
const db = scopedExecutor(handle, tenantId, 'idempotency.lock()').db
|
|
49
|
+
const lockUntil = new Date(Date.now() + lockMs)
|
|
50
|
+
const rows: unknown[] = await db.execute(
|
|
51
|
+
sql`INSERT INTO ${sql.raw(table)} (key, tenant_id, method, path, locked_until)
|
|
52
|
+
VALUES (${key}, ${tenantId}, ${method}, ${path}, ${lockUntil.toISOString()})
|
|
53
|
+
ON CONFLICT (tenant_id, key) DO NOTHING
|
|
54
|
+
RETURNING key`,
|
|
55
|
+
)
|
|
56
|
+
return (rows as unknown[]).length > 0
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async complete(handle: DB, tenantId: string, key: string, statusCode: number, responseBody: unknown): Promise<void> {
|
|
60
|
+
const db = scopedExecutor(handle, tenantId, 'idempotency.complete()').db
|
|
61
|
+
// JSON.stringify(undefined) returns undefined, which binds as an empty
|
|
62
|
+
// SQL placeholder and triggers a syntax error. A 204-style response
|
|
63
|
+
// legitimately has no body; persist it as JSON null so replay surfaces
|
|
64
|
+
// the same null and the cached statusCode.
|
|
65
|
+
const body = responseBody === undefined ? null : responseBody
|
|
66
|
+
await db.execute(
|
|
67
|
+
sql`UPDATE ${sql.raw(table)}
|
|
68
|
+
SET status_code = ${statusCode},
|
|
69
|
+
response_body = ${JSON.stringify(body)},
|
|
70
|
+
locked_until = NULL
|
|
71
|
+
WHERE tenant_id = ${tenantId} AND key = ${key}`,
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async remove(handle: DB, tenantId: string, key: string): Promise<void> {
|
|
76
|
+
const db = scopedExecutor(handle, tenantId, 'idempotency.remove()').db
|
|
77
|
+
await db.execute(
|
|
78
|
+
sql`DELETE FROM ${sql.raw(table)} WHERE tenant_id = ${tenantId} AND key = ${key}`,
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async cleanup(handle: DB, ttlSeconds: number): Promise<number> {
|
|
83
|
+
const db = requireUnscopedExecutor(handle, 'idempotency.cleanup()', 'it deletes every tenant\'s expired keys')
|
|
84
|
+
const rows: unknown[] = await db.execute(
|
|
85
|
+
sql`DELETE FROM ${sql.raw(table)}
|
|
86
|
+
WHERE created_at < now() - ${ttlSeconds.toString() + ' seconds'}::interval
|
|
87
|
+
RETURNING key`,
|
|
88
|
+
)
|
|
89
|
+
return (rows as unknown[]).length
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DB } from '../../data/db/index.js'
|
|
2
|
+
|
|
3
|
+
export interface IdempotencyConfig {
|
|
4
|
+
database: string
|
|
5
|
+
table?: string
|
|
6
|
+
headerName?: string
|
|
7
|
+
ttlSeconds?: number
|
|
8
|
+
lockTimeoutMs?: number
|
|
9
|
+
methods?: ReadonlyArray<'POST' | 'PUT' | 'PATCH' | 'DELETE'>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IdempotencyRecord {
|
|
13
|
+
key: string
|
|
14
|
+
tenantId: string
|
|
15
|
+
method: string
|
|
16
|
+
path: string
|
|
17
|
+
statusCode: number | null
|
|
18
|
+
responseBody: unknown
|
|
19
|
+
lockedUntil: Date | null
|
|
20
|
+
createdAt: Date
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IdempotencyStore {
|
|
24
|
+
ensureTable(db: DB): Promise<void>
|
|
25
|
+
find(db: DB, tenantId: string, key: string): Promise<IdempotencyRecord | null>
|
|
26
|
+
lock(db: DB, tenantId: string, key: string, method: string, path: string, lockMs: number): Promise<boolean>
|
|
27
|
+
complete(db: DB, tenantId: string, key: string, statusCode: number, responseBody: unknown): Promise<void>
|
|
28
|
+
remove(db: DB, tenantId: string, key: string): Promise<void>
|
|
29
|
+
cleanup(db: DB, ttlSeconds: number): Promise<number>
|
|
30
|
+
}
|