pipework 0.252.0 → 0.252.1
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 +5 -0
- 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 +854 -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,693 @@
|
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
import { sql } from '../../data/db/sql.js'
|
|
3
|
+
import { pipeSystem } from '../../data/db/db.js'
|
|
4
|
+
import type { DB } from '../../data/db/index.js'
|
|
5
|
+
import { fitting } from '../../request/di/index.js'
|
|
6
|
+
import type { Handler } from '../../request/di/types.js'
|
|
7
|
+
import { getContext } from '../../request/context/store.js'
|
|
8
|
+
import { resolveActorId } from '../../data/write/audit/actor.js'
|
|
9
|
+
import { schema } from '../../data/schema/namespace.js'
|
|
10
|
+
import { __flowStep } from '../../trace/runtime/flow-step.js'
|
|
11
|
+
import { getBaseLogger } from '../../core/logging/index.js'
|
|
12
|
+
import { ConflictError, NotFoundError } from '../../core/errors/index.js'
|
|
13
|
+
import type { Manifold } from '../../core/pipework.js'
|
|
14
|
+
import type { Queue } from '../../async/jobs/queue.js'
|
|
15
|
+
import { untrustedGet } from '../fetch/handle.js'
|
|
16
|
+
import type { FetchTarget } from '../fetch/handle.js'
|
|
17
|
+
import type { Storage } from '../storage/handle.js'
|
|
18
|
+
import type { Socket } from 'node:net'
|
|
19
|
+
import { auditCrawl } from './audit.js'
|
|
20
|
+
import { CrawlNotEnabledError, CrawlSeedError } from './errors.js'
|
|
21
|
+
import { extractLinks } from './links.js'
|
|
22
|
+
import { ALLOW_ALL, DISALLOW_ALL, parseRobots, type RobotsRules } from './robots.js'
|
|
23
|
+
|
|
24
|
+
export type CrawlScope = 'host' | { readonly domain: string }
|
|
25
|
+
export type CrawlState = 'awaiting_approval' | 'running' | 'done' | 'stopped' | 'failed'
|
|
26
|
+
export type CrawlStoppedBy = 'pages' | 'depth' | 'bytes' | 'wall' | 'robots_unreachable'
|
|
27
|
+
|
|
28
|
+
/** Limits of one crawl; each is optional and the default is in the field's description. */
|
|
29
|
+
export interface CrawlLimits {
|
|
30
|
+
/** Requests in flight at once to one host. Default 1. */
|
|
31
|
+
readonly perHostConcurrency?: number
|
|
32
|
+
/** Least milliseconds between requests to a host; the gap used is the larger of this and the host's `Crawl-delay`. Default 1000. */
|
|
33
|
+
readonly minGapMs?: number
|
|
34
|
+
/** Most pages and files requested (robots.txt not counted). Default 1000. */
|
|
35
|
+
readonly maxPages?: number
|
|
36
|
+
/** Most links deep from the seed. Default 10. */
|
|
37
|
+
readonly maxDepth?: number
|
|
38
|
+
/** Most body bytes fetched in all. Default 1 GiB. */
|
|
39
|
+
readonly maxBytes?: number
|
|
40
|
+
/** Most bytes of one response; a larger one is refused as `too_large`. Default 25 MiB. */
|
|
41
|
+
readonly maxBytesPerResponse?: number
|
|
42
|
+
/** Most milliseconds the crawl runs, counted from its start. Default one hour. */
|
|
43
|
+
readonly maxWallMs?: number
|
|
44
|
+
/** Time limit of one request. Default 30 s. */
|
|
45
|
+
readonly requestTimeoutMs?: number
|
|
46
|
+
/** Most redirects followed in a chain. Default 5. */
|
|
47
|
+
readonly maxRedirects?: number
|
|
48
|
+
/** Time limit of one `onPage` call. Default 5000. */
|
|
49
|
+
readonly pageHookMs?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ResolvedCrawlLimits = Required<CrawlLimits>
|
|
53
|
+
|
|
54
|
+
export interface CrawlPage {
|
|
55
|
+
readonly url: string
|
|
56
|
+
/** The page's html, already limited to `maxBytesPerResponse`. */
|
|
57
|
+
readonly html: string
|
|
58
|
+
/** Aborted when the hook has overrun `pageHookMs`. */
|
|
59
|
+
readonly signal: AbortSignal
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CrawlConfig {
|
|
63
|
+
/** The app's manifold; its Blueprint must have a `crawl` block. */
|
|
64
|
+
readonly instance: Manifold
|
|
65
|
+
/** Names the site and its job type (`pipework.crawl.<name>`): letters, digits, `_`, `.` and `-`, starting with a letter. */
|
|
66
|
+
readonly name: string
|
|
67
|
+
/** The queue the crawl job runs on; its worker surface must run `site.job`. */
|
|
68
|
+
readonly queue: Pick<Queue, 'enqueue'>
|
|
69
|
+
/** Which resources are listed. In-scope `text/html` is followed for links whatever this says; any other type this refuses is not read. */
|
|
70
|
+
readonly accept: (resource: { url: string; contentType: string }) => boolean
|
|
71
|
+
/** Called with each followed page's html. A hook that throws or overruns `pageHookMs` is recorded against the URL and the crawl goes on. */
|
|
72
|
+
readonly onPage?: (page: CrawlPage) => Promise<void> | void
|
|
73
|
+
/** When given, each listed resource's bytes are put here (the job's tenant) and the row records the sha256 and file id. */
|
|
74
|
+
readonly store?: Storage
|
|
75
|
+
/** Sent on every request; its product token (the leading letters) picks the robots.txt group. */
|
|
76
|
+
readonly userAgent: string
|
|
77
|
+
/** For tests: replaces DNS and the socket, as in `untrustedFetch.get`. */
|
|
78
|
+
readonly network?: {
|
|
79
|
+
readonly resolve?: (hostname: string) => Promise<string[]>
|
|
80
|
+
readonly connect?: (target: FetchTarget) => Socket
|
|
81
|
+
}
|
|
82
|
+
/** Record the full URL in each request's trace step. Default true: crawled links are public. */
|
|
83
|
+
readonly traceUrls?: boolean
|
|
84
|
+
/** Database holding the crawl tables; default the default database. */
|
|
85
|
+
readonly database?: string
|
|
86
|
+
/** Attempts the queue gives the job: a job that dies or throws is retried and carries on from the queued URLs. Default 5. */
|
|
87
|
+
readonly maxAttempts?: number
|
|
88
|
+
/** First wait before a robots.txt retry, doubling each time. Default 2000. */
|
|
89
|
+
readonly robotsRetryMs?: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CrawlStartOptions {
|
|
93
|
+
readonly seed: string
|
|
94
|
+
/** `'host'` (default): the seed's exact host. `{ domain: 'example.gov.uk' }`: that host and every host under it. */
|
|
95
|
+
readonly scope?: CrawlScope
|
|
96
|
+
readonly limits?: CrawlLimits
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CrawlCounts {
|
|
100
|
+
readonly queued: number
|
|
101
|
+
readonly fetching: number
|
|
102
|
+
readonly done: number
|
|
103
|
+
readonly refused: number
|
|
104
|
+
readonly listed: number
|
|
105
|
+
readonly unchanged: number
|
|
106
|
+
/** Body bytes fetched. */
|
|
107
|
+
readonly bytes: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface CrawlStatus {
|
|
111
|
+
readonly state: CrawlState
|
|
112
|
+
/** What the crawl stays inside. A `'host'` scope moves to the new host when the seed's own redirect chain leaves the seed's host (example.gov.uk to www.example.gov.uk). */
|
|
113
|
+
readonly scope: { readonly host: string } | { readonly domain: string }
|
|
114
|
+
/** Present when `state` is `stopped`. */
|
|
115
|
+
readonly stoppedBy?: CrawlStoppedBy
|
|
116
|
+
/** Present when `state` is `failed`. */
|
|
117
|
+
readonly error?: string
|
|
118
|
+
readonly counts: CrawlCounts
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface CrawlResource {
|
|
122
|
+
readonly url: string
|
|
123
|
+
/** The page that linked to it; null for the seed. */
|
|
124
|
+
readonly from: string | null
|
|
125
|
+
readonly status: number
|
|
126
|
+
readonly contentType: string | null
|
|
127
|
+
readonly bytes: number
|
|
128
|
+
readonly etag: string | null
|
|
129
|
+
readonly lastModified: string | null
|
|
130
|
+
readonly sha256: string | null
|
|
131
|
+
readonly fileId: string | null
|
|
132
|
+
/** True when the server answered a conditional request with 304; the other fields are those of the earlier crawl. */
|
|
133
|
+
readonly unchanged: boolean
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface CrawlRefusal {
|
|
137
|
+
readonly url: string
|
|
138
|
+
readonly from: string | null
|
|
139
|
+
/** `scope`, `robots`, `depth`, `page_hook`, or the `untrustedFetch.get` reason (`address`, `too_large`, `rejected`, `status`, ...). */
|
|
140
|
+
readonly reason: string
|
|
141
|
+
readonly message: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface CrawlSite {
|
|
145
|
+
/** Add to a worker surface's `jobs`. */
|
|
146
|
+
readonly job: Handler<Record<string, unknown>, unknown>
|
|
147
|
+
/** Job type of `job`. */
|
|
148
|
+
readonly jobType: string
|
|
149
|
+
/**
|
|
150
|
+
* Records a crawl and, unless the Blueprint's `crawl.requireApproval` is true, queues its job. Needs a Flow. Throws
|
|
151
|
+
* `CrawlSeedError` for a seed that is not http(s) or not inside its own scope. One audit record: who, seed, scope, limits.
|
|
152
|
+
*/
|
|
153
|
+
start(options: CrawlStartOptions): Promise<{ id: string }>
|
|
154
|
+
/** Queues the job of a crawl that is `awaiting_approval`; one audit record. Throws `ConflictError` for a crawl in another state. */
|
|
155
|
+
approve(id: string): Promise<void>
|
|
156
|
+
status(id: string): Promise<CrawlStatus>
|
|
157
|
+
/** The listed resources, in the order they were found. */
|
|
158
|
+
resources(id: string): Promise<CrawlResource[]>
|
|
159
|
+
/** Every URL the crawl declined to fetch or could not, and every `onPage` failure, with the reason. */
|
|
160
|
+
refusals(id: string): Promise<CrawlRefusal[]>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const DEFAULT_LIMITS: ResolvedCrawlLimits = {
|
|
164
|
+
perHostConcurrency: 1, minGapMs: 1000, maxPages: 1000, maxDepth: 10, maxBytes: 1024 ** 3, maxBytesPerResponse: 25 * 1024 * 1024,
|
|
165
|
+
maxWallMs: 3_600_000, requestTimeoutMs: 30_000, maxRedirects: 5, pageHookMs: 5000,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const NAME_RE = /^[A-Za-z][A-Za-z0-9_.-]*$/
|
|
169
|
+
const ROBOTS_MAX_BYTES = 500 * 1024
|
|
170
|
+
const ROBOTS_TRIES = 4
|
|
171
|
+
/** Rules that allow nothing because the host's robots.txt could not be fetched for a reason that also bars the page itself. */
|
|
172
|
+
interface BarredRules extends RobotsRules { readonly refusal: { reason: string; message: string } }
|
|
173
|
+
const barred = (reason: string, message: string): BarredRules => ({ ...DISALLOW_ALL, refusal: { reason, message } })
|
|
174
|
+
const HTML_TYPES = new Set(['text/html', 'application/xhtml+xml'])
|
|
175
|
+
|
|
176
|
+
type StoredScope = { kind: 'host'; host: string } | { kind: 'domain'; domain: string }
|
|
177
|
+
|
|
178
|
+
interface UrlRow { id: string; url: string; depth: number; fromUrl: string | null; redirects: number; seedChain: boolean }
|
|
179
|
+
|
|
180
|
+
interface RunState {
|
|
181
|
+
stop: CrawlStoppedBy | null
|
|
182
|
+
inFlight: number
|
|
183
|
+
/** Per host: when the last request ended (started, with several in flight). */
|
|
184
|
+
last: Map<string, number>
|
|
185
|
+
hostInit: Map<string, Promise<void>>
|
|
186
|
+
robots: Map<string, Promise<RobotsRules | null>>
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const num = (v: unknown): number => Number(v ?? 0)
|
|
190
|
+
const str = (v: unknown): string | null => (v === null || v === undefined ? null : String(v))
|
|
191
|
+
const baseType = (contentType: string | null): string => (contentType ?? '').split(';')[0]!.trim().toLowerCase()
|
|
192
|
+
const describeError = (err: unknown): string => (err instanceof Error ? err.message : String(err)).slice(0, 500)
|
|
193
|
+
|
|
194
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
195
|
+
return new Promise<void>(resolve => {
|
|
196
|
+
if (ms <= 0 || signal.aborted) { resolve(); return }
|
|
197
|
+
const done = (): void => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve() }
|
|
198
|
+
const timer = setTimeout(done, ms)
|
|
199
|
+
signal.addEventListener('abort', done, { once: true })
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function inScope(scope: StoredScope, hostname: string): boolean {
|
|
204
|
+
if (scope.kind === 'host') return hostname === scope.host
|
|
205
|
+
return hostname === scope.domain || hostname.endsWith('.' + scope.domain)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveLimits(given: CrawlLimits | undefined): ResolvedCrawlLimits {
|
|
209
|
+
const out: Record<string, number> = { ...DEFAULT_LIMITS }
|
|
210
|
+
for (const [key, value] of Object.entries(given ?? {})) {
|
|
211
|
+
if (!(key in DEFAULT_LIMITS)) throw new CrawlSeedError(`unknown limit "${key}" (limits: ${Object.keys(DEFAULT_LIMITS).join(', ')}).`)
|
|
212
|
+
if (value === undefined) continue
|
|
213
|
+
const min = key === 'minGapMs' || key === 'maxRedirects' || key === 'maxDepth' ? 0 : 1
|
|
214
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min) throw new CrawlSeedError(`limit ${key} must be a number of at least ${String(min)}, got ${String(value)}.`)
|
|
215
|
+
out[key] = value
|
|
216
|
+
}
|
|
217
|
+
return out as unknown as ResolvedCrawlLimits
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseSeed(seed: string, scope: CrawlScope | undefined): { url: URL; stored: StoredScope } {
|
|
221
|
+
let url: URL
|
|
222
|
+
try { url = new URL(seed) } catch { throw new CrawlSeedError(`the seed "${seed}" is not a URL.`) }
|
|
223
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new CrawlSeedError(`the seed must be an http or https URL, got ${url.protocol}`)
|
|
224
|
+
url.hash = ''
|
|
225
|
+
let stored: StoredScope
|
|
226
|
+
if (scope === undefined || scope === 'host') {
|
|
227
|
+
stored = { kind: 'host', host: url.hostname }
|
|
228
|
+
} else if (typeof scope === 'object' && typeof scope.domain === 'string' && scope.domain.trim() !== '') {
|
|
229
|
+
stored = { kind: 'domain', domain: scope.domain.trim().toLowerCase().replace(/^\.+|\.+$/g, '') }
|
|
230
|
+
} else {
|
|
231
|
+
throw new CrawlSeedError(`scope must be 'host' or { domain: 'example.gov.uk' }.`)
|
|
232
|
+
}
|
|
233
|
+
if (!inScope(stored, url.hostname)) {
|
|
234
|
+
throw new CrawlSeedError(`the seed's host ${url.hostname} is not inside its scope (${stored.kind === 'host' ? stored.host : stored.domain}).`)
|
|
235
|
+
}
|
|
236
|
+
return { url, stored }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Creates a site crawler; see `crawl.create`. */
|
|
240
|
+
export function createCrawl(config: CrawlConfig): CrawlSite {
|
|
241
|
+
const blueprint = config.instance.config.raw.crawl
|
|
242
|
+
if (blueprint === undefined) throw new CrawlNotEnabledError()
|
|
243
|
+
if (typeof config.name !== 'string' || !NAME_RE.test(config.name)) {
|
|
244
|
+
throw new Error(`[pipework] crawl.create: name "${String(config.name)}" must start with a letter and use letters, digits, "_", "." or "-".\n`)
|
|
245
|
+
}
|
|
246
|
+
if (typeof config.userAgent !== 'string' || config.userAgent.trim() === '') throw new Error('[pipework] crawl.create: userAgent is required: sites read it to decide what you may fetch.\n')
|
|
247
|
+
const requireApproval = blueprint.requireApproval === true
|
|
248
|
+
const jobType = `pipework.crawl.${config.name}`
|
|
249
|
+
const database = config.database
|
|
250
|
+
const db = (): DB => pipeSystem(database)
|
|
251
|
+
const robotsRetryMs = config.robotsRetryMs ?? 2000
|
|
252
|
+
const urlIsSafeToTrace = config.traceUrls !== false
|
|
253
|
+
|
|
254
|
+
const enqueue = async (id: string): Promise<void> => {
|
|
255
|
+
await config.queue.enqueue(db(), { type: jobType, payload: { crawlId: id }, liveKey: id, continueFlow: true, maxAttempts: config.maxAttempts ?? 5 })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const startStep = __flowStep({ ref: 'pipework/crawl:start', capture: [null] }, async (options: CrawlStartOptions): Promise<{ id: string }> => {
|
|
259
|
+
const { url, stored } = parseSeed(options.seed, options.scope)
|
|
260
|
+
const limits = resolveLimits(options.limits)
|
|
261
|
+
const flow = getContext()
|
|
262
|
+
const actor = flow === undefined ? null : resolveActorId(flow)
|
|
263
|
+
const state: CrawlState = requireApproval ? 'awaiting_approval' : 'running'
|
|
264
|
+
const rows = await db().execute(sql`
|
|
265
|
+
INSERT INTO pipework_crawl (site, seed, scope, limits, state, tenant_id, started_by, run_started_at)
|
|
266
|
+
VALUES (${config.name}, ${url.href}, ${JSON.stringify(stored)}::jsonb, ${JSON.stringify(limits)}::jsonb, ${state},
|
|
267
|
+
${flow?.tenant ?? null}, ${actor}, ${requireApproval ? null : sql`now()`})
|
|
268
|
+
RETURNING id::text AS id`)
|
|
269
|
+
const id = rows[0]!['id'] as string
|
|
270
|
+
await db().execute(sql`INSERT INTO pipework_crawl_url (crawl_id, url, depth, seed_chain) VALUES (${id}::uuid, ${url.href}, 0, true)`)
|
|
271
|
+
await auditCrawl('start', id, { site: config.name, seed: url.href, scope: stored, limits, awaitingApproval: requireApproval }, database)
|
|
272
|
+
if (!requireApproval) await enqueue(id)
|
|
273
|
+
return { id }
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const approveStep = __flowStep({ ref: 'pipework/crawl:approve', capture: [null] }, async (id: string): Promise<void> => {
|
|
277
|
+
await requireCrawl(id)
|
|
278
|
+
const flow = getContext()
|
|
279
|
+
const actor = flow === undefined ? null : resolveActorId(flow)
|
|
280
|
+
const rows = await db().execute(sql`
|
|
281
|
+
UPDATE pipework_crawl SET state = 'running', approved_by = ${actor}, run_started_at = now()
|
|
282
|
+
WHERE id = ${id}::uuid AND site = ${config.name} AND state = 'awaiting_approval' RETURNING seed`)
|
|
283
|
+
if (rows.length === 0) {
|
|
284
|
+
const found = await db().execute(sql`SELECT state FROM pipework_crawl WHERE id = ${id}::uuid AND site = ${config.name}`)
|
|
285
|
+
if (found.length === 0) throw new NotFoundError(`Crawl ${id}`)
|
|
286
|
+
throw new ConflictError(`Crawl ${id} is not awaiting approval (it is ${String(found[0]!['state'])}).`)
|
|
287
|
+
}
|
|
288
|
+
await auditCrawl('approve', id, { site: config.name, seed: rows[0]!['seed'] }, database)
|
|
289
|
+
await enqueue(id)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
async function loadCrawl(id: string): Promise<{ state: CrawlState; limits: ResolvedCrawlLimits; scope: StoredScope; seed: string; runStartedAt: number; tenantId: string | null; stoppedBy: CrawlStoppedBy | null; error: string | null; bytes: number } | null> {
|
|
293
|
+
const rows = await db().execute(sql`
|
|
294
|
+
SELECT state, limits, scope, seed, tenant_id, stopped_by, error, bytes::float8 AS bytes,
|
|
295
|
+
(extract(epoch from run_started_at) * 1000)::float8 AS run_started_ms
|
|
296
|
+
FROM pipework_crawl WHERE id = ${id}::uuid AND site = ${config.name}`)
|
|
297
|
+
const r = rows[0]
|
|
298
|
+
if (r === undefined) return null
|
|
299
|
+
return {
|
|
300
|
+
state: r['state'] as CrawlState, limits: r['limits'] as ResolvedCrawlLimits, scope: r['scope'] as StoredScope, seed: r['seed'] as string,
|
|
301
|
+
runStartedAt: num(r['run_started_ms']), tenantId: str(r['tenant_id']),
|
|
302
|
+
stoppedBy: str(r['stopped_by']) as CrawlStoppedBy | null, error: str(r['error']), bytes: num(r['bytes']),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The crawl, or NotFoundError: also when it belongs to another tenant than the Flow's. */
|
|
307
|
+
const requireCrawl = async (id: string): Promise<NonNullable<Awaited<ReturnType<typeof loadCrawl>>>> => {
|
|
308
|
+
const crawl = await loadCrawl(id)
|
|
309
|
+
const tenant = getContext()?.tenant ?? null
|
|
310
|
+
if (crawl === null || (crawl.tenantId !== null && crawl.tenantId !== tenant)) throw new NotFoundError(`Crawl ${id}`)
|
|
311
|
+
return crawl
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const status = async (id: string): Promise<CrawlStatus> => {
|
|
315
|
+
const crawl = await requireCrawl(id)
|
|
316
|
+
const counted = await db().execute(sql`
|
|
317
|
+
SELECT count(*) FILTER (WHERE state = 'queued')::int AS queued, count(*) FILTER (WHERE state = 'fetching')::int AS fetching,
|
|
318
|
+
count(*) FILTER (WHERE state = 'done')::int AS done, count(*) FILTER (WHERE state = 'refused')::int AS refused
|
|
319
|
+
FROM pipework_crawl_url WHERE crawl_id = ${id}::uuid`)
|
|
320
|
+
const listed = await db().execute(sql`
|
|
321
|
+
SELECT count(*)::int AS listed, count(*) FILTER (WHERE unchanged)::int AS unchanged FROM pipework_crawl_resource WHERE crawl_id = ${id}::uuid`)
|
|
322
|
+
const c = counted[0]!
|
|
323
|
+
return {
|
|
324
|
+
state: crawl.state,
|
|
325
|
+
scope: crawl.scope.kind === 'host' ? { host: crawl.scope.host } : { domain: crawl.scope.domain },
|
|
326
|
+
...(crawl.stoppedBy !== null ? { stoppedBy: crawl.stoppedBy } : {}),
|
|
327
|
+
...(crawl.error !== null ? { error: crawl.error } : {}),
|
|
328
|
+
counts: {
|
|
329
|
+
queued: num(c['queued']), fetching: num(c['fetching']), done: num(c['done']), refused: num(c['refused']),
|
|
330
|
+
listed: num(listed[0]!['listed']), unchanged: num(listed[0]!['unchanged']), bytes: crawl.bytes,
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const resources = async (id: string): Promise<CrawlResource[]> => {
|
|
336
|
+
await requireCrawl(id)
|
|
337
|
+
const rows = await db().execute(sql`
|
|
338
|
+
SELECT r.url, r.from_url, r.status, r.content_type, r.bytes::float8 AS bytes, r.etag, r.last_modified, r.sha256, r.file_id, r.unchanged
|
|
339
|
+
FROM pipework_crawl_resource r JOIN pipework_crawl c ON c.id = r.crawl_id
|
|
340
|
+
WHERE r.crawl_id = ${id}::uuid AND c.site = ${config.name} ORDER BY r.created_at, r.id`)
|
|
341
|
+
return rows.map(r => ({
|
|
342
|
+
url: r['url'] as string, from: str(r['from_url']), status: num(r['status']), contentType: str(r['content_type']), bytes: num(r['bytes']),
|
|
343
|
+
etag: str(r['etag']), lastModified: str(r['last_modified']), sha256: str(r['sha256']), fileId: str(r['file_id']), unchanged: r['unchanged'] === true,
|
|
344
|
+
}))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const refusals = async (id: string): Promise<CrawlRefusal[]> => {
|
|
348
|
+
await requireCrawl(id)
|
|
349
|
+
const rows = await db().execute(sql`
|
|
350
|
+
SELECT u.url, u.from_url, u.reason, u.message
|
|
351
|
+
FROM pipework_crawl_url u JOIN pipework_crawl c ON c.id = u.crawl_id
|
|
352
|
+
WHERE u.crawl_id = ${id}::uuid AND c.site = ${config.name} AND (u.state = 'refused' OR u.reason = 'page_hook') ORDER BY u.seq`)
|
|
353
|
+
return rows.map(r => ({ url: r['url'] as string, from: str(r['from_url']), reason: str(r['reason']) ?? '', message: str(r['message']) ?? '' }))
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- the job ----
|
|
357
|
+
|
|
358
|
+
async function run(crawlId: string, signal: AbortSignal): Promise<void> {
|
|
359
|
+
const crawl = await loadCrawl(crawlId)
|
|
360
|
+
if (crawl === null || crawl.state !== 'running') return
|
|
361
|
+
const { limits } = crawl
|
|
362
|
+
let scope = crawl.scope
|
|
363
|
+
const state: RunState = { stop: null, inFlight: 0, last: new Map(), hostInit: new Map(), robots: new Map() }
|
|
364
|
+
// Only one job runs a crawl, so a row still 'fetching' is the one that was in flight when the last attempt died.
|
|
365
|
+
await db().execute(sql`UPDATE pipework_crawl_url SET state = 'queued' WHERE crawl_id = ${crawlId}::uuid AND state = 'fetching'`)
|
|
366
|
+
|
|
367
|
+
const hostRow = async (host: string): Promise<{ robotsState: string | null; robotsText: string | null; attempts: number; lastMs: number }> => {
|
|
368
|
+
await db().execute(sql`INSERT INTO pipework_crawl_host (crawl_id, host) VALUES (${crawlId}::uuid, ${host}) ON CONFLICT (crawl_id, host) DO NOTHING`)
|
|
369
|
+
const r = (await db().execute(sql`
|
|
370
|
+
SELECT robots_state, robots_text, robots_attempts, last_request_at_ms::float8 AS last_ms FROM pipework_crawl_host WHERE crawl_id = ${crawlId}::uuid AND host = ${host}`))[0]!
|
|
371
|
+
return { robotsState: str(r['robots_state']), robotsText: str(r['robots_text']), attempts: num(r['robots_attempts']), lastMs: num(r['last_ms']) }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const gapFor = (rules: RobotsRules | null): number => Math.max(limits.minGapMs, (rules?.crawlDelaySeconds ?? 0) * 1000)
|
|
375
|
+
|
|
376
|
+
/** Waits for this host's turn, then returns a function that records the request's end. A request starts at least `gap` after the last one ended (started, with several in flight). */
|
|
377
|
+
const takeTurn = async (host: string, gap: number): Promise<() => Promise<void>> => {
|
|
378
|
+
let init = state.hostInit.get(host)
|
|
379
|
+
if (init === undefined) {
|
|
380
|
+
init = hostRow(host).then(row => { state.last.set(host, row.lastMs) })
|
|
381
|
+
state.hostInit.set(host, init)
|
|
382
|
+
}
|
|
383
|
+
await init
|
|
384
|
+
const start = Math.max(Date.now(), state.last.get(host)! + gap)
|
|
385
|
+
if (limits.perHostConcurrency > 1) state.last.set(host, start)
|
|
386
|
+
await sleep(start - Date.now(), signal)
|
|
387
|
+
return async () => {
|
|
388
|
+
const end = Date.now()
|
|
389
|
+
state.last.set(host, Math.max(state.last.get(host)!, end))
|
|
390
|
+
await db().execute(sql`UPDATE pipework_crawl_host SET last_request_at_ms = ${end} WHERE crawl_id = ${crawlId}::uuid AND host = ${host}`)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const ua = { 'user-agent': config.userAgent }
|
|
395
|
+
const network = {
|
|
396
|
+
...(config.network?.resolve !== undefined ? { resolve: config.network.resolve } : {}),
|
|
397
|
+
...(config.network?.connect !== undefined ? { connect: config.network.connect } : {}),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The host's robots rules, fetched once before any other request to it; null when it stayed unreachable (the crawl stops). */
|
|
401
|
+
const robotsFor = (host: string, protocol: string): Promise<RobotsRules | null> => {
|
|
402
|
+
let pending = state.robots.get(host)
|
|
403
|
+
if (pending === undefined) {
|
|
404
|
+
pending = loadRobots(host, protocol)
|
|
405
|
+
state.robots.set(host, pending)
|
|
406
|
+
}
|
|
407
|
+
return pending
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const loadRobots = async (host: string, protocol: string): Promise<RobotsRules | null> => {
|
|
411
|
+
const row = await hostRow(host)
|
|
412
|
+
if (row.robotsState === 'ok') return parseRobots(row.robotsText ?? '', config.userAgent)
|
|
413
|
+
if (row.robotsState === 'allow_all') return ALLOW_ALL
|
|
414
|
+
if (row.robotsState === 'disallow_all') {
|
|
415
|
+
const kept = JSON.parse(row.robotsText ?? 'null') as { reason: string; message: string } | null
|
|
416
|
+
return kept === null ? DISALLOW_ALL : barred(kept.reason, kept.message)
|
|
417
|
+
}
|
|
418
|
+
const settle = async (robotsState: string, text: string | null): Promise<void> => {
|
|
419
|
+
await db().execute(sql`UPDATE pipework_crawl_host SET robots_state = ${robotsState}, robots_text = ${text} WHERE crawl_id = ${crawlId}::uuid AND host = ${host}`)
|
|
420
|
+
}
|
|
421
|
+
for (let attempt = row.attempts; attempt < ROBOTS_TRIES; attempt++) {
|
|
422
|
+
if (signal.aborted) return null
|
|
423
|
+
if (attempt > 0) await sleep(robotsRetryMs * 2 ** (attempt - 1), signal)
|
|
424
|
+
const done = await takeTurn(host, limits.minGapMs)
|
|
425
|
+
const got = await untrustedGet(`${protocol}//${host}/robots.txt`, {
|
|
426
|
+
maxBytes: ROBOTS_MAX_BYTES, timeoutMs: limits.requestTimeoutMs, maxRedirects: limits.maxRedirects, headers: ua, urlIsSafeToTrace,
|
|
427
|
+
followRedirect: (to) => new URL(to).host === host ? true : 'robots.txt redirects to another host',
|
|
428
|
+
...network,
|
|
429
|
+
})
|
|
430
|
+
await done()
|
|
431
|
+
if (got.ok) {
|
|
432
|
+
const text = new TextDecoder().decode(got.body)
|
|
433
|
+
await settle('ok', text)
|
|
434
|
+
return parseRobots(text, config.userAgent)
|
|
435
|
+
}
|
|
436
|
+
if (got.reason === 'status' && got.status !== undefined && got.status >= 400 && got.status < 500) {
|
|
437
|
+
await settle('allow_all', null)
|
|
438
|
+
return ALLOW_ALL
|
|
439
|
+
}
|
|
440
|
+
if (got.reason === 'status' || got.reason === 'network' || got.reason === 'timeout') {
|
|
441
|
+
await db().execute(sql`UPDATE pipework_crawl_host SET robots_attempts = ${attempt + 1} WHERE crawl_id = ${crawlId}::uuid AND host = ${host}`)
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
const refusal = { reason: got.reason, message: got.message }
|
|
445
|
+
await settle('disallow_all', JSON.stringify(refusal))
|
|
446
|
+
return barred(refusal.reason, refusal.message)
|
|
447
|
+
}
|
|
448
|
+
return signal.aborted ? null : (state.stop = 'robots_unreachable', null)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const insertLinks = async (tx: DB, rows: Array<{ url: string; depth: number; from: string; state: string; reason: string | null; message: string | null; redirects: number; seedChain?: boolean }>): Promise<void> => {
|
|
452
|
+
if (rows.length === 0) return
|
|
453
|
+
await tx.execute(sql`
|
|
454
|
+
INSERT INTO pipework_crawl_url (crawl_id, url, depth, from_url, redirects, state, reason, message, seed_chain)
|
|
455
|
+
SELECT ${crawlId}::uuid, x.url, x.depth, x.from_url, x.redirects, x.state, x.reason, x.message, x.seed_chain
|
|
456
|
+
FROM jsonb_to_recordset(${JSON.stringify(rows.map(r => ({ url: r.url, depth: r.depth, from_url: r.from, redirects: r.redirects, state: r.state, reason: r.reason, message: r.message, seed_chain: r.seedChain === true })))}::jsonb)
|
|
457
|
+
AS x(url text, depth int, from_url text, redirects int, state text, reason text, message text, seed_chain boolean)
|
|
458
|
+
ON CONFLICT (crawl_id, url) DO NOTHING`)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const finishUrl = async (row: UrlRow, outcome: { state: 'done' | 'refused'; reason?: string; message?: string; status?: number | null; requested?: boolean }, extra?: (tx: DB) => Promise<void>): Promise<void> => {
|
|
462
|
+
await db().transaction(async (tx) => {
|
|
463
|
+
await extra?.(tx)
|
|
464
|
+
await tx.execute(sql`
|
|
465
|
+
UPDATE pipework_crawl_url SET state = ${outcome.state}, reason = ${outcome.reason ?? null}, message = ${outcome.message ?? null}, status = ${outcome.status ?? null}, requested = requested OR ${outcome.requested === true}
|
|
466
|
+
WHERE id = ${row.id}::uuid`)
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const runHook = async (url: string, html: string): Promise<string | null> => {
|
|
471
|
+
const hook = config.onPage
|
|
472
|
+
if (hook === undefined) return null
|
|
473
|
+
const controller = new AbortController()
|
|
474
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
475
|
+
const overrun = new Promise<never>((_, reject) => {
|
|
476
|
+
timer = setTimeout(() => {
|
|
477
|
+
const err = new Error(`onPage took longer than ${String(limits.pageHookMs)} ms.`)
|
|
478
|
+
controller.abort(err)
|
|
479
|
+
reject(err)
|
|
480
|
+
}, limits.pageHookMs)
|
|
481
|
+
})
|
|
482
|
+
try {
|
|
483
|
+
const work = Promise.resolve().then(() => hook({ url, html, signal: controller.signal }))
|
|
484
|
+
work.catch(() => undefined)
|
|
485
|
+
await Promise.race([work, overrun])
|
|
486
|
+
return null
|
|
487
|
+
} catch (err) {
|
|
488
|
+
return describeError(err)
|
|
489
|
+
} finally {
|
|
490
|
+
clearTimeout(timer)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const previous = async (url: string): Promise<{ etag: string | null; lastModified: string | null; status: number; contentType: string | null; bytes: number; sha256: string | null; fileId: string | null } | null> => {
|
|
495
|
+
const rows = await db().execute(sql`
|
|
496
|
+
SELECT r.etag, r.last_modified, r.status, r.content_type, r.bytes::float8 AS bytes, r.sha256, r.file_id
|
|
497
|
+
FROM pipework_crawl_resource r JOIN pipework_crawl c ON c.id = r.crawl_id
|
|
498
|
+
WHERE c.site = ${config.name} AND c.seed = ${crawl.seed} AND c.id <> ${crawlId}::uuid AND c.created_at < (SELECT created_at FROM pipework_crawl WHERE id = ${crawlId}::uuid)
|
|
499
|
+
AND r.url = ${url} AND (r.etag IS NOT NULL OR r.last_modified IS NOT NULL)
|
|
500
|
+
ORDER BY c.created_at DESC LIMIT 1`)
|
|
501
|
+
const r = rows[0]
|
|
502
|
+
if (r === undefined) return null
|
|
503
|
+
return { etag: str(r['etag']), lastModified: str(r['last_modified']), status: num(r['status']), contentType: str(r['content_type']), bytes: num(r['bytes']), sha256: str(r['sha256']), fileId: str(r['file_id']) }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const movesScope = (row: UrlRow, to: URL): boolean => scope.kind === 'host' && row.seedChain && row.depth === 0 && !inScope(scope, to.hostname)
|
|
507
|
+
|
|
508
|
+
const processUrl = async (row: UrlRow): Promise<void> => {
|
|
509
|
+
const target = new URL(row.url)
|
|
510
|
+
const rules = await robotsFor(target.host, target.protocol)
|
|
511
|
+
if (rules === null) {
|
|
512
|
+
if (!signal.aborted) await db().execute(sql`UPDATE pipework_crawl_url SET state = 'queued' WHERE id = ${row.id}::uuid`)
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
if (!rules.allows(target.pathname + target.search)) {
|
|
516
|
+
const why = (rules as Partial<BarredRules>).refusal
|
|
517
|
+
await finishUrl(row, { state: 'refused', reason: why?.reason ?? 'robots', message: why?.message ?? 'robots.txt does not allow this path.' })
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
const done = await takeTurn(target.host, gapFor(rules))
|
|
521
|
+
if (signal.aborted) {
|
|
522
|
+
await db().execute(sql`UPDATE pipework_crawl_url SET state = 'queued' WHERE id = ${row.id}::uuid`)
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
const prior = await previous(row.url)
|
|
526
|
+
const headers: Record<string, string> = { ...ua }
|
|
527
|
+
if (prior?.etag != null) headers['if-none-match'] = prior.etag
|
|
528
|
+
if (prior?.lastModified != null) headers['if-modified-since'] = prior.lastModified
|
|
529
|
+
const hop: { to: string | null } = { to: null }
|
|
530
|
+
const got = await untrustedGet(row.url, {
|
|
531
|
+
maxBytes: limits.maxBytesPerResponse, timeoutMs: limits.requestTimeoutMs, maxRedirects: limits.maxRedirects, headers, urlIsSafeToTrace,
|
|
532
|
+
// A redirect is not followed inside the request: its target is a URL of the crawl like any link, so it meets scope, robots and the host's gap.
|
|
533
|
+
followRedirect: (to) => { hop.to = to; return 'The crawler handles redirects itself.' },
|
|
534
|
+
accept: (head) => {
|
|
535
|
+
const type = baseType(head.contentType)
|
|
536
|
+
if (HTML_TYPES.has(type)) return true
|
|
537
|
+
return config.accept({ url: row.url, contentType: type }) || 'This is neither a page nor a file the app lists.'
|
|
538
|
+
},
|
|
539
|
+
...network,
|
|
540
|
+
})
|
|
541
|
+
await done()
|
|
542
|
+
|
|
543
|
+
if (!got.ok) {
|
|
544
|
+
if (got.reason === 'rejected' && hop.to !== null) {
|
|
545
|
+
const hops = row.redirects + 1
|
|
546
|
+
let to: URL | null = null
|
|
547
|
+
try { to = new URL(hop.to) } catch { /* refused below */ }
|
|
548
|
+
if (hops > limits.maxRedirects) await finishUrl(row, { requested: true, state: 'refused', reason: 'redirects', message: 'The link redirects too many times.' })
|
|
549
|
+
else if (to === null || (!inScope(scope, to.hostname) && !movesScope(row, to))) await finishUrl(row, { requested: true, state: 'refused', reason: 'scope', message: 'The link redirects to a page outside the crawl\'s scope.' })
|
|
550
|
+
else {
|
|
551
|
+
to.hash = ''
|
|
552
|
+
// The seed's own redirect chain may move a host scope (example.gov.uk to www.example.gov.uk).
|
|
553
|
+
const moved: StoredScope | null = inScope(scope, to.hostname) ? null : { kind: 'host', host: to.hostname }
|
|
554
|
+
await finishUrl(row, { requested: true, state: 'done', reason: 'redirected', message: 'Redirected; the target is queued.' }, async (tx) => {
|
|
555
|
+
if (moved !== null) await tx.execute(sql`UPDATE pipework_crawl SET scope = ${JSON.stringify(moved)}::jsonb WHERE id = ${crawlId}::uuid`)
|
|
556
|
+
await insertLinks(tx, [{ url: to.href, depth: row.depth, from: row.fromUrl ?? row.url, state: 'queued', reason: null, message: null, redirects: hops, seedChain: row.seedChain }])
|
|
557
|
+
})
|
|
558
|
+
if (moved !== null) scope = moved
|
|
559
|
+
}
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
if (got.reason === 'status' && got.status === 304 && prior !== null) {
|
|
563
|
+
await finishUrl(row, { requested: true, state: 'done', status: 304 }, tx => tx.execute(sql`
|
|
564
|
+
INSERT INTO pipework_crawl_resource (crawl_id, url, from_url, status, content_type, bytes, etag, last_modified, sha256, file_id, unchanged)
|
|
565
|
+
VALUES (${crawlId}::uuid, ${row.url}, ${row.fromUrl}, ${prior.status}, ${prior.contentType}, ${prior.bytes}, ${prior.etag}, ${prior.lastModified}, ${prior.sha256}, ${prior.fileId}, true)
|
|
566
|
+
ON CONFLICT (crawl_id, url) DO NOTHING`).then(() => undefined))
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
await finishUrl(row, { requested: true, state: 'refused', reason: got.reason, message: got.message, status: got.status ?? null })
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const type = baseType(got.contentType)
|
|
574
|
+
const isHtml = HTML_TYPES.has(type)
|
|
575
|
+
const listed = config.accept({ url: got.url, contentType: type })
|
|
576
|
+
let hookFault: string | null = null
|
|
577
|
+
let links: string[] = []
|
|
578
|
+
if (isHtml) {
|
|
579
|
+
const html = new TextDecoder().decode(got.body)
|
|
580
|
+
links = extractLinks(html, got.url)
|
|
581
|
+
hookFault = await runHook(row.url, html)
|
|
582
|
+
}
|
|
583
|
+
let sha256: string | null = null
|
|
584
|
+
let fileId: string | null = null
|
|
585
|
+
if (listed && config.store !== undefined) {
|
|
586
|
+
const file = await config.store.put({ body: Readable.from(Buffer.from(got.body)), mediaType: type === '' ? 'application/octet-stream' : type, metadata: { url: got.url, crawl: crawlId } })
|
|
587
|
+
sha256 = file.sha256
|
|
588
|
+
fileId = file.id
|
|
589
|
+
}
|
|
590
|
+
const children = links.map(link => {
|
|
591
|
+
const u = new URL(link)
|
|
592
|
+
const depth = row.depth + 1
|
|
593
|
+
if (!inScope(scope, u.hostname)) return { url: link, depth, from: row.url, state: 'refused', reason: 'scope', message: 'The link points outside the crawl\'s scope.', redirects: 0 }
|
|
594
|
+
if (depth > limits.maxDepth) return { url: link, depth, from: row.url, state: 'refused', reason: 'depth', message: `The link is more than ${String(limits.maxDepth)} links from the seed.`, redirects: 0 }
|
|
595
|
+
return { url: link, depth, from: row.url, state: 'queued', reason: null, message: null, redirects: 0 }
|
|
596
|
+
})
|
|
597
|
+
await finishUrl(
|
|
598
|
+
row,
|
|
599
|
+
hookFault === null ? { requested: true, state: 'done', status: got.status } : { requested: true, state: 'done', status: got.status, reason: 'page_hook', message: hookFault },
|
|
600
|
+
async (tx) => {
|
|
601
|
+
await tx.execute(sql`UPDATE pipework_crawl SET bytes = bytes + ${got.bytes} WHERE id = ${crawlId}::uuid`)
|
|
602
|
+
if (listed) {
|
|
603
|
+
await tx.execute(sql`
|
|
604
|
+
INSERT INTO pipework_crawl_resource (crawl_id, url, from_url, status, content_type, bytes, etag, last_modified, sha256, file_id)
|
|
605
|
+
VALUES (${crawlId}::uuid, ${row.url}, ${row.fromUrl}, ${got.status}, ${type === '' ? null : type}, ${got.bytes}, ${got.etag}, ${got.lastModified}, ${sha256}, ${fileId})
|
|
606
|
+
ON CONFLICT (crawl_id, url) DO NOTHING`)
|
|
607
|
+
}
|
|
608
|
+
await insertLinks(tx, children)
|
|
609
|
+
},
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** The next queued URL, claimed so no other lane takes it; breadth first. */
|
|
614
|
+
const claim = async (): Promise<UrlRow | null> => {
|
|
615
|
+
const rows = await db().execute(sql`
|
|
616
|
+
UPDATE pipework_crawl_url SET state = 'fetching'
|
|
617
|
+
WHERE id = (SELECT id FROM pipework_crawl_url WHERE crawl_id = ${crawlId}::uuid AND state = 'queued'
|
|
618
|
+
ORDER BY depth, seq LIMIT 1 FOR UPDATE SKIP LOCKED)
|
|
619
|
+
RETURNING id::text AS id, url, depth, from_url, redirects, seed_chain`)
|
|
620
|
+
const r = rows[0]
|
|
621
|
+
return r === undefined ? null : { id: r['id'] as string, url: r['url'] as string, depth: num(r['depth']), fromUrl: str(r['from_url']), redirects: num(r['redirects']), seedChain: r['seed_chain'] === true }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const limitHit = async (): Promise<CrawlStoppedBy | null> => {
|
|
625
|
+
if (Date.now() - crawl.runStartedAt > limits.maxWallMs) return 'wall'
|
|
626
|
+
const r = (await db().execute(sql`
|
|
627
|
+
SELECT (SELECT count(*) FROM pipework_crawl_url WHERE crawl_id = ${crawlId}::uuid AND requested)::int AS pages,
|
|
628
|
+
(SELECT bytes::float8 FROM pipework_crawl WHERE id = ${crawlId}::uuid) AS bytes`))[0]!
|
|
629
|
+
if (num(r['pages']) >= limits.maxPages) return 'pages'
|
|
630
|
+
if (num(r['bytes']) >= limits.maxBytes) return 'bytes'
|
|
631
|
+
return null
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const lane = async (): Promise<void> => {
|
|
635
|
+
while (!signal.aborted && state.stop === null) {
|
|
636
|
+
const hit = await limitHit()
|
|
637
|
+
if (hit !== null) {
|
|
638
|
+
const queued = hit === 'wall' || hit === 'bytes' || num((await db().execute(sql`SELECT count(*)::int AS n FROM pipework_crawl_url WHERE crawl_id = ${crawlId}::uuid AND state = 'queued'`))[0]!['n']) > 0
|
|
639
|
+
if (queued) state.stop = hit
|
|
640
|
+
else if (state.inFlight === 0) return
|
|
641
|
+
}
|
|
642
|
+
if (state.stop !== null) return
|
|
643
|
+
const row = await claim()
|
|
644
|
+
if (row === null) {
|
|
645
|
+
if (state.inFlight === 0) return
|
|
646
|
+
await sleep(25, signal)
|
|
647
|
+
continue
|
|
648
|
+
}
|
|
649
|
+
state.inFlight++
|
|
650
|
+
try { await processUrl(row) } finally { state.inFlight-- }
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
await Promise.all(Array.from({ length: limits.perHostConcurrency }, () => lane()))
|
|
655
|
+
if (signal.aborted) return
|
|
656
|
+
if (state.stop !== null) {
|
|
657
|
+
await db().execute(sql`UPDATE pipework_crawl SET state = 'stopped', stopped_by = ${state.stop}, finished_at = now() WHERE id = ${crawlId}::uuid`)
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
const depthCut = await db().execute(sql`SELECT 1 FROM pipework_crawl_url WHERE crawl_id = ${crawlId}::uuid AND reason = 'depth' LIMIT 1`)
|
|
661
|
+
if (depthCut.length > 0) {
|
|
662
|
+
await db().execute(sql`UPDATE pipework_crawl SET state = 'stopped', stopped_by = 'depth', finished_at = now() WHERE id = ${crawlId}::uuid`)
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
await db().execute(sql`UPDATE pipework_crawl SET state = 'done', finished_at = now() WHERE id = ${crawlId}::uuid`)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const builder = database !== undefined ? fitting.use(database) : fitting.use()
|
|
669
|
+
const job = builder.job(jobType)
|
|
670
|
+
.transaction('manual')
|
|
671
|
+
.input(schema.check.object({ crawlId: schema.check.string() }))
|
|
672
|
+
.fit((async ({ input, signal }: { input: { crawlId: string }; signal: AbortSignal }) => {
|
|
673
|
+
try {
|
|
674
|
+
await run(input.crawlId, signal)
|
|
675
|
+
} catch (err) {
|
|
676
|
+
const flow = getContext()
|
|
677
|
+
const last = flow?.jobAttempt != null && flow.jobMaxAttempts != null && flow.jobAttempt >= flow.jobMaxAttempts
|
|
678
|
+
if (last) {
|
|
679
|
+
getBaseLogger().error({ err, crawlId: input.crawlId }, 'Crawl job failed on its last attempt')
|
|
680
|
+
await db().execute(sql`UPDATE pipework_crawl SET state = 'failed', error = ${describeError(err)}, finished_at = now() WHERE id = ${input.crawlId}::uuid`)
|
|
681
|
+
}
|
|
682
|
+
throw err
|
|
683
|
+
}
|
|
684
|
+
}) as never) as unknown as Handler<Record<string, unknown>, unknown>
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
job,
|
|
688
|
+
jobType,
|
|
689
|
+
start: (options) => startStep(options),
|
|
690
|
+
approve: (id) => approveStep(id),
|
|
691
|
+
status, resources, refusals,
|
|
692
|
+
}
|
|
693
|
+
}
|