The gap
PR #518 added server-side checks on the two paths that place an artefact into a package — AddArtefactsToPackageUseCase and CreatePackageUseCase — and MoveArtefactsToPackageUseCase empties every other package in the space before writing the target.
All three read, then write. Two requests that interleave between the read and the write still place one artefact twice:
- two
packages move of the same unassigned artefact to different targets: both read an empty source list, both add;
- the same interleaving on
add and on package creation.
Live updates narrow the window; they do not close it, and they are not there at all when the SSE connection drops. Raised by Greptile on #518 (P1) and deferred deliberately: the checks that PR adds are worth having on their own — they close the wide window, the one measured in minutes, that the clients' fetched-once lists left open — but the last few milliseconds need a different mechanism.
What would close it
Uniqueness enforced where the write happens, so that the loser of a race is rejected by the database rather than by a check that has already gone stale:
- one artefact may appear in at most one package per space, across the three join tables (
package_commands, package_standards, package_skills — names to confirm);
- a package is scoped to a space, so the constraint has to reach through the package to its space, which a plain unique index on the join table cannot express. A partial/functional index, a denormalised
space_id on the join rows, or an exclusion constraint are the candidates;
- the three use cases then catch the violation and answer the same 409 they already raise, so the error contract does not change.
Worth knowing before starting
UpdatePackageUseCase deliberately does not check, so that a package holding an artefact shared with another one stays editable rather than becoming frozen. A database constraint would not make that distinction, so existing shared memberships have to be found and resolved before it can be applied — a migration question, not only a schema one.
- Two test suites now seed a shared artefact through the update path precisely because it is the one route left that allows it (
packages/integration-tests/src/package-removal-from-target.spec.ts, apps/cli-e2e-tests/src/packages-membership.spec.ts). They will need another route, or the scenario retired.
Follow-up to #518.
The gap
PR #518 added server-side checks on the two paths that place an artefact into a package —
AddArtefactsToPackageUseCaseandCreatePackageUseCase— andMoveArtefactsToPackageUseCaseempties every other package in the space before writing the target.All three read, then write. Two requests that interleave between the read and the write still place one artefact twice:
packages moveof the same unassigned artefact to different targets: both read an empty source list, both add;addand on package creation.Live updates narrow the window; they do not close it, and they are not there at all when the SSE connection drops. Raised by Greptile on #518 (P1) and deferred deliberately: the checks that PR adds are worth having on their own — they close the wide window, the one measured in minutes, that the clients' fetched-once lists left open — but the last few milliseconds need a different mechanism.
What would close it
Uniqueness enforced where the write happens, so that the loser of a race is rejected by the database rather than by a check that has already gone stale:
package_commands,package_standards,package_skills— names to confirm);space_idon the join rows, or an exclusion constraint are the candidates;Worth knowing before starting
UpdatePackageUseCasedeliberately does not check, so that a package holding an artefact shared with another one stays editable rather than becoming frozen. A database constraint would not make that distinction, so existing shared memberships have to be found and resolved before it can be applied — a migration question, not only a schema one.packages/integration-tests/src/package-removal-from-target.spec.ts,apps/cli-e2e-tests/src/packages-membership.spec.ts). They will need another route, or the scenario retired.Follow-up to #518.