Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
378a036
feat: guard batch segment deletion against concurrent usage changes
zackcl Sep 10, 2026
f2ae6b4
feat: add batch deletion eligibility endpoints
zackcl Sep 10, 2026
602b426
feat: add guarded batch deletion endpoints with per-item outcomes
zackcl Sep 10, 2026
c48a328
feat: add frontend batch selection and deletion state flows
zackcl Sep 10, 2026
40edc0c
feat: add batch deletion UI using cached selection state
zackcl Sep 10, 2026
78055da
feat: share deletion state guards between single and batch endpoints
zackcl Sep 11, 2026
0668c48
fix: align batch deletion with existing UI patterns and continue past…
zackcl Sep 11, 2026
0fa4e43
fix: use standard batch deletion error handling and keep selection us…
zackcl Sep 11, 2026
9a64209
refactor: remove obsolete batch deletion code and simplify regression…
zackcl Sep 11, 2026
ac76bfd
fix: preserve root table query behavior during batch deletion
zackcl Sep 11, 2026
9b74e33
fix: clear root table loading state when cancelling list requests
zackcl Sep 11, 2026
c929131
fix: allow selecting loaded rows while additional pages load
zackcl Sep 13, 2026
e0c4f9c
fix: avoid duplicate notifications after reconciliation HTTP failures
zackcl Sep 13, 2026
aa38ca1
refactor: remove deletion eligibility endpoints and frontend reconcil…
zackcl Sep 14, 2026
66ddd7e
fix: hide header selection checkboxes on empty tables
zackcl Sep 14, 2026
b3cfc95
test: remove redundant batch deletion checks and update rejection fix…
zackcl Sep 14, 2026
9c711e9
refactor: move batch segment lookup into its repository and clarify d…
zackcl Sep 14, 2026
11df68c
refactor: move deletion locking queries into the repository layer and…
zackcl Sep 14, 2026
1e28434
refactor: move experiment and flag deletion eligibility queries into …
zackcl Sep 14, 2026
f10d903
refactor: remove unused batch selection exports and redundant confirm…
zackcl Sep 14, 2026
4e0dd3f
fix: show existing table loading indicators during batch deletion
zackcl Sep 14, 2026
8f718fd
fix: retain server-reported deletion restrictions for selected items
zackcl Sep 14, 2026
027b4bd
refactor: simplify batch deletion feedback
zackcl Sep 15, 2026
82bc9d8
refactor: remove unused batch deletion type exports
zackcl Sep 15, 2026
b4b4e3c
fix: restore row selection after failed root table queries
zackcl Sep 15, 2026
cecafaa
fix: await experiment deletion audit logs in the deletion transaction
zackcl Sep 15, 2026
8ebe3f4
fix: preserve selection and detail views during batch deletion
zackcl Sep 15, 2026
c677a0c
fix: keep feature flag deletion audits in the deletion transaction
zackcl Sep 15, 2026
f3a1616
refactor: remove redundant batch deletion refreshes and unused outcomes
zackcl Sep 15, 2026
0c6926f
refactor: remove backend deletion eligibility guards
zackcl Sep 16, 2026
7fb56a0
fix: align experiment deletion rules and batch selection permissions
zackcl Sep 16, 2026
ea9f93e
docs: restore existing single-delete API descriptions
zackcl Sep 16, 2026
6dd78bf
fix: remove reactive batch selection invalidation
zackcl Sep 16, 2026
2ef23d2
refactor: align batch deletion service and test locations with existi…
zackcl Sep 16, 2026
3ae2020
fix: classify batch deletion notifications by confirmed results
zackcl Sep 16, 2026
7e2213d
test: trim redundant batch deletion cases and correct response fixtures
zackcl Sep 16, 2026
8afb8df
fix: refresh segments when filtering by tag
zackcl Sep 16, 2026
6e1bc92
fix: await segment recomputation between batch deletions
zackcl Sep 17, 2026
6e2802f
fix: stop batch deletion when releasing a skipped item fails
zackcl Sep 17, 2026
e335cd8
fix: avoid error logging for expected batch deletion skips
zackcl Sep 17, 2026
001d02d
fix: propagate batch budget reason to remaining items
zackcl Sep 17, 2026
d0dbcc9
fix: capture affected segment owners after acquiring deletion lock
zackcl Sep 17, 2026
55c5f8a
test: remove redundant batch HTTP error interceptor coverage
zackcl Sep 17, 2026
52bed39
fix: restore root table name spacing without batch checkboxes
zackcl Sep 17, 2026
1633c74
fix: align batch table headers with existing UI conventions
zackcl Sep 17, 2026
4604b0b
refactor: remove unused batch pagination actions and redundant pruning
zackcl Sep 17, 2026
a2ece1e
refactor: simplify segment batch recomputation options
zackcl Sep 17, 2026
c5d639e
fix: keep root table selection checkboxes visible
zackcl Sep 17, 2026
eea5736
fix: finish post-commit deletion work before reporting release failures
zackcl Sep 17, 2026
1f2bc88
fix: preserve errors from experiment list requests
zackcl Sep 17, 2026
131b90b
fix: reuse transaction connections throughout batch deletion
zackcl Sep 17, 2026
71a529f
fix: handle detail views during batch deletion
zackcl Sep 17, 2026
62bc061
fix: run segment cleanup after ambiguous batch commits
zackcl Sep 17, 2026
6e2ad68
fix: make batch ID validation linear and remove unused pagination sel…
zackcl Sep 17, 2026
e80eb99
fix: avoid argument limits when building batch deletion results
zackcl Sep 18, 2026
b76320c
perf: use set lookups for batch response validation and selection
zackcl Sep 18, 2026
6f92b8b
refactor: align batch deletion policies with existing single-delete APIs
zackcl Sep 18, 2026
4768e3f
perf: use set membership for batch row checkbox bindings
zackcl Sep 18, 2026
b4f2b7e
fix: align flag selection IDs with first-page reloads
zackcl Sep 18, 2026
22b278f
fix: treat a zero list offset as a replacement
zackcl Sep 18, 2026
42c935a
refactor: remove unused phase from batch deletion responses
zackcl Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions packages/backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ Segment inclusion/exclusion for **feature flags** is precomputed and stored flat

