@shipfox/api-definitions 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +215 -0
- package/LICENSE +21 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +35 -0
- package/dist/config.js.map +1 -0
- package/dist/core/entities/index.d.ts +5 -0
- package/dist/core/entities/index.d.ts.map +1 -0
- package/dist/core/entities/index.js +6 -0
- package/dist/core/entities/index.js.map +1 -0
- package/dist/core/entities/integration-context.d.ts +11 -0
- package/dist/core/entities/integration-context.d.ts.map +1 -0
- package/dist/core/entities/integration-context.js +3 -0
- package/dist/core/entities/integration-context.js.map +1 -0
- package/dist/core/entities/sync-state.d.ts +19 -0
- package/dist/core/entities/sync-state.d.ts.map +1 -0
- package/dist/core/entities/sync-state.js +20 -0
- package/dist/core/entities/sync-state.js.map +1 -0
- package/dist/core/entities/workflow-definition.d.ts +35 -0
- package/dist/core/entities/workflow-definition.d.ts.map +1 -0
- package/dist/core/entities/workflow-definition.js +3 -0
- package/dist/core/entities/workflow-definition.js.map +1 -0
- package/dist/core/entities/workflow-model.d.ts +138 -0
- package/dist/core/entities/workflow-model.d.ts.map +1 -0
- package/dist/core/entities/workflow-model.js +3 -0
- package/dist/core/entities/workflow-model.js.map +1 -0
- package/dist/core/errors.d.ts +10 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +14 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/has-agent-step-integrations.d.ts +3 -0
- package/dist/core/has-agent-step-integrations.d.ts.map +1 -0
- package/dist/core/has-agent-step-integrations.js +5 -0
- package/dist/core/has-agent-step-integrations.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +9 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/parse-definition.d.ts +9 -0
- package/dist/core/parse-definition.d.ts.map +1 -0
- package/dist/core/parse-definition.js +17 -0
- package/dist/core/parse-definition.js.map +1 -0
- package/dist/core/sync-definitions.d.ts +44 -0
- package/dist/core/sync-definitions.d.ts.map +1 -0
- package/dist/core/sync-definitions.js +140 -0
- package/dist/core/sync-definitions.js.map +1 -0
- package/dist/core/validate-definition.d.ts +20 -0
- package/dist/core/validate-definition.d.ts.map +1 -0
- package/dist/core/validate-definition.js +61 -0
- package/dist/core/validate-definition.js.map +1 -0
- package/dist/core/workflow-model/constants.d.ts +2 -0
- package/dist/core/workflow-model/constants.d.ts.map +1 -0
- package/dist/core/workflow-model/constants.js +3 -0
- package/dist/core/workflow-model/constants.js.map +1 -0
- package/dist/core/workflow-model/cron-trigger.d.ts +16 -0
- package/dist/core/workflow-model/cron-trigger.d.ts.map +1 -0
- package/dist/core/workflow-model/cron-trigger.js +85 -0
- package/dist/core/workflow-model/cron-trigger.js.map +1 -0
- package/dist/core/workflow-model/index.d.ts +4 -0
- package/dist/core/workflow-model/index.d.ts.map +1 -0
- package/dist/core/workflow-model/index.js +5 -0
- package/dist/core/workflow-model/index.js.map +1 -0
- package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +15 -0
- package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -0
- package/dist/core/workflow-model/invalid-workflow-model-error.js +9 -0
- package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -0
- package/dist/core/workflow-model/map-job-ids.d.ts +4 -0
- package/dist/core/workflow-model/map-job-ids.d.ts.map +1 -0
- package/dist/core/workflow-model/map-job-ids.js +33 -0
- package/dist/core/workflow-model/map-job-ids.js.map +1 -0
- package/dist/core/workflow-model/normalize-agent-integrations.d.ts +12 -0
- package/dist/core/workflow-model/normalize-agent-integrations.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-agent-integrations.js +157 -0
- package/dist/core/workflow-model/normalize-agent-integrations.js.map +1 -0
- package/dist/core/workflow-model/normalize-dependencies.d.ts +6 -0
- package/dist/core/workflow-model/normalize-dependencies.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-dependencies.js +119 -0
- package/dist/core/workflow-model/normalize-dependencies.js.map +1 -0
- package/dist/core/workflow-model/normalize-env.d.ts +17 -0
- package/dist/core/workflow-model/normalize-env.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-env.js +40 -0
- package/dist/core/workflow-model/normalize-env.js.map +1 -0
- package/dist/core/workflow-model/normalize-if-condition.d.ts +14 -0
- package/dist/core/workflow-model/normalize-if-condition.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-if-condition.js +75 -0
- package/dist/core/workflow-model/normalize-if-condition.js.map +1 -0
- package/dist/core/workflow-model/normalize-job-checkout.d.ts +5 -0
- package/dist/core/workflow-model/normalize-job-checkout.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-job-checkout.js +16 -0
- package/dist/core/workflow-model/normalize-job-checkout.js.map +1 -0
- package/dist/core/workflow-model/normalize-job-listening.d.ts +10 -0
- package/dist/core/workflow-model/normalize-job-listening.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-job-listening.js +125 -0
- package/dist/core/workflow-model/normalize-job-listening.js.map +1 -0
- package/dist/core/workflow-model/normalize-job-success.d.ts +11 -0
- package/dist/core/workflow-model/normalize-job-success.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-job-success.js +23 -0
- package/dist/core/workflow-model/normalize-job-success.js.map +1 -0
- package/dist/core/workflow-model/normalize-jobs.d.ts +12 -0
- package/dist/core/workflow-model/normalize-jobs.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-jobs.js +935 -0
- package/dist/core/workflow-model/normalize-jobs.js.map +1 -0
- package/dist/core/workflow-model/normalize-needs.d.ts +2 -0
- package/dist/core/workflow-model/normalize-needs.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-needs.js +16 -0
- package/dist/core/workflow-model/normalize-needs.js.map +1 -0
- package/dist/core/workflow-model/normalize-step-gate.d.ts +15 -0
- package/dist/core/workflow-model/normalize-step-gate.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-step-gate.js +89 -0
- package/dist/core/workflow-model/normalize-step-gate.js.map +1 -0
- package/dist/core/workflow-model/normalize-step-outputs.d.ts +10 -0
- package/dist/core/workflow-model/normalize-step-outputs.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-step-outputs.js +32 -0
- package/dist/core/workflow-model/normalize-step-outputs.js.map +1 -0
- package/dist/core/workflow-model/normalize-triggers.d.ts +11 -0
- package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-triggers.js +128 -0
- package/dist/core/workflow-model/normalize-triggers.js.map +1 -0
- package/dist/core/workflow-model/normalize-workflow-document.d.ts +11 -0
- package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -0
- package/dist/core/workflow-model/normalize-workflow-document.js +43 -0
- package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -0
- package/dist/core/workflow-model/parse-duration-ms.d.ts +11 -0
- package/dist/core/workflow-model/parse-duration-ms.d.ts.map +1 -0
- package/dist/core/workflow-model/parse-duration-ms.js +47 -0
- package/dist/core/workflow-model/parse-duration-ms.js.map +1 -0
- package/dist/core/workflow-model/parse-interpolation-field.d.ts +14 -0
- package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -0
- package/dist/core/workflow-model/parse-interpolation-field.js +377 -0
- package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -0
- package/dist/core/workflow-model/stable-id.d.ts +2 -0
- package/dist/core/workflow-model/stable-id.d.ts.map +1 -0
- package/dist/core/workflow-model/stable-id.js +8 -0
- package/dist/core/workflow-model/stable-id.js.map +1 -0
- package/dist/core/workflow-model/validate-job-references.d.ts +10 -0
- package/dist/core/workflow-model/validate-job-references.d.ts.map +1 -0
- package/dist/core/workflow-model/validate-job-references.js +42 -0
- package/dist/core/workflow-model/validate-job-references.js.map +1 -0
- package/dist/core/workflow-model/validate-predicate-expression.d.ts +14 -0
- package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -0
- package/dist/core/workflow-model/validate-predicate-expression.js +273 -0
- package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -0
- package/dist/core/workflow-model/validation-issue.d.ts +8 -0
- package/dist/core/workflow-model/validation-issue.d.ts.map +1 -0
- package/dist/core/workflow-model/validation-issue.js +17 -0
- package/dist/core/workflow-model/validation-issue.js.map +1 -0
- package/dist/core/workflow-model/workflow-field-label.d.ts +3 -0
- package/dist/core/workflow-model/workflow-field-label.d.ts.map +1 -0
- package/dist/core/workflow-model/workflow-field-label.js +47 -0
- package/dist/core/workflow-model/workflow-field-label.js.map +1 -0
- package/dist/core/workflow-yaml/index.d.ts +3 -0
- package/dist/core/workflow-yaml/index.d.ts.map +1 -0
- package/dist/core/workflow-yaml/index.js +4 -0
- package/dist/core/workflow-yaml/index.js.map +1 -0
- package/dist/core/workflow-yaml/invalid-workflow-yaml-error.d.ts +16 -0
- package/dist/core/workflow-yaml/invalid-workflow-yaml-error.d.ts.map +1 -0
- package/dist/core/workflow-yaml/invalid-workflow-yaml-error.js +12 -0
- package/dist/core/workflow-yaml/invalid-workflow-yaml-error.js.map +1 -0
- package/dist/core/workflow-yaml/parse-workflow-yaml.d.ts +9 -0
- package/dist/core/workflow-yaml/parse-workflow-yaml.d.ts.map +1 -0
- package/dist/core/workflow-yaml/parse-workflow-yaml.js +52 -0
- package/dist/core/workflow-yaml/parse-workflow-yaml.js.map +1 -0
- package/dist/core/workflow-yaml/source-locations.d.ts +3 -0
- package/dist/core/workflow-yaml/source-locations.d.ts.map +1 -0
- package/dist/core/workflow-yaml/source-locations.js +48 -0
- package/dist/core/workflow-yaml/source-locations.js.map +1 -0
- package/dist/db/db.d.ts +1277 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +22 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/definition-triggers.d.ts +4 -0
- package/dist/db/definition-triggers.d.ts.map +1 -0
- package/dist/db/definition-triggers.js +17 -0
- package/dist/db/definition-triggers.js.map +1 -0
- package/dist/db/definitions.d.ts +63 -0
- package/dist/db/definitions.d.ts.map +1 -0
- package/dist/db/definitions.js +208 -0
- package/dist/db/definitions.js.map +1 -0
- package/dist/db/index.d.ts +8 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +10 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/definitions.d.ts +237 -0
- package/dist/db/schema/definitions.d.ts.map +1 -0
- package/dist/db/schema/definitions.js +63 -0
- package/dist/db/schema/definitions.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/sync-states.d.ts +218 -0
- package/dist/db/schema/sync-states.d.ts.map +1 -0
- package/dist/db/schema/sync-states.js +68 -0
- package/dist/db/schema/sync-states.js.map +1 -0
- package/dist/db/sync-states.d.ts +21 -0
- package/dist/db/sync-states.d.ts.map +1 -0
- package/dist/db/sync-states.js +47 -0
- package/dist/db/sync-states.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/definition.d.ts +6 -0
- package/dist/presentation/dto/definition.d.ts.map +1 -0
- package/dist/presentation/dto/definition.js +35 -0
- package/dist/presentation/dto/definition.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -0
- package/dist/presentation/dto/index.d.ts.map +1 -0
- package/dist/presentation/dto/index.js +3 -0
- package/dist/presentation/dto/index.js.map +1 -0
- package/dist/presentation/index.d.ts +2 -0
- package/dist/presentation/index.d.ts.map +1 -0
- package/dist/presentation/index.js +3 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/presentation/routes/create-definition.d.ts +9 -0
- package/dist/presentation/routes/create-definition.d.ts.map +1 -0
- package/dist/presentation/routes/create-definition.js +76 -0
- package/dist/presentation/routes/create-definition.js.map +1 -0
- package/dist/presentation/routes/get-definition.d.ts +2 -0
- package/dist/presentation/routes/get-definition.d.ts.map +1 -0
- package/dist/presentation/routes/get-definition.js +43 -0
- package/dist/presentation/routes/get-definition.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +7 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +22 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/list-definitions.d.ts +2 -0
- package/dist/presentation/routes/list-definitions.d.ts.map +1 -0
- package/dist/presentation/routes/list-definitions.js +56 -0
- package/dist/presentation/routes/list-definitions.js.map +1 -0
- package/dist/presentation/routes/validate-definition.d.ts +2 -0
- package/dist/presentation/routes/validate-definition.d.ts.map +1 -0
- package/dist/presentation/routes/validate-definition.js +43 -0
- package/dist/presentation/routes/validate-definition.js.map +1 -0
- package/dist/presentation/subscribers/index.d.ts +3 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -0
- package/dist/presentation/subscribers/index.js +4 -0
- package/dist/presentation/subscribers/index.js.map +1 -0
- package/dist/presentation/subscribers/on-project-source-bound.d.ts +3 -0
- package/dist/presentation/subscribers/on-project-source-bound.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-project-source-bound.js +11 -0
- package/dist/presentation/subscribers/on-project-source-bound.js.map +1 -0
- package/dist/presentation/subscribers/on-project-source-commit-observed.d.ts +3 -0
- package/dist/presentation/subscribers/on-project-source-commit-observed.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-project-source-commit-observed.js +13 -0
- package/dist/presentation/subscribers/on-project-source-commit-observed.js.map +1 -0
- package/dist/presentation/subscribers/start-definition-sync.d.ts +10 -0
- package/dist/presentation/subscribers/start-definition-sync.d.ts.map +1 -0
- package/dist/presentation/subscribers/start-definition-sync.js +26 -0
- package/dist/presentation/subscribers/start-definition-sync.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +3 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +3 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/sync-activities.d.ts +47 -0
- package/dist/temporal/activities/sync-activities.d.ts.map +1 -0
- package/dist/temporal/activities/sync-activities.js +128 -0
- package/dist/temporal/activities/sync-activities.js.map +1 -0
- package/dist/temporal/constants.d.ts +3 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +4 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/index.d.ts +3 -0
- package/dist/temporal/index.d.ts.map +1 -0
- package/dist/temporal/index.js +4 -0
- package/dist/temporal/index.js.map +1 -0
- package/dist/temporal/workflows/definition-sync.d.ts +20 -0
- package/dist/temporal/workflows/definition-sync.d.ts.map +1 -0
- package/dist/temporal/workflows/definition-sync.js +95 -0
- package/dist/temporal/workflows/definition-sync.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +64 -0
- package/drizzle/0001_definition_list_pagination.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +527 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +11 -0
- package/package.json +89 -0
- package/src/config.test.ts +23 -0
- package/src/config.ts +50 -0
- package/src/core/entities/index.ts +4 -0
- package/src/core/entities/integration-context.ts +21 -0
- package/src/core/entities/sync-state.ts +40 -0
- package/src/core/entities/workflow-definition.ts +38 -0
- package/src/core/entities/workflow-model.ts +162 -0
- package/src/core/errors.ts +21 -0
- package/src/core/has-agent-step-integrations.ts +7 -0
- package/src/core/index.ts +23 -0
- package/src/core/parse-definition.test.ts +200 -0
- package/src/core/parse-definition.ts +28 -0
- package/src/core/sync-definitions.test.ts +390 -0
- package/src/core/sync-definitions.ts +216 -0
- package/src/core/validate-definition.test.ts +152 -0
- package/src/core/validate-definition.ts +72 -0
- package/src/core/workflow-model/README.md +129 -0
- package/src/core/workflow-model/constants.ts +1 -0
- package/src/core/workflow-model/cron-trigger.test.ts +29 -0
- package/src/core/workflow-model/cron-trigger.ts +83 -0
- package/src/core/workflow-model/index.ts +9 -0
- package/src/core/workflow-model/invalid-workflow-model-error.ts +71 -0
- package/src/core/workflow-model/map-job-ids.ts +33 -0
- package/src/core/workflow-model/normalize-agent-integrations.ts +208 -0
- package/src/core/workflow-model/normalize-dependencies.ts +139 -0
- package/src/core/workflow-model/normalize-env.ts +48 -0
- package/src/core/workflow-model/normalize-if-condition.ts +126 -0
- package/src/core/workflow-model/normalize-job-checkout.ts +19 -0
- package/src/core/workflow-model/normalize-job-listening.ts +121 -0
- package/src/core/workflow-model/normalize-job-success.ts +28 -0
- package/src/core/workflow-model/normalize-jobs.ts +1010 -0
- package/src/core/workflow-model/normalize-needs.ts +12 -0
- package/src/core/workflow-model/normalize-step-gate.ts +90 -0
- package/src/core/workflow-model/normalize-step-outputs.ts +36 -0
- package/src/core/workflow-model/normalize-triggers.ts +130 -0
- package/src/core/workflow-model/normalize-workflow-document.test.ts +4463 -0
- package/src/core/workflow-model/normalize-workflow-document.ts +63 -0
- package/src/core/workflow-model/parse-duration-ms.ts +58 -0
- package/src/core/workflow-model/parse-interpolation-field.ts +566 -0
- package/src/core/workflow-model/stable-id.ts +12 -0
- package/src/core/workflow-model/validate-job-references.ts +54 -0
- package/src/core/workflow-model/validate-predicate-expression.test.ts +161 -0
- package/src/core/workflow-model/validate-predicate-expression.ts +406 -0
- package/src/core/workflow-model/validation-issue.ts +27 -0
- package/src/core/workflow-model/workflow-field-label.ts +50 -0
- package/src/core/workflow-yaml/README.md +77 -0
- package/src/core/workflow-yaml/index.ts +11 -0
- package/src/core/workflow-yaml/invalid-workflow-yaml-error.ts +23 -0
- package/src/core/workflow-yaml/parse-workflow-yaml.test.ts +71 -0
- package/src/core/workflow-yaml/parse-workflow-yaml.ts +74 -0
- package/src/core/workflow-yaml/source-locations.ts +62 -0
- package/src/db/db.ts +18 -0
- package/src/db/definition-triggers.test.ts +95 -0
- package/src/db/definition-triggers.ts +17 -0
- package/src/db/definitions.test.ts +1309 -0
- package/src/db/definitions.ts +357 -0
- package/src/db/index.ts +31 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/definitions.test.ts +36 -0
- package/src/db/schema/definitions.ts +76 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/sync-states.ts +79 -0
- package/src/db/sync-states.test.ts +90 -0
- package/src/db/sync-states.ts +86 -0
- package/src/index.ts +104 -0
- package/src/presentation/dto/definition.test.ts +59 -0
- package/src/presentation/dto/definition.ts +41 -0
- package/src/presentation/dto/index.ts +1 -0
- package/src/presentation/index.ts +1 -0
- package/src/presentation/routes/create-definition.test.ts +356 -0
- package/src/presentation/routes/create-definition.ts +101 -0
- package/src/presentation/routes/get-definition.test.ts +79 -0
- package/src/presentation/routes/get-definition.ts +37 -0
- package/src/presentation/routes/index.test.ts +41 -0
- package/src/presentation/routes/index.ts +28 -0
- package/src/presentation/routes/list-definitions.test.ts +195 -0
- package/src/presentation/routes/list-definitions.ts +49 -0
- package/src/presentation/routes/validate-definition.test.ts +63 -0
- package/src/presentation/routes/validate-definition.ts +46 -0
- package/src/presentation/subscribers/index.ts +2 -0
- package/src/presentation/subscribers/on-project-source-bound.test.ts +65 -0
- package/src/presentation/subscribers/on-project-source-bound.ts +11 -0
- package/src/presentation/subscribers/on-project-source-commit-observed.test.ts +56 -0
- package/src/presentation/subscribers/on-project-source-commit-observed.ts +15 -0
- package/src/presentation/subscribers/start-definition-sync.ts +38 -0
- package/src/temporal/activities/index.ts +10 -0
- package/src/temporal/activities/sync-activities.test.ts +337 -0
- package/src/temporal/activities/sync-activities.ts +206 -0
- package/src/temporal/constants.ts +2 -0
- package/src/temporal/index.ts +2 -0
- package/src/temporal/workflows/definition-sync.test.ts +59 -0
- package/src/temporal/workflows/definition-sync.ts +116 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/test/env.ts +7 -0
- package/test/factories/definition.ts +66 -0
- package/test/fixtures/invalid-cycle.yml +13 -0
- package/test/fixtures/invalid-missing-name.yml +4 -0
- package/test/fixtures/invalid-yaml-syntax.yml +6 -0
- package/test/fixtures/valid-dag.yml +25 -0
- package/test/fixtures/valid-listening-job.yml +21 -0
- package/test/fixtures/valid-simple.yml +18 -0
- package/test/fixtures/workflow-yaml/invalid/invalid-yaml-syntax.yaml +6 -0
- package/test/fixtures/workflow-yaml/invalid/missing-step-run.yaml +5 -0
- package/test/fixtures/workflow-yaml/valid/simple-build.document.json +26 -0
- package/test/fixtures/workflow-yaml/valid/simple-build.yaml +14 -0
- package/test/globalSetup.ts +25 -0
- package/test/index.ts +1 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
import {DEFINITION_RESOLVED} from '@shipfox/api-definitions-dto';
|
|
2
|
+
import {
|
|
3
|
+
BATCH_SIZE,
|
|
4
|
+
type DrainedEvent,
|
|
5
|
+
drainAll,
|
|
6
|
+
markDispatched,
|
|
7
|
+
type OutboxDispatchFailure,
|
|
8
|
+
pruneDispatchedOutboxRows,
|
|
9
|
+
recordDispatchFailure,
|
|
10
|
+
registerPublisher,
|
|
11
|
+
renewDispatchClaim,
|
|
12
|
+
resetPublishers,
|
|
13
|
+
} from '@shipfox/node-module';
|
|
14
|
+
import {sql} from 'drizzle-orm';
|
|
15
|
+
import type {WorkflowDefinitionPayload} from '#core/entities/workflow-definition.js';
|
|
16
|
+
import {normalizeWorkflowDocument} from '#core/workflow-model/index.js';
|
|
17
|
+
import {db} from './db.js';
|
|
18
|
+
import {
|
|
19
|
+
applyVcsDefinitionsBatch,
|
|
20
|
+
getDefinitionById,
|
|
21
|
+
invalidateCache,
|
|
22
|
+
listDefinitionsByProject,
|
|
23
|
+
softDeleteVcsDefinitionsNotIn,
|
|
24
|
+
upsertDefinition,
|
|
25
|
+
} from './definitions.js';
|
|
26
|
+
import {workflowDefinitions} from './schema/definitions.js';
|
|
27
|
+
import {definitionsOutbox} from './schema/outbox.js';
|
|
28
|
+
|
|
29
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1_000;
|
|
30
|
+
|
|
31
|
+
function definitionFields(name = 'Test Workflow'): WorkflowDefinitionPayload {
|
|
32
|
+
const document = {
|
|
33
|
+
name,
|
|
34
|
+
runner: 'ubuntu-latest',
|
|
35
|
+
jobs: {build: {steps: [{run: 'echo hello'}]}},
|
|
36
|
+
};
|
|
37
|
+
return definitionFieldsForDocument(document);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function definitionFieldsForDocument(
|
|
41
|
+
document: WorkflowDefinitionPayload['document'],
|
|
42
|
+
): WorkflowDefinitionPayload {
|
|
43
|
+
return {document, model: normalizeWorkflowDocument(document)};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function listOutboxRowsForProject(projectId: string) {
|
|
47
|
+
return await db()
|
|
48
|
+
.select()
|
|
49
|
+
.from(definitionsOutbox)
|
|
50
|
+
.where(sql`${definitionsOutbox.payload}->>'projectId' = ${projectId}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function eventsForProject(events: DrainedEvent[], projectId: string) {
|
|
54
|
+
return events.filter((event) => {
|
|
55
|
+
const payload = event.event.payload as {projectId?: unknown};
|
|
56
|
+
return payload.projectId === projectId;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function insertOutboxRow(params: {
|
|
61
|
+
projectId: string;
|
|
62
|
+
marker: string;
|
|
63
|
+
orderingKey?: string | null;
|
|
64
|
+
createdAt?: Date;
|
|
65
|
+
nextDispatchAt?: Date;
|
|
66
|
+
dispatchedAt?: Date | null;
|
|
67
|
+
deadLetteredAt?: Date | null;
|
|
68
|
+
}) {
|
|
69
|
+
const id = crypto.randomUUID();
|
|
70
|
+
await db()
|
|
71
|
+
.insert(definitionsOutbox)
|
|
72
|
+
.values({
|
|
73
|
+
id,
|
|
74
|
+
eventType: DEFINITION_RESOLVED,
|
|
75
|
+
orderingKey: params.orderingKey ?? null,
|
|
76
|
+
payload: {projectId: params.projectId, marker: params.marker},
|
|
77
|
+
createdAt: params.createdAt,
|
|
78
|
+
nextDispatchAt: params.nextDispatchAt,
|
|
79
|
+
dispatchedAt: params.dispatchedAt ?? null,
|
|
80
|
+
deadLetteredAt: params.deadLetteredAt ?? null,
|
|
81
|
+
});
|
|
82
|
+
return id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('definition queries', () => {
|
|
86
|
+
let projectId: string;
|
|
87
|
+
let workspaceId: string;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
projectId = crypto.randomUUID();
|
|
91
|
+
workspaceId = crypto.randomUUID();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('upsertDefinition', () => {
|
|
95
|
+
test('inserts a new definition and returns entity with id and timestamps', async () => {
|
|
96
|
+
const definition = await upsertDefinition({
|
|
97
|
+
projectId,
|
|
98
|
+
workspaceId,
|
|
99
|
+
configPath: '.shipfox/workflows/test.yml',
|
|
100
|
+
name: 'Test',
|
|
101
|
+
...definitionFields(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(definition.id).toBeDefined();
|
|
105
|
+
expect(definition.projectId).toBe(projectId);
|
|
106
|
+
expect(definition.configPath).toBe('.shipfox/workflows/test.yml');
|
|
107
|
+
expect(definition.name).toBe('Test');
|
|
108
|
+
expect({document: definition.document, model: definition.model}).toEqual(definitionFields());
|
|
109
|
+
expect(definition.sha).toBeNull();
|
|
110
|
+
expect(definition.ref).toBeNull();
|
|
111
|
+
expect(definition.fetchedAt).toBeInstanceOf(Date);
|
|
112
|
+
expect(definition.createdAt).toBeInstanceOf(Date);
|
|
113
|
+
expect(definition.updatedAt).toBeInstanceOf(Date);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('updates existing definition on conflict (same project_id + config_path)', async () => {
|
|
117
|
+
const first = await upsertDefinition({
|
|
118
|
+
projectId,
|
|
119
|
+
workspaceId,
|
|
120
|
+
configPath: '.shipfox/workflows/deploy.yml',
|
|
121
|
+
name: 'Deploy v1',
|
|
122
|
+
...definitionFields('Deploy v1'),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const second = await upsertDefinition({
|
|
126
|
+
projectId,
|
|
127
|
+
workspaceId,
|
|
128
|
+
configPath: '.shipfox/workflows/deploy.yml',
|
|
129
|
+
name: 'Deploy v2',
|
|
130
|
+
...definitionFields('Deploy v2'),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(second.id).toBe(first.id);
|
|
134
|
+
expect(second.name).toBe('Deploy v2');
|
|
135
|
+
expect(second.document.name).toBe('Deploy v2');
|
|
136
|
+
expect(second.createdAt.getTime()).toBe(first.createdAt.getTime());
|
|
137
|
+
expect(second.updatedAt).not.toEqual(first.updatedAt);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('with sha inserts SHA-pinned definition', async () => {
|
|
141
|
+
const definition = await upsertDefinition({
|
|
142
|
+
projectId,
|
|
143
|
+
workspaceId,
|
|
144
|
+
configPath: 'ci.yml',
|
|
145
|
+
name: 'CI',
|
|
146
|
+
...definitionFields('CI'),
|
|
147
|
+
source: 'vcs',
|
|
148
|
+
sha: 'abc123',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(definition.sha).toBe('abc123');
|
|
152
|
+
expect(definition.ref).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('with sha updates on conflict (same projectId + sha + configPath)', async () => {
|
|
156
|
+
const first = await upsertDefinition({
|
|
157
|
+
projectId,
|
|
158
|
+
workspaceId,
|
|
159
|
+
configPath: 'ci.yml',
|
|
160
|
+
name: 'CI v1',
|
|
161
|
+
...definitionFields('CI v1'),
|
|
162
|
+
source: 'vcs',
|
|
163
|
+
sha: 'abc123',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const second = await upsertDefinition({
|
|
167
|
+
projectId,
|
|
168
|
+
workspaceId,
|
|
169
|
+
configPath: 'ci.yml',
|
|
170
|
+
name: 'CI v2',
|
|
171
|
+
...definitionFields('CI v2'),
|
|
172
|
+
source: 'vcs',
|
|
173
|
+
sha: 'abc123',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(second.id).toBe(first.id);
|
|
177
|
+
expect(second.name).toBe('CI v2');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('with ref inserts ref-based definition', async () => {
|
|
181
|
+
const definition = await upsertDefinition({
|
|
182
|
+
projectId,
|
|
183
|
+
workspaceId,
|
|
184
|
+
configPath: 'ci.yml',
|
|
185
|
+
name: 'CI',
|
|
186
|
+
...definitionFields('CI'),
|
|
187
|
+
source: 'vcs',
|
|
188
|
+
ref: 'main',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(definition.ref).toBe('main');
|
|
192
|
+
expect(definition.sha).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('with ref updates on conflict (same projectId + ref + configPath)', async () => {
|
|
196
|
+
const first = await upsertDefinition({
|
|
197
|
+
projectId,
|
|
198
|
+
workspaceId,
|
|
199
|
+
configPath: 'ci.yml',
|
|
200
|
+
name: 'CI v1',
|
|
201
|
+
...definitionFields('CI v1'),
|
|
202
|
+
source: 'vcs',
|
|
203
|
+
ref: 'main',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const second = await upsertDefinition({
|
|
207
|
+
projectId,
|
|
208
|
+
workspaceId,
|
|
209
|
+
configPath: 'ci.yml',
|
|
210
|
+
name: 'CI v2',
|
|
211
|
+
...definitionFields('CI v2'),
|
|
212
|
+
source: 'vcs',
|
|
213
|
+
ref: 'main',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(second.id).toBe(first.id);
|
|
217
|
+
expect(second.name).toBe('CI v2');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('manual and VCS definitions coexist at the same config_path (ENG-659)', async () => {
|
|
221
|
+
const manual = await upsertDefinition({
|
|
222
|
+
projectId,
|
|
223
|
+
workspaceId,
|
|
224
|
+
configPath: 'ci.yml',
|
|
225
|
+
name: 'CI (manual)',
|
|
226
|
+
...definitionFields('CI (manual)'),
|
|
227
|
+
source: 'manual',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const vcs = await upsertDefinition({
|
|
231
|
+
projectId,
|
|
232
|
+
workspaceId,
|
|
233
|
+
configPath: 'ci.yml',
|
|
234
|
+
name: 'CI (vcs)',
|
|
235
|
+
...definitionFields('CI (vcs)'),
|
|
236
|
+
source: 'vcs',
|
|
237
|
+
ref: 'main',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(vcs.id).not.toBe(manual.id);
|
|
241
|
+
expect(manual.source).toBe('manual');
|
|
242
|
+
expect(vcs.source).toBe('vcs');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('VCS definitions on two branches coexist at the same config_path', async () => {
|
|
246
|
+
const main = await upsertDefinition({
|
|
247
|
+
projectId,
|
|
248
|
+
workspaceId,
|
|
249
|
+
configPath: 'ci.yml',
|
|
250
|
+
name: 'CI main',
|
|
251
|
+
...definitionFields('CI main'),
|
|
252
|
+
source: 'vcs',
|
|
253
|
+
ref: 'main',
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const dev = await upsertDefinition({
|
|
257
|
+
projectId,
|
|
258
|
+
workspaceId,
|
|
259
|
+
configPath: 'ci.yml',
|
|
260
|
+
name: 'CI dev',
|
|
261
|
+
...definitionFields('CI dev'),
|
|
262
|
+
source: 'vcs',
|
|
263
|
+
ref: 'dev',
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(dev.id).not.toBe(main.id);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('rejects a vcs definition with neither ref nor sha', async () => {
|
|
270
|
+
const upsert = upsertDefinition({
|
|
271
|
+
projectId,
|
|
272
|
+
workspaceId,
|
|
273
|
+
configPath: 'ci.yml',
|
|
274
|
+
name: 'CI',
|
|
275
|
+
...definitionFields('CI'),
|
|
276
|
+
source: 'vcs',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await expect(upsert).rejects.toThrow();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('rejects a manual definition that sets a sha', async () => {
|
|
283
|
+
const upsert = upsertDefinition({
|
|
284
|
+
projectId,
|
|
285
|
+
workspaceId,
|
|
286
|
+
configPath: 'ci.yml',
|
|
287
|
+
name: 'CI',
|
|
288
|
+
...definitionFields('CI'),
|
|
289
|
+
source: 'manual',
|
|
290
|
+
sha: 'abc123',
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await expect(upsert).rejects.toThrow();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('with neither sha nor ref uses base constraint', async () => {
|
|
297
|
+
const first = await upsertDefinition({
|
|
298
|
+
projectId,
|
|
299
|
+
workspaceId,
|
|
300
|
+
configPath: 'ci.yml',
|
|
301
|
+
name: 'CI v1',
|
|
302
|
+
...definitionFields('CI v1'),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const second = await upsertDefinition({
|
|
306
|
+
projectId,
|
|
307
|
+
workspaceId,
|
|
308
|
+
configPath: 'ci.yml',
|
|
309
|
+
name: 'CI v2',
|
|
310
|
+
...definitionFields('CI v2'),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(second.id).toBe(first.id);
|
|
314
|
+
expect(second.name).toBe('CI v2');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('sets fetchedAt on insert', async () => {
|
|
318
|
+
const definition = await upsertDefinition({
|
|
319
|
+
projectId,
|
|
320
|
+
workspaceId,
|
|
321
|
+
configPath: 'ci.yml',
|
|
322
|
+
name: 'CI',
|
|
323
|
+
...definitionFields('CI'),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(definition.fetchedAt).toBeInstanceOf(Date);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('updates fetchedAt on conflict update', async () => {
|
|
330
|
+
const first = await upsertDefinition({
|
|
331
|
+
projectId,
|
|
332
|
+
workspaceId,
|
|
333
|
+
configPath: 'ci.yml',
|
|
334
|
+
name: 'CI v1',
|
|
335
|
+
...definitionFields('CI v1'),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const second = await upsertDefinition({
|
|
339
|
+
projectId,
|
|
340
|
+
workspaceId,
|
|
341
|
+
configPath: 'ci.yml',
|
|
342
|
+
name: 'CI v2',
|
|
343
|
+
...definitionFields('CI v2'),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(second.fetchedAt.getTime()).toBeGreaterThanOrEqual(first.fetchedAt.getTime());
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('getDefinitionById', () => {
|
|
351
|
+
test('returns the definition when found', async () => {
|
|
352
|
+
const created = await upsertDefinition({
|
|
353
|
+
projectId,
|
|
354
|
+
workspaceId,
|
|
355
|
+
configPath: '.shipfox/workflows/ci.yml',
|
|
356
|
+
name: 'CI',
|
|
357
|
+
...definitionFields('CI'),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const found = await getDefinitionById(created.id);
|
|
361
|
+
|
|
362
|
+
expect(found).toBeDefined();
|
|
363
|
+
expect(found?.id).toBe(created.id);
|
|
364
|
+
expect(found?.name).toBe('CI');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('returns undefined when not found', async () => {
|
|
368
|
+
const found = await getDefinitionById(crypto.randomUUID());
|
|
369
|
+
|
|
370
|
+
expect(found).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('listDefinitionsByProject', () => {
|
|
375
|
+
test('returns definitions ordered by name', async () => {
|
|
376
|
+
await upsertDefinition({
|
|
377
|
+
projectId,
|
|
378
|
+
workspaceId,
|
|
379
|
+
configPath: 'z.yml',
|
|
380
|
+
name: 'Zulu',
|
|
381
|
+
...definitionFields('Zulu'),
|
|
382
|
+
});
|
|
383
|
+
await upsertDefinition({
|
|
384
|
+
projectId,
|
|
385
|
+
workspaceId,
|
|
386
|
+
configPath: 'a.yml',
|
|
387
|
+
name: 'Alpha',
|
|
388
|
+
...definitionFields('Alpha'),
|
|
389
|
+
});
|
|
390
|
+
await upsertDefinition({
|
|
391
|
+
projectId,
|
|
392
|
+
workspaceId,
|
|
393
|
+
configPath: 'm.yml',
|
|
394
|
+
name: 'Mike',
|
|
395
|
+
...definitionFields('Mike'),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const definitions = await listDefinitionsByProject(projectId);
|
|
399
|
+
|
|
400
|
+
expect(definitions).toHaveLength(3);
|
|
401
|
+
expect(definitions[0]?.name).toBe('Alpha');
|
|
402
|
+
expect(definitions[1]?.name).toBe('Mike');
|
|
403
|
+
expect(definitions[2]?.name).toBe('Zulu');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('returns empty array for project with no definitions', async () => {
|
|
407
|
+
const definitions = await listDefinitionsByProject(crypto.randomUUID());
|
|
408
|
+
|
|
409
|
+
expect(definitions).toEqual([]);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe('soft-delete + restore', () => {
|
|
414
|
+
test('soft-deletes VCS definitions for a ref that are not in the keep set', async () => {
|
|
415
|
+
const stale = await upsertDefinition({
|
|
416
|
+
projectId,
|
|
417
|
+
workspaceId,
|
|
418
|
+
configPath: 'old.yml',
|
|
419
|
+
name: 'Old',
|
|
420
|
+
...definitionFields('Old'),
|
|
421
|
+
ref: 'main',
|
|
422
|
+
source: 'vcs',
|
|
423
|
+
});
|
|
424
|
+
const kept = await upsertDefinition({
|
|
425
|
+
projectId,
|
|
426
|
+
workspaceId,
|
|
427
|
+
configPath: 'kept.yml',
|
|
428
|
+
name: 'Kept',
|
|
429
|
+
...definitionFields('Kept'),
|
|
430
|
+
ref: 'main',
|
|
431
|
+
source: 'vcs',
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const deletedCount = await softDeleteVcsDefinitionsNotIn({
|
|
435
|
+
projectId,
|
|
436
|
+
workspaceId,
|
|
437
|
+
ref: 'main',
|
|
438
|
+
keepConfigPaths: ['kept.yml'],
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(deletedCount).toBe(1);
|
|
442
|
+
const after = await listDefinitionsByProject(projectId);
|
|
443
|
+
expect(after.map((definition) => definition.id)).toEqual([kept.id]);
|
|
444
|
+
const staleRow = await db()
|
|
445
|
+
.select()
|
|
446
|
+
.from(workflowDefinitions)
|
|
447
|
+
.where(sql`${workflowDefinitions.id} = ${stale.id}`);
|
|
448
|
+
expect(staleRow[0]?.deletedAt).not.toBeNull();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('reactivates a previously soft-deleted definition by clearing deleted_at on next upsert', async () => {
|
|
452
|
+
const original = await upsertDefinition({
|
|
453
|
+
projectId,
|
|
454
|
+
workspaceId,
|
|
455
|
+
configPath: 'recovered.yml',
|
|
456
|
+
name: 'Recovered v1',
|
|
457
|
+
...definitionFields('Recovered v1'),
|
|
458
|
+
ref: 'main',
|
|
459
|
+
source: 'vcs',
|
|
460
|
+
});
|
|
461
|
+
await softDeleteVcsDefinitionsNotIn({
|
|
462
|
+
projectId,
|
|
463
|
+
workspaceId,
|
|
464
|
+
ref: 'main',
|
|
465
|
+
keepConfigPaths: [],
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const restored = await upsertDefinition({
|
|
469
|
+
projectId,
|
|
470
|
+
workspaceId,
|
|
471
|
+
configPath: 'recovered.yml',
|
|
472
|
+
name: 'Recovered v2',
|
|
473
|
+
...definitionFields('Recovered v2'),
|
|
474
|
+
ref: 'main',
|
|
475
|
+
source: 'vcs',
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
expect(restored.id).toBe(original.id);
|
|
479
|
+
expect(restored.name).toBe('Recovered v2');
|
|
480
|
+
expect(restored.deletedAt).toBeNull();
|
|
481
|
+
const visible = await listDefinitionsByProject(projectId);
|
|
482
|
+
expect(visible.map((definition) => definition.id)).toEqual([restored.id]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('applyVcsDefinitionsBatch upserts and soft-deletes in a single transaction', async () => {
|
|
486
|
+
await upsertDefinition({
|
|
487
|
+
projectId,
|
|
488
|
+
workspaceId,
|
|
489
|
+
configPath: 'orphaned.yml',
|
|
490
|
+
name: 'Orphan',
|
|
491
|
+
...definitionFields('Orphan'),
|
|
492
|
+
ref: 'main',
|
|
493
|
+
source: 'vcs',
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const result = await applyVcsDefinitionsBatch({
|
|
497
|
+
projectId,
|
|
498
|
+
workspaceId,
|
|
499
|
+
ref: 'main',
|
|
500
|
+
upserts: [
|
|
501
|
+
{
|
|
502
|
+
configPath: 'kept.yml',
|
|
503
|
+
name: 'Kept',
|
|
504
|
+
...definitionFields('Kept'),
|
|
505
|
+
contentHash: 'h-kept',
|
|
506
|
+
},
|
|
507
|
+
{configPath: 'new.yml', name: 'New', ...definitionFields('New'), contentHash: 'h-new'},
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
expect(result).toEqual({appliedCount: 2, deletedCount: 1});
|
|
512
|
+
const visible = await listDefinitionsByProject(projectId);
|
|
513
|
+
expect(visible.map((definition) => definition.configPath).sort()).toEqual([
|
|
514
|
+
'kept.yml',
|
|
515
|
+
'new.yml',
|
|
516
|
+
]);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('applyVcsDefinitionsBatch skips outbox events for unchanged content hashes', async () => {
|
|
520
|
+
const first = await applyVcsDefinitionsBatch({
|
|
521
|
+
projectId,
|
|
522
|
+
workspaceId,
|
|
523
|
+
ref: 'main',
|
|
524
|
+
upserts: [
|
|
525
|
+
{configPath: 'a.yml', name: 'A', ...definitionFields('A'), contentHash: 'h-a-v1'},
|
|
526
|
+
{configPath: 'b.yml', name: 'B', ...definitionFields('B'), contentHash: 'h-b-v1'},
|
|
527
|
+
],
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
expect(first.appliedCount).toBe(2);
|
|
531
|
+
expect(await listOutboxRowsForProject(projectId)).toHaveLength(2);
|
|
532
|
+
|
|
533
|
+
const second = await applyVcsDefinitionsBatch({
|
|
534
|
+
projectId,
|
|
535
|
+
workspaceId,
|
|
536
|
+
ref: 'main',
|
|
537
|
+
upserts: [
|
|
538
|
+
{configPath: 'a.yml', name: 'A', ...definitionFields('A'), contentHash: 'h-a-v1'},
|
|
539
|
+
{configPath: 'b.yml', name: 'B v2', ...definitionFields('B v2'), contentHash: 'h-b-v2'},
|
|
540
|
+
],
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
expect(second.appliedCount).toBe(1);
|
|
544
|
+
expect(await listOutboxRowsForProject(projectId)).toHaveLength(3);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('softDeleteVcsDefinitionsNotIn writes a DEFINITION_DELETED event per pruned row', async () => {
|
|
548
|
+
const pruned = await upsertDefinition({
|
|
549
|
+
projectId,
|
|
550
|
+
workspaceId,
|
|
551
|
+
configPath: 'orphan.yml',
|
|
552
|
+
name: 'Orphan',
|
|
553
|
+
...definitionFields('Orphan'),
|
|
554
|
+
ref: 'main',
|
|
555
|
+
source: 'vcs',
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const deletedCount = await softDeleteVcsDefinitionsNotIn({
|
|
559
|
+
projectId,
|
|
560
|
+
workspaceId,
|
|
561
|
+
ref: 'main',
|
|
562
|
+
keepConfigPaths: [],
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(deletedCount).toBe(1);
|
|
566
|
+
const outboxRows = await listOutboxRowsForProject(projectId);
|
|
567
|
+
const deletedEvents = outboxRows.filter(
|
|
568
|
+
(row) => row.eventType === 'definitions.definition.deleted',
|
|
569
|
+
);
|
|
570
|
+
expect(deletedEvents).toHaveLength(1);
|
|
571
|
+
expect(deletedEvents[0]?.payload).toEqual({
|
|
572
|
+
definitionId: pruned.id,
|
|
573
|
+
projectId,
|
|
574
|
+
workspaceId,
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
describe('invalidateCache', () => {
|
|
580
|
+
test('deletes ref-based entries for matching project and ref', async () => {
|
|
581
|
+
const definition = await upsertDefinition({
|
|
582
|
+
projectId,
|
|
583
|
+
workspaceId,
|
|
584
|
+
configPath: 'ci.yml',
|
|
585
|
+
name: 'CI',
|
|
586
|
+
...definitionFields('CI'),
|
|
587
|
+
source: 'vcs',
|
|
588
|
+
ref: 'main',
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
await invalidateCache({projectId, ref: 'main'});
|
|
592
|
+
|
|
593
|
+
const found = await getDefinitionById(definition.id);
|
|
594
|
+
expect(found).toBeUndefined();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('does not delete SHA-pinned entries', async () => {
|
|
598
|
+
const definition = await upsertDefinition({
|
|
599
|
+
projectId,
|
|
600
|
+
workspaceId,
|
|
601
|
+
configPath: 'ci.yml',
|
|
602
|
+
name: 'CI',
|
|
603
|
+
...definitionFields('CI'),
|
|
604
|
+
source: 'vcs',
|
|
605
|
+
sha: 'abc123',
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
await invalidateCache({projectId, ref: 'main'});
|
|
609
|
+
|
|
610
|
+
const found = await getDefinitionById(definition.id);
|
|
611
|
+
expect(found).toBeDefined();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test('is a no-op when no matching entries exist', async () => {
|
|
615
|
+
await invalidateCache({projectId: crypto.randomUUID(), ref: 'main'});
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe('outbox integration', () => {
|
|
620
|
+
test('writes a definitions.definition.resolved event to the outbox', async () => {
|
|
621
|
+
const definition = await upsertDefinition({
|
|
622
|
+
projectId,
|
|
623
|
+
workspaceId,
|
|
624
|
+
configPath: '.shipfox/workflows/test.yml',
|
|
625
|
+
name: 'Test',
|
|
626
|
+
...definitionFields(),
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const outboxRows = await listOutboxRowsForProject(projectId);
|
|
630
|
+
|
|
631
|
+
expect(outboxRows).toHaveLength(1);
|
|
632
|
+
expect(outboxRows[0]?.eventType).toBe(DEFINITION_RESOLVED);
|
|
633
|
+
expect(outboxRows[0]?.payload).toEqual({
|
|
634
|
+
definitionId: definition.id,
|
|
635
|
+
projectId: definition.projectId,
|
|
636
|
+
workspaceId,
|
|
637
|
+
configPath: definition.configPath,
|
|
638
|
+
triggers: {},
|
|
639
|
+
});
|
|
640
|
+
expect(outboxRows[0]?.dispatchedAt).toBeNull();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test('writes normalized trigger config to the resolved outbox event', async () => {
|
|
644
|
+
const fields = definitionFieldsForDocument({
|
|
645
|
+
name: 'Nightly',
|
|
646
|
+
runner: 'ubuntu-latest',
|
|
647
|
+
triggers: {
|
|
648
|
+
nightly: {
|
|
649
|
+
source: 'cron',
|
|
650
|
+
event: 'tick',
|
|
651
|
+
config: {schedule: '0 2 * * *'},
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
jobs: {build: {steps: [{run: 'echo hello'}]}},
|
|
655
|
+
});
|
|
656
|
+
const definition = await upsertDefinition({
|
|
657
|
+
projectId,
|
|
658
|
+
workspaceId,
|
|
659
|
+
configPath: '.shipfox/workflows/nightly.yml',
|
|
660
|
+
name: 'Nightly',
|
|
661
|
+
...fields,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const outboxRows = await listOutboxRowsForProject(projectId);
|
|
665
|
+
|
|
666
|
+
expect(outboxRows).toHaveLength(1);
|
|
667
|
+
expect(outboxRows[0]?.payload).toMatchObject({
|
|
668
|
+
definitionId: definition.id,
|
|
669
|
+
triggers: {
|
|
670
|
+
nightly: {
|
|
671
|
+
source: 'cron',
|
|
672
|
+
event: 'tick',
|
|
673
|
+
config: {
|
|
674
|
+
schedule: '0 2 * * *',
|
|
675
|
+
timezone: 'UTC',
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test('writes one outbox event per upsert call', async () => {
|
|
683
|
+
await upsertDefinition({
|
|
684
|
+
projectId,
|
|
685
|
+
workspaceId,
|
|
686
|
+
configPath: '.shipfox/workflows/test.yml',
|
|
687
|
+
name: 'Test v1',
|
|
688
|
+
...definitionFields('v1'),
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
await upsertDefinition({
|
|
692
|
+
projectId,
|
|
693
|
+
workspaceId,
|
|
694
|
+
configPath: '.shipfox/workflows/test.yml',
|
|
695
|
+
name: 'Test v2',
|
|
696
|
+
...definitionFields('v2'),
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const outboxRows = await listOutboxRowsForProject(projectId);
|
|
700
|
+
|
|
701
|
+
expect(outboxRows).toHaveLength(2);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test('rolls back the outbox event when the transaction fails', async () => {
|
|
705
|
+
const transaction = db().transaction(async (tx) => {
|
|
706
|
+
await tx.insert(definitionsOutbox).values({
|
|
707
|
+
eventType: DEFINITION_RESOLVED,
|
|
708
|
+
payload: {definitionId: 'test', projectId, configPath: 'test'},
|
|
709
|
+
});
|
|
710
|
+
throw new Error('Simulated failure');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
await expect(transaction).rejects.toThrow('Simulated failure');
|
|
714
|
+
|
|
715
|
+
const outboxRows = await listOutboxRowsForProject(projectId);
|
|
716
|
+
|
|
717
|
+
expect(outboxRows).toHaveLength(0);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe('publisher registry (drainAll + markDispatched)', () => {
|
|
722
|
+
beforeEach(async () => {
|
|
723
|
+
await db()
|
|
724
|
+
.update(definitionsOutbox)
|
|
725
|
+
.set({dispatchedAt: sql`COALESCE(${definitionsOutbox.dispatchedAt}, now())`})
|
|
726
|
+
.where(sql`${definitionsOutbox.dispatchedAt} IS NULL`);
|
|
727
|
+
resetPublishers();
|
|
728
|
+
registerPublisher({
|
|
729
|
+
name: 'definitions',
|
|
730
|
+
table: definitionsOutbox,
|
|
731
|
+
db: () => db(),
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
afterEach(async () => {
|
|
736
|
+
resetPublishers();
|
|
737
|
+
await db()
|
|
738
|
+
.update(definitionsOutbox)
|
|
739
|
+
.set({dispatchedAt: sql`COALESCE(${definitionsOutbox.dispatchedAt}, now())`})
|
|
740
|
+
.where(sql`${definitionsOutbox.payload}->>'projectId' = ${projectId}`);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test('drainAll returns undispatched outbox events', async () => {
|
|
744
|
+
await upsertDefinition({
|
|
745
|
+
projectId,
|
|
746
|
+
workspaceId,
|
|
747
|
+
configPath: 'test.yml',
|
|
748
|
+
name: 'Test',
|
|
749
|
+
...definitionFields(),
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const events = (await drainAll()).events;
|
|
753
|
+
const projectEvents = eventsForProject(events, projectId);
|
|
754
|
+
|
|
755
|
+
expect(projectEvents).toHaveLength(1);
|
|
756
|
+
expect(projectEvents[0]?.source).toBe('definitions');
|
|
757
|
+
expect(projectEvents[0]?.orderingKey).toBe('definitions');
|
|
758
|
+
expect(projectEvents[0]?.event.type).toBe(DEFINITION_RESOLVED);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test('drainAll resolves orderingKey from the column', async () => {
|
|
762
|
+
await insertOutboxRow({projectId, marker: 'keyed', orderingKey: 'run-1'});
|
|
763
|
+
|
|
764
|
+
const projectEvents = eventsForProject((await drainAll()).events, projectId);
|
|
765
|
+
|
|
766
|
+
expect(projectEvents).toHaveLength(1);
|
|
767
|
+
expect(projectEvents[0]?.orderingKey).toBe('run-1');
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test('drainAll blocks later same-key rows while an earlier row waits for retry', async () => {
|
|
771
|
+
const base = new Date(Date.now() - 60_000);
|
|
772
|
+
const retry = await insertOutboxRow({
|
|
773
|
+
projectId,
|
|
774
|
+
marker: 'retry',
|
|
775
|
+
orderingKey: 'run-1',
|
|
776
|
+
createdAt: base,
|
|
777
|
+
nextDispatchAt: new Date(Date.now() + 60 * 60_000),
|
|
778
|
+
});
|
|
779
|
+
await insertOutboxRow({
|
|
780
|
+
projectId,
|
|
781
|
+
marker: 'later-same-key',
|
|
782
|
+
orderingKey: 'run-1',
|
|
783
|
+
createdAt: new Date(base.getTime() + 1_000),
|
|
784
|
+
});
|
|
785
|
+
await insertOutboxRow({
|
|
786
|
+
projectId,
|
|
787
|
+
marker: 'other-key',
|
|
788
|
+
orderingKey: 'run-2',
|
|
789
|
+
createdAt: new Date(base.getTime() + 2_000),
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
const projectEvents = eventsForProject((await drainAll()).events, projectId);
|
|
794
|
+
|
|
795
|
+
expect(projectEvents.map((event) => event.id)).not.toContain(retry);
|
|
796
|
+
expect(projectEvents.map((event) => event.event.payload)).toEqual([
|
|
797
|
+
{projectId, marker: 'other-key'},
|
|
798
|
+
]);
|
|
799
|
+
} finally {
|
|
800
|
+
await db()
|
|
801
|
+
.update(definitionsOutbox)
|
|
802
|
+
.set({dispatchedAt: sql`now()`})
|
|
803
|
+
.where(sql`${definitionsOutbox.payload}->>'projectId' = ${projectId}`);
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test('drainAll reports hasMore when a source returns a full batch', async () => {
|
|
808
|
+
const rows = Array.from({length: BATCH_SIZE}, (_, index) => ({
|
|
809
|
+
id: crypto.randomUUID(),
|
|
810
|
+
eventType: DEFINITION_RESOLVED,
|
|
811
|
+
payload: {projectId, marker: `batch-${index}`},
|
|
812
|
+
}));
|
|
813
|
+
await db().insert(definitionsOutbox).values(rows);
|
|
814
|
+
|
|
815
|
+
const drain = await drainAll();
|
|
816
|
+
|
|
817
|
+
try {
|
|
818
|
+
expect(drain.events).toHaveLength(BATCH_SIZE);
|
|
819
|
+
expect(drain.hasMore).toBe(true);
|
|
820
|
+
} finally {
|
|
821
|
+
await markDispatched(
|
|
822
|
+
'definitions',
|
|
823
|
+
drain.events.map((event) => event.id),
|
|
824
|
+
);
|
|
825
|
+
await db()
|
|
826
|
+
.update(definitionsOutbox)
|
|
827
|
+
.set({dispatchedAt: sql`now()`})
|
|
828
|
+
.where(sql`${definitionsOutbox.payload}->>'projectId' = ${projectId}`);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test('concurrent drainAll calls claim each row at most once', async () => {
|
|
833
|
+
await insertOutboxRow({projectId, marker: 'first', orderingKey: 'run-1'});
|
|
834
|
+
await insertOutboxRow({projectId, marker: 'second', orderingKey: 'run-2'});
|
|
835
|
+
|
|
836
|
+
const [firstDrain, secondDrain] = await Promise.all([drainAll(), drainAll()]);
|
|
837
|
+
|
|
838
|
+
const firstEvents = eventsForProject(firstDrain.events, projectId);
|
|
839
|
+
const secondEvents = eventsForProject(secondDrain.events, projectId);
|
|
840
|
+
const firstIds = new Set(firstEvents.map((event) => event.id));
|
|
841
|
+
const duplicateIds = secondEvents
|
|
842
|
+
.map((event) => event.id)
|
|
843
|
+
.filter((eventId) => firstIds.has(eventId));
|
|
844
|
+
expect(firstEvents.length + secondEvents.length).toBe(2);
|
|
845
|
+
expect(duplicateIds).toEqual([]);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test('drainAll leases claimed rows so a crashed worker redelivers after the lease expires', async () => {
|
|
849
|
+
const id = await insertOutboxRow({projectId, marker: 'leased', orderingKey: 'run-1'});
|
|
850
|
+
|
|
851
|
+
const firstDrain = eventsForProject((await drainAll()).events, projectId);
|
|
852
|
+
const secondDrain = eventsForProject((await drainAll()).events, projectId);
|
|
853
|
+
await db()
|
|
854
|
+
.update(definitionsOutbox)
|
|
855
|
+
.set({nextDispatchAt: sql`now() - interval '1 second'`})
|
|
856
|
+
.where(sql`${definitionsOutbox.id} = ${id}`);
|
|
857
|
+
const redeliveryDrain = eventsForProject((await drainAll()).events, projectId);
|
|
858
|
+
|
|
859
|
+
expect(firstDrain.map((event) => event.id)).toEqual([id]);
|
|
860
|
+
expect(secondDrain).toHaveLength(0);
|
|
861
|
+
expect(redeliveryDrain.map((event) => event.id)).toEqual([id]);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test('renewDispatchClaim extends only the currently owned claim', async () => {
|
|
865
|
+
await insertOutboxRow({projectId, marker: 'renewed', orderingKey: 'run-1'});
|
|
866
|
+
const [event] = eventsForProject((await drainAll()).events, projectId);
|
|
867
|
+
|
|
868
|
+
const renewedClaimExpiresAt = await renewDispatchClaim('definitions', {
|
|
869
|
+
id: event?.id as string,
|
|
870
|
+
claimExpiresAt: event?.claimExpiresAt as Date,
|
|
871
|
+
});
|
|
872
|
+
const staleRenewal = await renewDispatchClaim('definitions', {
|
|
873
|
+
id: event?.id as string,
|
|
874
|
+
claimExpiresAt: event?.claimExpiresAt as Date,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
expect(renewedClaimExpiresAt).toBeInstanceOf(Date);
|
|
878
|
+
expect(renewedClaimExpiresAt?.getTime()).toBeGreaterThanOrEqual(
|
|
879
|
+
(event?.claimExpiresAt as Date).getTime(),
|
|
880
|
+
);
|
|
881
|
+
expect(staleRenewal).toBeUndefined();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test('markDispatched ignores stale claims that have already been renewed', async () => {
|
|
885
|
+
await insertOutboxRow({projectId, marker: 'stale-success', orderingKey: 'run-1'});
|
|
886
|
+
const [event] = eventsForProject((await drainAll()).events, projectId);
|
|
887
|
+
await renewDispatchClaim('definitions', {
|
|
888
|
+
id: event?.id as string,
|
|
889
|
+
claimExpiresAt: event?.claimExpiresAt as Date,
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
await markDispatched('definitions', [
|
|
893
|
+
{id: event?.id as string, claimExpiresAt: event?.claimExpiresAt as Date},
|
|
894
|
+
]);
|
|
895
|
+
|
|
896
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
897
|
+
expect(rows[0]?.dispatchedAt).toBeNull();
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test('recordDispatchFailure ignores stale claims that have already been renewed', async () => {
|
|
901
|
+
const failure: OutboxDispatchFailure = {
|
|
902
|
+
kind: 'handler',
|
|
903
|
+
eventType: DEFINITION_RESOLVED,
|
|
904
|
+
eventId: crypto.randomUUID(),
|
|
905
|
+
errorName: 'Error',
|
|
906
|
+
errorMessage: 'subscriber failed',
|
|
907
|
+
};
|
|
908
|
+
await insertOutboxRow({projectId, marker: 'stale-failure', orderingKey: 'run-1'});
|
|
909
|
+
const [event] = eventsForProject((await drainAll()).events, projectId);
|
|
910
|
+
await renewDispatchClaim('definitions', {
|
|
911
|
+
id: event?.id as string,
|
|
912
|
+
claimExpiresAt: event?.claimExpiresAt as Date,
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
await recordDispatchFailure(
|
|
916
|
+
'definitions',
|
|
917
|
+
event?.id as string,
|
|
918
|
+
failure,
|
|
919
|
+
event?.claimExpiresAt as Date,
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
923
|
+
expect(rows[0]?.dispatchAttempts).toBe(0);
|
|
924
|
+
expect(rows[0]?.lastDispatchError).toBeNull();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test('partitioned drainAll assigns the same ordering key to one worker', async () => {
|
|
928
|
+
await insertOutboxRow({projectId, marker: 'shared-1', orderingKey: 'run-shared'});
|
|
929
|
+
await insertOutboxRow({projectId, marker: 'shared-2', orderingKey: 'run-shared'});
|
|
930
|
+
await insertOutboxRow({projectId, marker: 'other-1', orderingKey: 'run-other-1'});
|
|
931
|
+
await insertOutboxRow({projectId, marker: 'other-2', orderingKey: 'run-other-2'});
|
|
932
|
+
const workerCount = 4;
|
|
933
|
+
|
|
934
|
+
const drains = await Promise.all(
|
|
935
|
+
Array.from({length: workerCount}, (_, workerIndex) =>
|
|
936
|
+
drainAll({partition: {workerIndex, workerCount}}),
|
|
937
|
+
),
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const partitionsByKey = new Map<string, Set<number>>();
|
|
941
|
+
const projectEvents = drains.flatMap((drain, workerIndex) =>
|
|
942
|
+
eventsForProject(drain.events, projectId).map((event) => ({...event, workerIndex})),
|
|
943
|
+
);
|
|
944
|
+
for (const event of projectEvents) {
|
|
945
|
+
const workers = partitionsByKey.get(event.orderingKey) ?? new Set<number>();
|
|
946
|
+
workers.add(event.workerIndex);
|
|
947
|
+
partitionsByKey.set(event.orderingKey, workers);
|
|
948
|
+
}
|
|
949
|
+
const sharedEvents = projectEvents.filter((event) => event.orderingKey === 'run-shared');
|
|
950
|
+
expect(projectEvents).toHaveLength(4);
|
|
951
|
+
expect(sharedEvents).toHaveLength(2);
|
|
952
|
+
expect(partitionsByKey.get('run-shared')?.size).toBe(1);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
test('markDispatched sets dispatchedAt for a single event', async () => {
|
|
956
|
+
await upsertDefinition({
|
|
957
|
+
projectId,
|
|
958
|
+
workspaceId,
|
|
959
|
+
configPath: 'test.yml',
|
|
960
|
+
name: 'Test',
|
|
961
|
+
...definitionFields(),
|
|
962
|
+
});
|
|
963
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
964
|
+
|
|
965
|
+
await markDispatched('definitions', [events[0]?.id as string]);
|
|
966
|
+
|
|
967
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
968
|
+
const dispatched = rows.filter((row) => row.dispatchedAt !== null);
|
|
969
|
+
expect(dispatched).toHaveLength(1);
|
|
970
|
+
expect(dispatched[0]?.id).toBe(events[0]?.id);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test('markDispatched sets dispatchedAt for multiple events', async () => {
|
|
974
|
+
await upsertDefinition({
|
|
975
|
+
projectId,
|
|
976
|
+
workspaceId,
|
|
977
|
+
configPath: 'a.yml',
|
|
978
|
+
name: 'A',
|
|
979
|
+
...definitionFields('A'),
|
|
980
|
+
});
|
|
981
|
+
await upsertDefinition({
|
|
982
|
+
projectId,
|
|
983
|
+
workspaceId,
|
|
984
|
+
configPath: 'b.yml',
|
|
985
|
+
name: 'B',
|
|
986
|
+
...definitionFields('B'),
|
|
987
|
+
});
|
|
988
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
989
|
+
const ids = events.map((e) => e.id);
|
|
990
|
+
|
|
991
|
+
await markDispatched('definitions', ids);
|
|
992
|
+
|
|
993
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
994
|
+
const pending = rows.filter((row) => row.dispatchedAt === null);
|
|
995
|
+
expect(pending).toHaveLength(0);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test('drainAll skips already-dispatched events', async () => {
|
|
999
|
+
await upsertDefinition({
|
|
1000
|
+
projectId,
|
|
1001
|
+
workspaceId,
|
|
1002
|
+
configPath: 'test.yml',
|
|
1003
|
+
name: 'Test',
|
|
1004
|
+
...definitionFields(),
|
|
1005
|
+
});
|
|
1006
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
1007
|
+
await markDispatched('definitions', [events[0]?.id as string]);
|
|
1008
|
+
|
|
1009
|
+
const secondDrain = eventsForProject((await drainAll()).events, projectId);
|
|
1010
|
+
|
|
1011
|
+
expect(secondDrain).toHaveLength(0);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test('drainAll skips rows scheduled for a future retry', async () => {
|
|
1015
|
+
await upsertDefinition({
|
|
1016
|
+
projectId,
|
|
1017
|
+
workspaceId,
|
|
1018
|
+
configPath: 'retry.yml',
|
|
1019
|
+
name: 'Retry',
|
|
1020
|
+
...definitionFields(),
|
|
1021
|
+
});
|
|
1022
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
1023
|
+
await db()
|
|
1024
|
+
.update(definitionsOutbox)
|
|
1025
|
+
.set({nextDispatchAt: sql`now() + interval '1 hour'`})
|
|
1026
|
+
.where(sql`${definitionsOutbox.id} = ${events[0]?.id as string}`);
|
|
1027
|
+
|
|
1028
|
+
const secondDrain = eventsForProject((await drainAll()).events, projectId);
|
|
1029
|
+
|
|
1030
|
+
expect(secondDrain).toHaveLength(0);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('recordDispatchFailure increments attempts, stores sanitized error metadata, and delays retry', async () => {
|
|
1034
|
+
const failure: OutboxDispatchFailure = {
|
|
1035
|
+
kind: 'validation',
|
|
1036
|
+
eventType: DEFINITION_RESOLVED,
|
|
1037
|
+
eventId: crypto.randomUUID(),
|
|
1038
|
+
issues: [{path: ['definitionId'], code: 'invalid_type', message: 'expected string'}],
|
|
1039
|
+
};
|
|
1040
|
+
await upsertDefinition({
|
|
1041
|
+
projectId,
|
|
1042
|
+
workspaceId,
|
|
1043
|
+
configPath: 'failure.yml',
|
|
1044
|
+
name: 'Failure',
|
|
1045
|
+
...definitionFields(),
|
|
1046
|
+
});
|
|
1047
|
+
const before = new Date();
|
|
1048
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
1049
|
+
|
|
1050
|
+
await recordDispatchFailure('definitions', events[0]?.id as string, failure);
|
|
1051
|
+
|
|
1052
|
+
const after = new Date();
|
|
1053
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
1054
|
+
const secondDrain = eventsForProject((await drainAll()).events, projectId);
|
|
1055
|
+
expect(rows[0]?.dispatchAttempts).toBe(1);
|
|
1056
|
+
expect(rows[0]?.lastDispatchError).toEqual(failure);
|
|
1057
|
+
expect(rows[0]?.lastDispatchFailedAt).toBeInstanceOf(Date);
|
|
1058
|
+
expect(rows[0]?.nextDispatchAt.getTime()).toBeGreaterThanOrEqual(before.getTime() + 9_000);
|
|
1059
|
+
expect(rows[0]?.nextDispatchAt.getTime()).toBeLessThanOrEqual(after.getTime() + 11_000);
|
|
1060
|
+
expect(rows[0]?.deadLetteredAt).toBeNull();
|
|
1061
|
+
expect(rows[0]?.dispatchedAt).toBeNull();
|
|
1062
|
+
expect(secondDrain).toHaveLength(0);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
test('recordDispatchFailure dead-letters on the fifth failed attempt and excludes the row from future drains', async () => {
|
|
1066
|
+
const failure: OutboxDispatchFailure = {
|
|
1067
|
+
kind: 'handler',
|
|
1068
|
+
eventType: DEFINITION_RESOLVED,
|
|
1069
|
+
eventId: crypto.randomUUID(),
|
|
1070
|
+
errorName: 'Error',
|
|
1071
|
+
errorMessage: 'subscriber failed',
|
|
1072
|
+
};
|
|
1073
|
+
await upsertDefinition({
|
|
1074
|
+
projectId,
|
|
1075
|
+
workspaceId,
|
|
1076
|
+
configPath: 'poison.yml',
|
|
1077
|
+
name: 'Poison',
|
|
1078
|
+
...definitionFields(),
|
|
1079
|
+
});
|
|
1080
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
1081
|
+
await db()
|
|
1082
|
+
.update(definitionsOutbox)
|
|
1083
|
+
.set({dispatchAttempts: 4})
|
|
1084
|
+
.where(sql`${definitionsOutbox.id} = ${events[0]?.id as string}`);
|
|
1085
|
+
|
|
1086
|
+
await recordDispatchFailure('definitions', events[0]?.id as string, failure);
|
|
1087
|
+
|
|
1088
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
1089
|
+
const secondDrain = eventsForProject((await drainAll()).events, projectId);
|
|
1090
|
+
expect(rows[0]?.dispatchAttempts).toBe(5);
|
|
1091
|
+
expect(rows[0]?.lastDispatchError).toEqual(failure);
|
|
1092
|
+
expect(rows[0]?.deadLetteredAt).toBeInstanceOf(Date);
|
|
1093
|
+
expect(rows[0]?.dispatchedAt).toBeNull();
|
|
1094
|
+
expect(secondDrain).toHaveLength(0);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test('markDispatched leaves dead-lettered rows inspectable as undispatched', async () => {
|
|
1098
|
+
await upsertDefinition({
|
|
1099
|
+
projectId,
|
|
1100
|
+
workspaceId,
|
|
1101
|
+
configPath: 'dead.yml',
|
|
1102
|
+
name: 'Dead',
|
|
1103
|
+
...definitionFields(),
|
|
1104
|
+
});
|
|
1105
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
1106
|
+
await db()
|
|
1107
|
+
.update(definitionsOutbox)
|
|
1108
|
+
.set({deadLetteredAt: sql`now()`})
|
|
1109
|
+
.where(sql`${definitionsOutbox.id} = ${events[0]?.id as string}`);
|
|
1110
|
+
|
|
1111
|
+
await markDispatched('definitions', [events[0]?.id as string]);
|
|
1112
|
+
|
|
1113
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
1114
|
+
expect(rows[0]?.dispatchedAt).toBeNull();
|
|
1115
|
+
expect(rows[0]?.deadLetteredAt).toBeInstanceOf(Date);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
test('recordDispatchFailure does not mutate already-dispatched rows', async () => {
|
|
1119
|
+
const failure: OutboxDispatchFailure = {
|
|
1120
|
+
kind: 'handler',
|
|
1121
|
+
eventType: DEFINITION_RESOLVED,
|
|
1122
|
+
eventId: crypto.randomUUID(),
|
|
1123
|
+
errorName: 'Error',
|
|
1124
|
+
errorMessage: 'subscriber failed',
|
|
1125
|
+
};
|
|
1126
|
+
await upsertDefinition({
|
|
1127
|
+
projectId,
|
|
1128
|
+
workspaceId,
|
|
1129
|
+
configPath: 'dispatched.yml',
|
|
1130
|
+
name: 'Dispatched',
|
|
1131
|
+
...definitionFields(),
|
|
1132
|
+
});
|
|
1133
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
1134
|
+
await markDispatched('definitions', [events[0]?.id as string]);
|
|
1135
|
+
|
|
1136
|
+
await recordDispatchFailure('definitions', events[0]?.id as string, failure);
|
|
1137
|
+
|
|
1138
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
1139
|
+
expect(rows[0]?.dispatchAttempts).toBe(0);
|
|
1140
|
+
expect(rows[0]?.lastDispatchError).toBeNull();
|
|
1141
|
+
expect(rows[0]?.dispatchedAt).toBeInstanceOf(Date);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
test.each([
|
|
1145
|
+
{priorAttempts: 1, delaySeconds: 60},
|
|
1146
|
+
{priorAttempts: 2, delaySeconds: 300},
|
|
1147
|
+
{priorAttempts: 3, delaySeconds: 1800},
|
|
1148
|
+
])('recordDispatchFailure backs off retry by ~$delaySeconds s after $priorAttempts prior attempts', async ({
|
|
1149
|
+
priorAttempts,
|
|
1150
|
+
delaySeconds,
|
|
1151
|
+
}) => {
|
|
1152
|
+
const failure: OutboxDispatchFailure = {
|
|
1153
|
+
kind: 'handler',
|
|
1154
|
+
eventType: DEFINITION_RESOLVED,
|
|
1155
|
+
eventId: crypto.randomUUID(),
|
|
1156
|
+
errorName: 'Error',
|
|
1157
|
+
errorMessage: 'subscriber failed',
|
|
1158
|
+
};
|
|
1159
|
+
await upsertDefinition({
|
|
1160
|
+
projectId,
|
|
1161
|
+
workspaceId,
|
|
1162
|
+
configPath: `backoff-${priorAttempts}.yml`,
|
|
1163
|
+
name: `Backoff ${priorAttempts}`,
|
|
1164
|
+
...definitionFields(),
|
|
1165
|
+
});
|
|
1166
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
1167
|
+
await db()
|
|
1168
|
+
.update(definitionsOutbox)
|
|
1169
|
+
.set({dispatchAttempts: priorAttempts})
|
|
1170
|
+
.where(sql`${definitionsOutbox.id} = ${events[0]?.id as string}`);
|
|
1171
|
+
const before = new Date();
|
|
1172
|
+
|
|
1173
|
+
await recordDispatchFailure('definitions', events[0]?.id as string, failure);
|
|
1174
|
+
|
|
1175
|
+
const after = new Date();
|
|
1176
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
1177
|
+
const delayMs = delaySeconds * 1_000;
|
|
1178
|
+
expect(rows[0]?.dispatchAttempts).toBe(priorAttempts + 1);
|
|
1179
|
+
expect(rows[0]?.deadLetteredAt).toBeNull();
|
|
1180
|
+
expect(rows[0]?.nextDispatchAt.getTime()).toBeGreaterThanOrEqual(
|
|
1181
|
+
before.getTime() + delayMs - 1_000,
|
|
1182
|
+
);
|
|
1183
|
+
expect(rows[0]?.nextDispatchAt.getTime()).toBeLessThanOrEqual(
|
|
1184
|
+
after.getTime() + delayMs + 1_000,
|
|
1185
|
+
);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
test('redrains a failed row once its retry delay passes, then dispatches it', async () => {
|
|
1189
|
+
const failure: OutboxDispatchFailure = {
|
|
1190
|
+
kind: 'handler',
|
|
1191
|
+
eventType: DEFINITION_RESOLVED,
|
|
1192
|
+
eventId: crypto.randomUUID(),
|
|
1193
|
+
errorName: 'Error',
|
|
1194
|
+
errorMessage: 'subscriber failed',
|
|
1195
|
+
};
|
|
1196
|
+
await upsertDefinition({
|
|
1197
|
+
projectId,
|
|
1198
|
+
workspaceId,
|
|
1199
|
+
configPath: 'recover.yml',
|
|
1200
|
+
name: 'Recover',
|
|
1201
|
+
...definitionFields(),
|
|
1202
|
+
});
|
|
1203
|
+
const events = eventsForProject((await drainAll()).events, projectId);
|
|
1204
|
+
await recordDispatchFailure('definitions', events[0]?.id as string, failure);
|
|
1205
|
+
await db()
|
|
1206
|
+
.update(definitionsOutbox)
|
|
1207
|
+
.set({nextDispatchAt: sql`now() - interval '1 second'`})
|
|
1208
|
+
.where(sql`${definitionsOutbox.id} = ${events[0]?.id as string}`);
|
|
1209
|
+
|
|
1210
|
+
const retryDrain = eventsForProject((await drainAll()).events, projectId);
|
|
1211
|
+
await markDispatched(
|
|
1212
|
+
'definitions',
|
|
1213
|
+
retryDrain.map((event) => event.id),
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
const rows = await listOutboxRowsForProject(projectId);
|
|
1217
|
+
const finalDrain = eventsForProject((await drainAll()).events, projectId);
|
|
1218
|
+
expect(retryDrain).toHaveLength(1);
|
|
1219
|
+
expect(retryDrain[0]?.id).toBe(events[0]?.id);
|
|
1220
|
+
expect(rows[0]?.dispatchAttempts).toBe(1);
|
|
1221
|
+
expect(rows[0]?.dispatchedAt).toBeInstanceOf(Date);
|
|
1222
|
+
expect(finalDrain).toHaveLength(0);
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
test('pruneDispatchedOutboxRows deletes only dispatched rows older than retention', async () => {
|
|
1226
|
+
const oldDispatchedAt = new Date(Date.now() - 8 * MS_PER_DAY);
|
|
1227
|
+
const recentDispatchedAt = new Date(Date.now() - 6 * MS_PER_DAY);
|
|
1228
|
+
await insertOutboxRow({projectId, marker: 'old-dispatched', dispatchedAt: oldDispatchedAt});
|
|
1229
|
+
await insertOutboxRow({
|
|
1230
|
+
projectId,
|
|
1231
|
+
marker: 'recent-dispatched',
|
|
1232
|
+
dispatchedAt: recentDispatchedAt,
|
|
1233
|
+
});
|
|
1234
|
+
await insertOutboxRow({projectId, marker: 'old-pending'});
|
|
1235
|
+
await insertOutboxRow({
|
|
1236
|
+
projectId,
|
|
1237
|
+
marker: 'dead-lettered',
|
|
1238
|
+
deadLetteredAt: oldDispatchedAt,
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
const result = await pruneDispatchedOutboxRows({
|
|
1242
|
+
retentionDays: 7,
|
|
1243
|
+
batchSize: 10,
|
|
1244
|
+
maxBatchesPerSource: 2,
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
const remaining = await listOutboxRowsForProject(projectId);
|
|
1248
|
+
const markers = remaining.map((row) => (row.payload as {marker: string}).marker).sort();
|
|
1249
|
+
expect(result).toEqual([{source: 'definitions', deleted: 1, capped: false}]);
|
|
1250
|
+
expect(markers).toEqual(['dead-lettered', 'old-pending', 'recent-dispatched']);
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
test('pruneDispatchedOutboxRows continues across batches', async () => {
|
|
1254
|
+
const oldDispatchedAt = new Date(Date.now() - 8 * MS_PER_DAY);
|
|
1255
|
+
await insertOutboxRow({projectId, marker: 'old-1', dispatchedAt: oldDispatchedAt});
|
|
1256
|
+
await insertOutboxRow({projectId, marker: 'old-2', dispatchedAt: oldDispatchedAt});
|
|
1257
|
+
await insertOutboxRow({projectId, marker: 'old-3', dispatchedAt: oldDispatchedAt});
|
|
1258
|
+
|
|
1259
|
+
const result = await pruneDispatchedOutboxRows({
|
|
1260
|
+
retentionDays: 7,
|
|
1261
|
+
batchSize: 2,
|
|
1262
|
+
maxBatchesPerSource: 2,
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
const remaining = await listOutboxRowsForProject(projectId);
|
|
1266
|
+
expect(result).toEqual([{source: 'definitions', deleted: 3, capped: false}]);
|
|
1267
|
+
expect(remaining).toHaveLength(0);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
test('pruneDispatchedOutboxRows reports capped sources', async () => {
|
|
1271
|
+
const oldDispatchedAt = new Date(Date.now() - 8 * MS_PER_DAY);
|
|
1272
|
+
await insertOutboxRow({projectId, marker: 'old-1', dispatchedAt: oldDispatchedAt});
|
|
1273
|
+
await insertOutboxRow({projectId, marker: 'old-2', dispatchedAt: oldDispatchedAt});
|
|
1274
|
+
await insertOutboxRow({projectId, marker: 'old-3', dispatchedAt: oldDispatchedAt});
|
|
1275
|
+
|
|
1276
|
+
const result = await pruneDispatchedOutboxRows({
|
|
1277
|
+
retentionDays: 7,
|
|
1278
|
+
batchSize: 2,
|
|
1279
|
+
maxBatchesPerSource: 1,
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
const remaining = await listOutboxRowsForProject(projectId);
|
|
1283
|
+
expect(result).toEqual([{source: 'definitions', deleted: 2, capped: true}]);
|
|
1284
|
+
expect(remaining).toHaveLength(1);
|
|
1285
|
+
await db()
|
|
1286
|
+
.update(definitionsOutbox)
|
|
1287
|
+
.set({dispatchedAt: new Date()})
|
|
1288
|
+
.where(sql`${definitionsOutbox.payload}->>'projectId' = ${projectId}`);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
test('pruneDispatchedOutboxRows returns zero when no rows are eligible', async () => {
|
|
1292
|
+
await insertOutboxRow({
|
|
1293
|
+
projectId,
|
|
1294
|
+
marker: 'recent-dispatched',
|
|
1295
|
+
dispatchedAt: new Date(Date.now() - 6 * MS_PER_DAY),
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
const result = await pruneDispatchedOutboxRows({
|
|
1299
|
+
retentionDays: 7,
|
|
1300
|
+
batchSize: 10,
|
|
1301
|
+
maxBatchesPerSource: 2,
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
const remaining = await listOutboxRowsForProject(projectId);
|
|
1305
|
+
expect(result).toEqual([{source: 'definitions', deleted: 0, capped: false}]);
|
|
1306
|
+
expect(remaining).toHaveLength(1);
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
});
|