- **`FeatureFlagPrecomputedSegment` entity** (`src/api/models/FeatureFlagPrecomputedSegment.ts`) — one row per feature flag, columns: `featureFlagId` (PK), `inclusionIds: text[]`, `exclusionIds: text[]`. FK to `feature_flag` with `onDelete: CASCADE`.
- **`FeatureFlagPrecomputedSegmentService`** (`src/api/services/FeatureFlagPrecomputedSegmentService.ts`) — owns all computation and cache logic:
- `recomputeForFlag(flagId)` — flattens all enabled inclusion/exclusion segments (recursive sub-segments) into flat ID arrays and upserts the row. This is the only method that `await`s — callers on write paths never call it directly.
- `recomputeForFlag(flagId)` — flattens all enabled inclusion/exclusion segments (recursive sub-segments) into flat ID arrays and upserts the row. Imports and batch segment deletion await this method directly.
- `scheduleRecomputeForFlags(flagIds[])` — fire-and-forget recompute for a known set of flags (swallows/logs errors). The flag-side counterpart to `scheduleRecomputeForSegment`.
- `scheduleRecomputeForSegment(segmentId)` — fire-and-forget; finds all flags referencing a segment (and its parents) and recomputes each.
- `withRecompute(logger, resolveAffectedFlagIds, work)` — **the wrapper all top-level write methods use.** Resolves affected flag IDs *before* `work`, runs `work` (which must own/commit its own transaction), then fires a fire-and-forget recompute *after* commit. Keeps mutation + recompute in one call so a refactor can't drop the recompute. `work` is never blocked on the recompute.
- `withRecompute(logger, resolveAffectedFlagIds, work)` — **the wrapper for background recomputation on write paths.** Resolves affected flag IDs *before* `work`, runs `work` (which must own/commit its own transaction), then fires a fire-and-forget recompute *after* commit. Keeps mutation + recompute in one call so a refactor can't drop the recompute. `work` is never blocked on the recompute.
- `getAffectedFlagIds(segmentId)` — public helper that returns flag IDs affected by a given segment (used as the `resolveAffectedFlagIds` for segment deletes).
- `getPrecomputedSets(flagIds[])` — cache-wrapped batch fetch, returns a `Map<flagId, FeatureFlagPrecomputedSegment>`.
- `backfillMissingFlags(logger)` — called at startup; computes rows only for flags that have none yet (no-op once all flags are populated).
Expand All @@ -105,14 +105,14 @@ Segment inclusion/exclusion for **feature flags** is precomputed and stored flat
| Private list added to a shared segment | `SegmentService.addList` → `scheduleRecomputeForSegment` |
| Private list removed from a shared segment | `SegmentService.deleteList` → `scheduleRecomputeForSegment` |
| Segment members/structure updated | `SegmentService.addSegmentDataWithPipeline` → `scheduleRecomputeForSegment` |
| Segment deleted entirely | `SegmentService.deleteSegment` → `withRecompute` (collects affected flag IDs **before** the delete, recomputes **after** commit) |
| Segment deleted entirely | `SegmentService.deleteSegment` → `withRecompute` for single deletion; batch deletion collects affected flag IDs after the target lock and **before** deletion, then awaits recomputation **after** commit |
| Server startup | `app.ts` → `backfillMissingFlags` — backfills any flag with no row |

All recomputes triggered from write paths are **fire-and-forget** — no request handler (flag-side or segment-side) ever blocks on a recompute. The `import*` paths are the one exception: they `await recomputeForFlag` so "import complete" means the rows are ready.
Ordinary write paths use **fire-and-forget** recomputation. The `import*` paths await `recomputeForFlag` so "import complete" means the rows are ready. Batch segment deletion also awaits post-commit recomputation for both flags and experiments before processing the next segment. `SegmentService.deleteSegment` selects this awaited branch when supplied with the batch transaction executor; ordinary callers retain background recomputation.

### Key invariant

The `feature_flag_precomputed_segment` row must always be recomputed **after** the structural change commits, so the flat arrays reflect the new state. For deletions specifically, affected flag IDs must be collected **before** the delete because the join table records are gone afterward. Both halves of this invariant are enforced by `withRecompute` (resolve-before → work → recompute-after), so top-level write methods get the ordering for free rather than hand-rolling it.
The `feature_flag_precomputed_segment` row must always be recomputed **after** the structural change commits, so the flat arrays reflect the new state. For deletions specifically, affected flag IDs must be collected **before** the delete because the join table records are gone afterward. `withRecompute` enforces this ordering for background recomputation. The batch branch collects owners inside the supplied transaction, after the target lock is held, and waits for all post-commit updates to settle before propagating any failure.

## Precomputed Segment Lists (Experiments)

Expand Down Expand Up @@ -144,10 +144,10 @@ Experiment join tables (`ExperimentSegmentInclusion` / `ExperimentSegmentExclusi
| Experiment lists imported | `ExperimentService.importExperimentLists` → `await recomputeForExperiment` after the import transaction commits |
| Experiment context changed (deletes all its lists) | `ExperimentService.updateExperimentInDB` → `scheduleRecomputeForExperiments` after commit (recomputes to empty; `deleteAllListsFromExperiment` deletes the private segments directly, so the precomputed row would otherwise keep stale IDs). When a caller owns the transaction (`MoocletExperimentService.syncUpdate` / `syncUpdateWithMoocletAlgorithmTransition`), those methods recompute after their own commit — mirrors the flag side's `updateFeatureFlagInDB` → `withRecompute` |
| Shared segment members/structure changed | `SegmentService.addList` / `deleteList` / `addSegmentDataWithPipeline` → `scheduleRecomputeForSegment` for **both** the flag and experiment services |
| Segment deleted entirely | `SegmentService.deleteSegment` → collects affected experiment IDs **before** the delete, recomputes **after** commit (flags use `withRecompute` in the same method) |
| Segment deleted entirely | `SegmentService.deleteSegment` → collects affected experiment IDs **before** deletion and recomputes **after** commit; single deletion schedules background work, while batch deletion collects owners after the target lock and awaits recomputation |
| Server startup | `app.ts` → `backfillExperimentPrecomputedSegments` (guarded by `.catch` — a missing table never crashes startup) |

Same invariant as feature flags: recompute **after** the change commits; for deletes, collect affected experiment IDs **before**. All write-path recomputes are fire-and-forget except the `create` / import / Mooclet paths, which `await` so "done" means the row is ready.
Same invariant as feature flags: recompute **after** the change commits; for deletes, collect affected experiment IDs **before**. Write-path recomputes are fire-and-forget except the `create` / import / Mooclet paths and batch segment deletion, which await recomputation. A batch finishes each segment's post-commit updates before deleting the next segment.

### INCLUDE_ALL semantics (resolved)

Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/controllers/ExperimentController.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { BatchDeleteResult } from 'upgrade_types';
import { Inject } from 'typedi';
import { BatchDeleteService } from '../services/BatchDeleteService';
import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator';
import {
Body,
Get,
Expand Down Expand Up @@ -664,9 +668,43 @@ export class ExperimentController {
public moocletExperimentService: MoocletExperimentService,
public moocletRewardService: MoocletRewardsService,
public importExportService: ImportExportService,
public cacheService: CacheService
public cacheService: CacheService,
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService
) {}

/**
* @swagger
* /experiments/batch-delete:
* post:
* summary: Delete the selected experiments
* description: Deletes selected experiments sequentially regardless of state, skipping missing items. Execution failures stop the remaining items. Returns one result per ID.
* tags:
* - Experiments
* parameters:
* - in: body
* name: selection
* required: true
* schema:
* $ref: '#/definitions/BatchEntityIdsRequest'
* responses:
* '200':
* description: Per-ID deletion outcomes, including failures and items not attempted.
* schema:
* $ref: '#/definitions/BatchDeleteResult'
* '400':
* description: Expected a nonempty array of unique UUIDs.
* '401':
* description: AuthorizationRequiredError
*/
@Post('/batch-delete')
public batchDelete(
@Body({ validate: true }) { ids }: BatchEntityIdsValidator,
@CurrentUser() currentUser: UserDTO,
@Req() request: AppRequest
): Promise<BatchDeleteResult> {
return this.batchDeleteService.delete('experiments', ids, currentUser, request.logger);
}

/**
* @swagger
* /experiments/names:
Expand Down
43 changes: 42 additions & 1 deletion packages/backend/src/api/controllers/FeatureFlagController.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { BatchDeleteResult } from 'upgrade_types';
import { Inject } from 'typedi';
import { BatchDeleteService } from '../services/BatchDeleteService';
import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator';
import {
JsonController,
Authorized,
Expand Down Expand Up @@ -154,7 +158,44 @@ interface FeatureFlagsPaginationInfo extends PaginationResponse {
@Authorized()
@JsonController('/flags')
export class FeatureFlagsController {
constructor(public featureFlagService: FeatureFlagService, public experimentUserService: ExperimentUserService) {}
constructor(
public featureFlagService: FeatureFlagService,
public experimentUserService: ExperimentUserService,
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService
) {}

/**
* @swagger
* /flags/batch-delete:
* post:
* summary: Delete the selected flags
* description: Deletes selected feature flags sequentially regardless of status, skipping missing items. Execution failures stop the remaining items. Returns one result per ID.
* tags:
* - Feature Flags
* parameters:
* - in: body
* name: selection
* required: true
* schema:
* $ref: '#/definitions/BatchEntityIdsRequest'
* responses:
* '200':
* description: Per-ID deletion outcomes, including failures and items not attempted.
* schema:
* $ref: '#/definitions/BatchDeleteResult'
* '400':
* description: Expected a nonempty array of unique UUIDs.
* '401':
* description: AuthorizationRequiredError
*/
@Post('/batch-delete')
public batchDelete(
@Body({ validate: true }) { ids }: BatchEntityIdsValidator,
@CurrentUser() currentUser: UserDTO,
@Req() request: AppRequest
): Promise<BatchDeleteResult> {
return this.batchDeleteService.delete('flags', ids, currentUser, request.logger);
}

/**
* @swagger
Expand Down
76 changes: 75 additions & 1 deletion packages/backend/src/api/controllers/SegmentController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { UserDTO } from '../DTO/UserDTO';
import { BatchDeleteResult } from 'upgrade_types';
import { Inject } from 'typedi';
import { BatchDeleteService } from '../services/BatchDeleteService';
import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator';
import {
JsonController,
CurrentUser,
Get,
Delete,
Authorized,
Expand Down Expand Up @@ -49,6 +55,38 @@ interface SegmentPaginationInfo extends PaginationResponse {
/**
* @swagger
* definitions:
* BatchDeleteItemResult:
* type: object
* required: [id, outcome]
* properties:
* id:
* type: string
* format: uuid
* outcome:
* type: string
* enum: [deleted, not_found, failed, unknown, not_attempted]
* reasonCode:
* type: string
* enum: [not_found, delete_failed, lock_timeout, external_sync_failed, outcome_unknown, post_delete_failed]
* BatchDeleteResult:
* type: object
* required: [results]
* properties:
* results:
* type: array
* items:
* $ref: '#/definitions/BatchDeleteItemResult'
* BatchEntityIdsRequest:
* type: object
* required: [ids]
* properties:
* ids:
* type: array
* minItems: 1
* uniqueItems: true
* items:
* type: string
* format: uuid
* Segment:
* required:
* - name
Expand Down Expand Up @@ -233,7 +271,43 @@ interface SegmentPaginationInfo extends PaginationResponse {
@Authorized()
@JsonController('/segments')
export class SegmentController {
constructor(public segmentService: SegmentService) {}
constructor(
public segmentService: SegmentService,
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService
) {}

/**
* @swagger
* /segments/batch-delete:
* post:
* summary: Delete the selected segments
* description: Deletes selected segments sequentially using the existing deletion workflow, skipping missing items. Execution failures stop the remaining items. Returns one result per ID.
* tags:
* - Segment
* parameters:
* - in: body
* name: selection
* required: true
* schema:
* $ref: '#/definitions/BatchEntityIdsRequest'
* responses:
* '200':
* description: Per-ID deletion outcomes, including failures and items not attempted.
* schema:
* $ref: '#/definitions/BatchDeleteResult'
* '400':
* description: Expected a nonempty array of unique UUIDs.
* '401':
* description: AuthorizationRequiredError
*/
@Post('/batch-delete')
public batchDelete(
@Body({ validate: true }) { ids }: BatchEntityIdsValidator,
@CurrentUser() currentUser: UserDTO,
@Req() request: AppRequest
): Promise<BatchDeleteResult> {
return this.batchDeleteService.delete('segments', ids, currentUser, request.logger);
}

/**
* @swagger
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ArrayNotEmpty, IsArray, IsUUID, registerDecorator } from 'class-validator';
import { BatchEntityIdsRequest } from 'upgrade_types';

const HasUniqueIds = () => (object: object, propertyName: string) => {
registerDecorator({
name: 'arrayUnique',
target: object.constructor,
propertyName,
options: { message: "All $property's elements must be unique" },
validator: {
validate(value: unknown) {
if (!Array.isArray(value)) return false;
const normalizedIds = value.map((id: unknown) => (typeof id === 'string' ? id.toLowerCase() : id));
return new Set(normalizedIds).size === value.length;
},
},
});
};

export class BatchEntityIdsValidator implements BatchEntityIdsRequest {
@IsArray()
@ArrayNotEmpty()
@HasUniqueIds()
@IsUUID('all', { each: true })
Comment thread
zackcl marked this conversation as resolved.
public ids: string[];
}
25 changes: 25 additions & 0 deletions packages/backend/src/api/repositories/DeletionRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { EntityManager, Repository } from 'typeorm';
import { BatchDeleteEntity } from 'upgrade_types';
import { EntityRepository } from '../../typeorm-typedi-extensions';
import { Experiment } from '../models/Experiment';
import { FeatureFlag } from '../models/FeatureFlag';
import { Segment } from '../models/Segment';
import repositoryError from './utils/repositoryError';

/** All reads and locks use the caller's deletion transaction, never the repository's default manager. */
@EntityRepository()
export class DeletionRepository extends Repository<DeletionRepository> {
public async findForDeletion(
entity: BatchDeleteEntity,
id: string,
manager: EntityManager
): Promise<{ id: string } | null> {
const target = { experiments: Experiment, flags: FeatureFlag, segments: Segment }[entity];
return manager
.getRepository<{ id: string }>(target)
.findOne({ where: { id }, select: { id: true }, lock: { mode: 'pessimistic_write' } })
.catch((errorMsg: any) => {
throw repositoryError('DeletionRepository', 'findForDeletion', { entity, id }, errorMsg);
});
}
}
Loading
Loading