@classytic/repo-core 0.14.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +2 -0
- package/dist/cleanup/define-purge-step.d.mts +76 -0
- package/dist/cleanup/define-purge-step.mjs +107 -0
- package/dist/cleanup/index.d.mts +3 -0
- package/dist/cleanup/index.mjs +2 -0
- package/dist/cleanup/types.d.mts +167 -0
- package/dist/repository/capabilities.d.mts +9 -0
- package/dist/repository/purge.d.mts +40 -4
- package/dist/repository/types.d.mts +42 -4
- package/dist/tenant/index.d.mts +2 -2
- package/dist/tenant/index.mjs +2 -2
- package/dist/tenant/resolve.d.mts +17 -1
- package/dist/tenant/resolve.mjs +21 -1
- package/dist/testing/index.d.mts +2 -1
- package/dist/testing/index.mjs +2 -1
- package/dist/testing/purge-conformance.d.mts +33 -0
- package/dist/testing/purge-conformance.mjs +160 -0
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,115 @@ All notable changes to `@classytic/repo-core` are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.18.0] - 2026-07-27
|
|
8
|
+
|
|
9
|
+
### Added — `definePurgeStep` builder (`./cleanup`) and `resolveTenantField` (`./tenant`)
|
|
10
|
+
|
|
11
|
+
#### `./cleanup` — `definePurgeStep`
|
|
12
|
+
|
|
13
|
+
- **`definePurgeStep(repository, spec)`** — standard builder for the most common
|
|
14
|
+
`CleanupStep` shape: a chunked `purgeByField` over a single scope value.
|
|
15
|
+
Nearly every provider step in a domain kernel is this pattern; the builder owns
|
|
16
|
+
the invariant core so callers only declare what differs:
|
|
17
|
+
- **fail-closed scoping** — missing scope value or unavailable repository is a
|
|
18
|
+
`BLOCKER` (not a silent no-op and never an unscoped purge that would hit every
|
|
19
|
+
tenant); blocked by `SCOPE_REQUIRED:<param>` / `REPOSITORY_UNAVAILABLE:<id>`.
|
|
20
|
+
- **cancellation** — `throwIfCancelled` is called before work starts and the
|
|
21
|
+
`signal` is threaded to the kit so a cancel lands between committed chunks.
|
|
22
|
+
- **honest failure** — a failing purge returns `ok: false` so the recipe composer
|
|
23
|
+
stops (retention §8); thrown errors are caught and reported, never swallowed.
|
|
24
|
+
- **verification** — absence is re-queried after the run via `countDocuments`
|
|
25
|
+
(`spec.verifyFilter` for steps whose match filter differs from the absence proof).
|
|
26
|
+
- **`PurgeStepSpec`** — declaration object: `id`, `resource`, `parameter`, `field`,
|
|
27
|
+
`strategy`, optional `retained`, `warnings`, `batchSize`, `guard(scope, ctx)`,
|
|
28
|
+
`verifyFilter(scope)`, `verifyName`.
|
|
29
|
+
- **`PurgeStepRepository`** — structural subset of `StandardRepo` satisfied by any
|
|
30
|
+
kit repository that implements `purgeByField` + `countDocuments`.
|
|
31
|
+
- **`SCOPE_REQUIRED`** / **`REPOSITORY_UNAVAILABLE`** — blocker-code prefix constants.
|
|
32
|
+
|
|
33
|
+
#### `./tenant` — `resolveTenantField`
|
|
34
|
+
|
|
35
|
+
- **`resolveTenantField(config?)`** — returns the single `tenantField` string a
|
|
36
|
+
resource layer needs for `defineResource({ tenantField })`, or `false` when the
|
|
37
|
+
config disables tenant scoping (`false` / `{ enabled: false }` / `{ strategy: 'none' }`).
|
|
38
|
+
Four spine modules hand-rolled equivalent logic independently, each re-deriving
|
|
39
|
+
`'organizationId'` as the default; one copy omitted the disable branch, making
|
|
40
|
+
that package silently un-configurable. This function is the single definition.
|
|
41
|
+
|
|
42
|
+
Both additions are purely additive — no breaking changes.
|
|
43
|
+
|
|
44
|
+
## [0.17.0] - 2026-07-25
|
|
45
|
+
|
|
46
|
+
### Added — `./cleanup`: framework-free cleanup provider step contract
|
|
47
|
+
|
|
48
|
+
- **`@classytic/repo-core/cleanup`** — new subpath (pure types, no runtime).
|
|
49
|
+
Domain kernels (`@classytic/flow`, `@classytic/order`, `@classytic/facts`,
|
|
50
|
+
`@classytic/ledger`, …) export `CleanupStep[]` for the data they own; a host
|
|
51
|
+
framework (`@classytic/arc/cleanup`) folds them into a Cleanup Center recipe.
|
|
52
|
+
The contract lives in repo-core so kernels stay framework-free: kernels already
|
|
53
|
+
depend on repo-core (the chunked-purge mechanics) and MUST NOT depend on arc.
|
|
54
|
+
- **`CleanupStep`** — the core provider interface: `id`, `resource`, `destructive`,
|
|
55
|
+
optional `rebuildActions`; and three lifecycle methods: `estimate(ctx)` (preview
|
|
56
|
+
without mutating), `execute(ctx)` (chunked, idempotent, cancellation-aware),
|
|
57
|
+
and optional `verify(ctx)` (post-checks — a delete count alone is never success).
|
|
58
|
+
- **Supporting types**: `CleanupStepContext` (injected `now`, `signal`, `ambient`,
|
|
59
|
+
`parameters`, `logger`), `CleanupStepExecuteContext` (adds `onProgress` +
|
|
60
|
+
`throwIfCancelled`), `CleanupStepEstimate` (row count, `retained`, `blockers`,
|
|
61
|
+
`warnings`), `CleanupStepOutcome` (`processed`, `ok`, `error`, `cursor`),
|
|
62
|
+
`CleanupStepCheck` (`name`, `ok`, `detail`), `CleanupStepProgress`, `CleanupStepLogger`.
|
|
63
|
+
|
|
64
|
+
Purely additive — zero runtime code, type-only subpath export.
|
|
65
|
+
|
|
66
|
+
## [0.16.0] - 2026-07-24
|
|
67
|
+
|
|
68
|
+
### Added — `runPurgeConformance`: cross-kit chunked-purge contract suite
|
|
69
|
+
|
|
70
|
+
- **`runPurgeConformance(harness)`** (from `@classytic/repo-core/testing`) —
|
|
71
|
+
proves a kit's purge port makes **stable progress for every strategy** when
|
|
72
|
+
the match set exceeds `batchSize`. Every scenario seeds more rows than one
|
|
73
|
+
batch; a chunk budget converts a non-progressing port's infinite loop into a
|
|
74
|
+
crisp assertion failure. Scenarios: hard drain, soft multi-batch WITHOUT
|
|
75
|
+
caller-supplied exclusion predicates, anonymize static + function-form,
|
|
76
|
+
exact-batch boundary, skip, empty scope, abort-between-chunks.
|
|
77
|
+
- **`PurgePort` progression contract (documented, mandatory)** — successive
|
|
78
|
+
`purgeChunk` calls MUST advance through the match set for every strategy via
|
|
79
|
+
stable keyset progression (`pk > lastSeen`, advanced only after the chunk's
|
|
80
|
+
write succeeds). Re-running the bare predicate only self-advances for
|
|
81
|
+
`hard`; `soft`/`anonymize` re-select the same first chunk forever. Offsets
|
|
82
|
+
are explicitly ruled out. A port instance is single-run state.
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- `purgeByField` docstring no longer claims soft/anonymized rows "simply don't
|
|
87
|
+
match the next pass" — they generally DO still match; idempotency holds by
|
|
88
|
+
outcome convergence, and within-run progression is the port's keyset
|
|
89
|
+
responsibility.
|
|
90
|
+
|
|
91
|
+
## [0.15.0] - 2026-07-24
|
|
92
|
+
|
|
93
|
+
### Added — `purgeByFilter`: range/filter-scoped purge + anonymize
|
|
94
|
+
|
|
95
|
+
- **`StandardRepo.purgeByFilter?(filter, strategy, options)`** — the
|
|
96
|
+
range/filter-scoped sibling of `purgeByField`. Where `purgeByField` matches
|
|
97
|
+
a single `field = value` equality, this optional method takes the full
|
|
98
|
+
portable `FilterInput` (Filter IR or a plain kit-native record) and runs any
|
|
99
|
+
`TenantPurgeStrategy` (`hard` / `soft` / `anonymize` / `skip`) over the
|
|
100
|
+
matched slice. THE compliance primitive for "purge/anonymize a dimension
|
|
101
|
+
across a RANGE while RETAINING measures" — redact a PII column across a
|
|
102
|
+
`civilDate` window, hard-delete rows past a retention cutoff, soft-delete a
|
|
103
|
+
compound cohort. Returns the same `TenantPurgeResult` envelope; chunking,
|
|
104
|
+
index requirement, idempotency, plugin composition, and narrowed-write
|
|
105
|
+
re-assertion are identical to `purgeByField`. Gate on the new
|
|
106
|
+
`capabilities.purgeByFilter`.
|
|
107
|
+
- **`RepoCapabilities.purgeByFilter?: boolean`** — feature-detection flag,
|
|
108
|
+
mirroring `purgeByField`.
|
|
109
|
+
- **`PurgePort` doc** clarifies the two bound-predicate forms (equality-bound
|
|
110
|
+
vs filter-bound) both satisfy the single port interface, so `runChunkedPurge`
|
|
111
|
+
drives both unchanged.
|
|
112
|
+
|
|
113
|
+
Strictly additive: `purgeByField`, `TenantPurgeStrategy`, and `runChunkedPurge`
|
|
114
|
+
are unchanged; both new members are optional.
|
|
115
|
+
|
|
7
116
|
## [0.14.0] - 2026-07-16
|
|
8
117
|
|
|
9
118
|
### Added — canonical `matchesRecordFilter` (the `DataAdapter.matchesFilter` home)
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @classytic/repo-core
|
|
2
2
|
|
|
3
|
+
[](https://github.com/sponsors/classytic)
|
|
4
|
+
|
|
3
5
|
**Driver-agnostic repository primitives.** Hooks, Filter IR, operations registry, pagination, URL query parsing, cache contract — the shared foundation for `@classytic/mongokit`, `@classytic/sqlitekit`, and future `@classytic/pgkit` / `@classytic/prismakit`.
|
|
4
6
|
|
|
5
7
|
Repo-core is **infrastructure for kit authors.** End-users install a kit (mongokit / sqlitekit) and import their full API from that one namespace. Repo-core is what each kit's runtime is built on — you typically won't import it directly in application code.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { TenantPurgeOptions, TenantPurgeStrategy } from "../repository/types.mjs";
|
|
2
|
+
import { CleanupStep, CleanupStepContext } from "./types.mjs";
|
|
3
|
+
//#region src/cleanup/define-purge-step.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The repository surface a purge step needs — a structural subset of
|
|
6
|
+
* `StandardRepo`, so any kit repository satisfies it without an adapter.
|
|
7
|
+
* Both members are optional so an engine that never wired the repository
|
|
8
|
+
* yields a BLOCKER rather than a crash.
|
|
9
|
+
*/
|
|
10
|
+
interface PurgeStepRepository {
|
|
11
|
+
purgeByField?(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<{
|
|
12
|
+
processed: number;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
error?: {
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
}>;
|
|
18
|
+
countDocuments?(filter: Record<string, unknown>): Promise<number>;
|
|
19
|
+
}
|
|
20
|
+
interface PurgeStepSpec {
|
|
21
|
+
/** Stable machine id, unique within a recipe. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** Business record class for the preview (e.g. `'CRM contacts'`). */
|
|
24
|
+
resource: string;
|
|
25
|
+
/**
|
|
26
|
+
* Run-parameter key carrying the scope value (e.g. `'subjectId'`,
|
|
27
|
+
* `'organizationId'`). Read from `ctx.parameters` at plan AND execute time,
|
|
28
|
+
* so a retry replays the value the operator actually confirmed.
|
|
29
|
+
*/
|
|
30
|
+
parameter: string;
|
|
31
|
+
/** Document field matched against the parameter's value. */
|
|
32
|
+
field: string;
|
|
33
|
+
/** What to do with matched rows. */
|
|
34
|
+
strategy: TenantPurgeStrategy;
|
|
35
|
+
/** What survives — surfaced in the preview. */
|
|
36
|
+
retained?: string;
|
|
37
|
+
/** Non-blocking operator warnings. */
|
|
38
|
+
warnings?: readonly string[];
|
|
39
|
+
/** Rows per chunk. Kit default when omitted. */
|
|
40
|
+
batchSize?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Domain blockers beyond the built-in scope/availability checks (e.g.
|
|
43
|
+
* `'OPEN_CHECKOUTS:3'`). A non-empty result is a HARD STOP.
|
|
44
|
+
*/
|
|
45
|
+
guard?: (scope: string, ctx: CleanupStepContext) => Promise<readonly string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Filter proving absence, when it differs from the match filter — e.g. an
|
|
48
|
+
* anonymize keyed on `_id` leaves the row in place, so absence must be
|
|
49
|
+
* proven by querying the redacted identifier instead.
|
|
50
|
+
*/
|
|
51
|
+
verifyFilter?: (scope: string) => Record<string, unknown>;
|
|
52
|
+
/** Overrides the generated check name. */
|
|
53
|
+
verifyName?: string;
|
|
54
|
+
}
|
|
55
|
+
/** Prefix for the blocker raised when the run carries no scope value. */
|
|
56
|
+
declare const SCOPE_REQUIRED = "CLEANUP_SCOPE_REQUIRED";
|
|
57
|
+
/** Prefix for the blocker raised when the repository cannot be reached. */
|
|
58
|
+
declare const REPOSITORY_UNAVAILABLE = "CLEANUP_REPOSITORY_UNAVAILABLE";
|
|
59
|
+
/**
|
|
60
|
+
* Build a `CleanupStep` from a repository + declaration.
|
|
61
|
+
*
|
|
62
|
+
* Invariants this owns, so no caller can get them wrong:
|
|
63
|
+
* - **fail-closed scoping** — no scope value, or no repository, is a
|
|
64
|
+
* BLOCKER, never a silent no-op and never an unscoped purge (which would
|
|
65
|
+
* hit every tenant);
|
|
66
|
+
* - **cancellation** — checked before work starts and threaded to the kit so
|
|
67
|
+
* a cancel lands between committed chunks, never mid-write;
|
|
68
|
+
* - **honest failure** — a failing purge returns `ok: false` so the composer
|
|
69
|
+
* stops the recipe (retention §8), and a thrown error is reported, never
|
|
70
|
+
* swallowed;
|
|
71
|
+
* - **verification** — absence is re-queried after the run, because a
|
|
72
|
+
* processed count alone is never proof (§9).
|
|
73
|
+
*/
|
|
74
|
+
declare function definePurgeStep(repository: PurgeStepRepository | undefined, spec: PurgeStepSpec): CleanupStep;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { PurgeStepRepository, PurgeStepSpec, REPOSITORY_UNAVAILABLE, SCOPE_REQUIRED, definePurgeStep };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//#region src/cleanup/define-purge-step.ts
|
|
2
|
+
/** Prefix for the blocker raised when the run carries no scope value. */
|
|
3
|
+
const SCOPE_REQUIRED = "CLEANUP_SCOPE_REQUIRED";
|
|
4
|
+
/** Prefix for the blocker raised when the repository cannot be reached. */
|
|
5
|
+
const REPOSITORY_UNAVAILABLE = "CLEANUP_REPOSITORY_UNAVAILABLE";
|
|
6
|
+
function scopeOf(ctx, parameter) {
|
|
7
|
+
const raw = ctx.parameters?.[parameter];
|
|
8
|
+
return typeof raw === "string" && raw.length > 0 ? raw : void 0;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build a `CleanupStep` from a repository + declaration.
|
|
12
|
+
*
|
|
13
|
+
* Invariants this owns, so no caller can get them wrong:
|
|
14
|
+
* - **fail-closed scoping** — no scope value, or no repository, is a
|
|
15
|
+
* BLOCKER, never a silent no-op and never an unscoped purge (which would
|
|
16
|
+
* hit every tenant);
|
|
17
|
+
* - **cancellation** — checked before work starts and threaded to the kit so
|
|
18
|
+
* a cancel lands between committed chunks, never mid-write;
|
|
19
|
+
* - **honest failure** — a failing purge returns `ok: false` so the composer
|
|
20
|
+
* stops the recipe (retention §8), and a thrown error is reported, never
|
|
21
|
+
* swallowed;
|
|
22
|
+
* - **verification** — absence is re-queried after the run, because a
|
|
23
|
+
* processed count alone is never proof (§9).
|
|
24
|
+
*/
|
|
25
|
+
function definePurgeStep(repository, spec) {
|
|
26
|
+
const destructive = spec.strategy.type !== "skip";
|
|
27
|
+
const verifyName = spec.verifyName ?? `${spec.id}.verified`;
|
|
28
|
+
const filterFor = (scope) => spec.verifyFilter?.(scope) ?? { [spec.field]: scope };
|
|
29
|
+
return {
|
|
30
|
+
id: spec.id,
|
|
31
|
+
resource: spec.resource,
|
|
32
|
+
destructive,
|
|
33
|
+
async estimate(ctx) {
|
|
34
|
+
const scope = scopeOf(ctx, spec.parameter);
|
|
35
|
+
if (!scope) return {
|
|
36
|
+
resource: spec.resource,
|
|
37
|
+
estimated: 0,
|
|
38
|
+
blockers: [`${SCOPE_REQUIRED}:${spec.parameter}`]
|
|
39
|
+
};
|
|
40
|
+
if (!repository?.purgeByField) return {
|
|
41
|
+
resource: spec.resource,
|
|
42
|
+
estimated: 0,
|
|
43
|
+
blockers: [`${REPOSITORY_UNAVAILABLE}:${spec.id}`]
|
|
44
|
+
};
|
|
45
|
+
const estimated = await repository.countDocuments?.({ [spec.field]: scope }) ?? 0;
|
|
46
|
+
const blockers = await spec.guard?.(scope, ctx) ?? [];
|
|
47
|
+
return {
|
|
48
|
+
resource: spec.resource,
|
|
49
|
+
estimated,
|
|
50
|
+
...spec.retained === void 0 ? {} : { retained: spec.retained },
|
|
51
|
+
...blockers.length > 0 ? { blockers: [...blockers] } : {},
|
|
52
|
+
...spec.warnings === void 0 ? {} : { warnings: [...spec.warnings] }
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
async execute(ctx) {
|
|
56
|
+
await ctx.throwIfCancelled?.();
|
|
57
|
+
const scope = scopeOf(ctx, spec.parameter);
|
|
58
|
+
if (!scope || !repository?.purgeByField) return {
|
|
59
|
+
resource: spec.resource,
|
|
60
|
+
processed: 0,
|
|
61
|
+
ok: false,
|
|
62
|
+
error: scope ? `repository for '${spec.id}' is unavailable` : `missing run parameter '${spec.parameter}'`
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
const result = await repository.purgeByField(spec.field, scope, spec.strategy, {
|
|
66
|
+
...spec.batchSize === void 0 ? {} : { batchSize: spec.batchSize },
|
|
67
|
+
...ctx.signal ? { signal: ctx.signal } : {},
|
|
68
|
+
onProgress: async (event) => {
|
|
69
|
+
await ctx.onProgress?.({
|
|
70
|
+
resource: spec.resource,
|
|
71
|
+
processed: event.processed
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
resource: spec.resource,
|
|
77
|
+
processed: result.processed,
|
|
78
|
+
ok: result.ok,
|
|
79
|
+
...result.ok ? {} : { error: result.error?.message ?? "purge reported failure" }
|
|
80
|
+
};
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return {
|
|
83
|
+
resource: spec.resource,
|
|
84
|
+
processed: 0,
|
|
85
|
+
ok: false,
|
|
86
|
+
error: error instanceof Error ? error.message : String(error)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async verify(ctx) {
|
|
91
|
+
const scope = scopeOf(ctx, spec.parameter);
|
|
92
|
+
if (!scope || !repository?.countDocuments) return [{
|
|
93
|
+
name: verifyName,
|
|
94
|
+
ok: false,
|
|
95
|
+
detail: "could not verify — no scope or no count support"
|
|
96
|
+
}];
|
|
97
|
+
const remaining = await repository.countDocuments(filterFor(scope));
|
|
98
|
+
return [{
|
|
99
|
+
name: verifyName,
|
|
100
|
+
ok: remaining === 0,
|
|
101
|
+
detail: remaining === 0 ? `no ${spec.resource} still match the scope` : `${remaining} row(s) still match — cleanup incomplete`
|
|
102
|
+
}];
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
export { REPOSITORY_UNAVAILABLE, SCOPE_REQUIRED, definePurgeStep };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { CleanupStep, CleanupStepCheck, CleanupStepContext, CleanupStepEstimate, CleanupStepExecuteContext, CleanupStepLogger, CleanupStepOutcome, CleanupStepProgress } from "./types.mjs";
|
|
2
|
+
import { PurgeStepRepository, PurgeStepSpec, REPOSITORY_UNAVAILABLE, SCOPE_REQUIRED, definePurgeStep } from "./define-purge-step.mjs";
|
|
3
|
+
export { type CleanupStep, type CleanupStepCheck, type CleanupStepContext, type CleanupStepEstimate, type CleanupStepExecuteContext, type CleanupStepLogger, type CleanupStepOutcome, type CleanupStepProgress, type PurgeStepRepository, type PurgeStepSpec, REPOSITORY_UNAVAILABLE, SCOPE_REQUIRED, definePurgeStep };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
//#region src/cleanup/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@classytic/repo-core/cleanup` — the framework-free **cleanup provider step**
|
|
4
|
+
* contract (data-cleanup / retention design §6.3, §6.6).
|
|
5
|
+
*
|
|
6
|
+
* A domain kernel (`@classytic/flow`, `@classytic/order`, `@classytic/facts`,
|
|
7
|
+
* `@classytic/ledger`, …) exports one or more `CleanupStep`s for the data it
|
|
8
|
+
* owns — the unit retention.md §6.6 calls "cleanup recipe steps." A host
|
|
9
|
+
* (`be-prod`) composes ordered steps into a full Cleanup Center recipe.
|
|
10
|
+
*
|
|
11
|
+
* This contract lives in repo-core — NOT in `@classytic/arc` — on purpose:
|
|
12
|
+
*
|
|
13
|
+
* - Kernels already depend on repo-core (it owns the chunked purge mechanics,
|
|
14
|
+
* §6.3) and MUST stay free of any host-framework dependency. A kernel that
|
|
15
|
+
* imported `@classytic/arc/cleanup` would invert the layering.
|
|
16
|
+
* - Arc's Cleanup Center framework (`@classytic/arc/cleanup`) imports THIS
|
|
17
|
+
* contract and folds `CleanupStep[]` into an arc `CleanupRecipe` via its
|
|
18
|
+
* `recipeFromSteps()` composer. One shape, two consumers, zero cycle.
|
|
19
|
+
*
|
|
20
|
+
* A step is a PURE PROVIDER: it knows only how to estimate / execute / verify a
|
|
21
|
+
* slice of its own data. It never authorizes a superadmin, reads go-live state,
|
|
22
|
+
* speaks HTTP, or opens a Mongo transaction the host didn't hand it. It reuses
|
|
23
|
+
* repo-core's own chunked-purge envelope (`TenantPurgeResult` / progress) under
|
|
24
|
+
* the hood; this contract is the composable wrapper around that envelope.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Framework-free ambient a cleanup step reads. A structural SUBSET of any host
|
|
28
|
+
* cleanup context (e.g. arc's `CleanupContext`), so passing the host context
|
|
29
|
+
* straight through type-checks — no adapter object required.
|
|
30
|
+
*/
|
|
31
|
+
interface CleanupStepContext {
|
|
32
|
+
/** Injected clock — steps never call `new Date()` directly (testability). */
|
|
33
|
+
readonly now: Date;
|
|
34
|
+
/** Cooperative cancellation — observed between chunks. */
|
|
35
|
+
readonly signal?: AbortSignal | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Opaque host-provided ambient scope (resolved company/branch, feature
|
|
38
|
+
* gates, …). MUST be JSON-serializable — a host may persist it on a durable
|
|
39
|
+
* run so a worker in another process rebuilds the exact operation context.
|
|
40
|
+
* A step reads only what it declared it needs; nobody else inspects it.
|
|
41
|
+
*/
|
|
42
|
+
readonly ambient?: Readonly<Record<string, unknown>> | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Operator-supplied recipe parameters (branch id, created-before date, module
|
|
45
|
+
* set, …). Opaque + host-validated at the edge; a step reads/parses the keys
|
|
46
|
+
* it declares. Recipes are boot-time singletons, so parameters arrive HERE at
|
|
47
|
+
* plan/execute time rather than being closed over at construction. The host
|
|
48
|
+
* composer threads the plan's sealed parameters through unchanged, so a
|
|
49
|
+
* worker replays the exact same op.
|
|
50
|
+
*/
|
|
51
|
+
readonly parameters?: Readonly<Record<string, unknown>> | undefined;
|
|
52
|
+
/** Optional structured logger. */
|
|
53
|
+
readonly logger?: CleanupStepLogger | undefined;
|
|
54
|
+
}
|
|
55
|
+
interface CleanupStepLogger {
|
|
56
|
+
info(msg: string, meta?: Record<string, unknown>): void;
|
|
57
|
+
warn(msg: string, meta?: Record<string, unknown>): void;
|
|
58
|
+
error(msg: string, meta?: Record<string, unknown>): void;
|
|
59
|
+
}
|
|
60
|
+
/** One committed chunk's progress — folded into the host run's bounded summary. */
|
|
61
|
+
interface CleanupStepProgress {
|
|
62
|
+
readonly resource: string;
|
|
63
|
+
/** Cumulative rows processed by THIS step so far. */
|
|
64
|
+
readonly processed: number;
|
|
65
|
+
/** Opaque resume cursor (keyset position) for observability. */
|
|
66
|
+
readonly cursor?: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Execution-time context — adds progress reporting + a durable-cancellation
|
|
70
|
+
* probe. Both are optional so a step is trivially runnable in a unit test with
|
|
71
|
+
* a bare `CleanupStepContext`.
|
|
72
|
+
*/
|
|
73
|
+
interface CleanupStepExecuteContext extends CleanupStepContext {
|
|
74
|
+
/** Report one committed chunk. Call AFTER the chunk's write commits. */
|
|
75
|
+
onProgress?(update: CleanupStepProgress): void | Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Throw the host's cancellation error if a cancel was requested for this run.
|
|
78
|
+
* Cheap to call between chunks; backed by the host's durable `cancelRequested`
|
|
79
|
+
* flag (source of truth) plus the in-process `signal`. Absent in a bare test
|
|
80
|
+
* context — treat as "never cancelled."
|
|
81
|
+
*/
|
|
82
|
+
throwIfCancelled?(): void | Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* One preview line — maps 1:1 onto a host plan item. A business record class
|
|
86
|
+
* (`'sales facts'`, `'journal entries'`), never a collection name.
|
|
87
|
+
*/
|
|
88
|
+
interface CleanupStepEstimate {
|
|
89
|
+
readonly resource: string;
|
|
90
|
+
/** Estimated records this step would affect. */
|
|
91
|
+
readonly estimated: number;
|
|
92
|
+
/** What this step RETAINS (e.g. `'measures kept, PII redacted'`). */
|
|
93
|
+
readonly retained?: string | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Domain blockers preventing this step (e.g. `'POSTED_BOOKS_IMMUTABLE'`,
|
|
96
|
+
* `'OPEN_TRANSFER'`). A non-empty list is a HARD STOP — the host refuses to
|
|
97
|
+
* execute until the operator resolves it. Blockers are DOMAIN facts, never a
|
|
98
|
+
* permission decision (that is the host's job).
|
|
99
|
+
*/
|
|
100
|
+
readonly blockers?: readonly string[] | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Non-blocking warnings surfaced to the operator (e.g. "rebuild may take
|
|
103
|
+
* several minutes on this generation").
|
|
104
|
+
*/
|
|
105
|
+
readonly warnings?: readonly string[] | undefined;
|
|
106
|
+
}
|
|
107
|
+
/** One executed step's outcome — maps onto a host step result. */
|
|
108
|
+
interface CleanupStepOutcome {
|
|
109
|
+
readonly resource: string;
|
|
110
|
+
/** Rows this step actually processed (deleted / redacted / rebuilt). */
|
|
111
|
+
readonly processed: number;
|
|
112
|
+
/**
|
|
113
|
+
* `false` iff the step failed. The host composer STOPS the recipe on the
|
|
114
|
+
* first `ok: false` (retention §8: never return success on a provider
|
|
115
|
+
* failure) and marks the run `failed`/`partial`. A step MUST NOT swallow a
|
|
116
|
+
* failure and report `ok: true`.
|
|
117
|
+
*/
|
|
118
|
+
readonly ok: boolean;
|
|
119
|
+
/** Failure message when `ok: false`. */
|
|
120
|
+
readonly error?: string | undefined;
|
|
121
|
+
/** Opaque resume cursor for observability. */
|
|
122
|
+
readonly cursor?: string | undefined;
|
|
123
|
+
}
|
|
124
|
+
/** One post-check — maps onto a host verification check (§9). */
|
|
125
|
+
interface CleanupStepCheck {
|
|
126
|
+
readonly name: string;
|
|
127
|
+
readonly ok: boolean;
|
|
128
|
+
readonly detail?: string | undefined;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A cleanup PROVIDER STEP — the unit a domain kernel exports for data it owns
|
|
132
|
+
* (retention design §6.6). Composed by the host into an ordered recipe.
|
|
133
|
+
*
|
|
134
|
+
* Idempotency + chunking are the step's responsibility: `execute` must be safe
|
|
135
|
+
* to re-run after a crash/retry (keyset progression, dedupe-by-occurrence, or a
|
|
136
|
+
* naturally-idempotent rebuild), and must observe `signal` / `throwIfCancelled`
|
|
137
|
+
* between chunks so a cancel lands between committed batches, never mid-write.
|
|
138
|
+
*/
|
|
139
|
+
interface CleanupStep {
|
|
140
|
+
/** Stable machine id, unique within a recipe. */
|
|
141
|
+
readonly id: string;
|
|
142
|
+
/** Logical resource label for the preview (e.g. `'sales facts'`). */
|
|
143
|
+
readonly resource: string;
|
|
144
|
+
/**
|
|
145
|
+
* `true` if the step deletes/redacts data; `false` for a pure rebuild
|
|
146
|
+
* (§4.3 — rebuilding a projection is not destructive to source data). A
|
|
147
|
+
* recipe is destructive iff ANY of its steps is.
|
|
148
|
+
*/
|
|
149
|
+
readonly destructive: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Projection / scaffolding rebuilds this step performs AFTER its cleanup —
|
|
152
|
+
* surfaced in the preview's `rebuildActions` (e.g. `'rebuild sales rollup'`).
|
|
153
|
+
*/
|
|
154
|
+
readonly rebuildActions?: readonly string[] | undefined;
|
|
155
|
+
/** Preview WITHOUT mutating. Idempotent + side-effect-free. */
|
|
156
|
+
estimate(ctx: CleanupStepContext): Promise<CleanupStepEstimate>;
|
|
157
|
+
/** Do the work. Chunked, idempotent, cancellation-aware. */
|
|
158
|
+
execute(ctx: CleanupStepExecuteContext): Promise<CleanupStepOutcome>;
|
|
159
|
+
/**
|
|
160
|
+
* Post-checks — the step owns what "clean" means for its data (§9: a delete
|
|
161
|
+
* count alone is never success). Optional: a pure-rebuild step may skip it,
|
|
162
|
+
* though verifying the watermark/counts is strongly encouraged.
|
|
163
|
+
*/
|
|
164
|
+
verify?(ctx: CleanupStepContext): Promise<readonly CleanupStepCheck[]>;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
export { CleanupStep, CleanupStepCheck, CleanupStepContext, CleanupStepEstimate, CleanupStepExecuteContext, CleanupStepLogger, CleanupStepOutcome, CleanupStepProgress };
|
|
@@ -126,6 +126,15 @@ interface RepoCapabilities {
|
|
|
126
126
|
* tenant cleanup primitive.
|
|
127
127
|
*/
|
|
128
128
|
purgeByField?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* `purgeByFilter(filter, strategy, options)` — range/filter-scoped
|
|
131
|
+
* variant of `purgeByField`. Processes rows matching an arbitrary
|
|
132
|
+
* compiled filter (a `civilDate` window, a retention cutoff, a compound
|
|
133
|
+
* cohort) rather than a single `field = value` equality — the GDPR /
|
|
134
|
+
* retention "anonymize a slice across a RANGE while retaining measures"
|
|
135
|
+
* primitive.
|
|
136
|
+
*/
|
|
137
|
+
purgeByFilter?: boolean;
|
|
129
138
|
/**
|
|
130
139
|
* `archiveByFilter(filter, sink, options)` — chunked cold-storage
|
|
131
140
|
* extraction (write-before-delete, at-least-once). The data-lifecycle
|
|
@@ -12,11 +12,30 @@ type WritingPurgeStrategy = Exclude<TenantPurgeStrategy, {
|
|
|
12
12
|
* Driver-facing port the orchestrator drives. Each kit implements one
|
|
13
13
|
* closure over its driver primitives + the purge predicate.
|
|
14
14
|
*
|
|
15
|
+
* **Bound-predicate forms.** The port binds its selection predicate
|
|
16
|
+
* internally — the orchestrator never sees it. Two factory shapes both
|
|
17
|
+
* satisfy this single interface:
|
|
18
|
+
*
|
|
19
|
+
* - **Equality-bound** (`purgeByField`): the predicate is
|
|
20
|
+
* `{ [field]: value }` — the classic tenant-scoped cleanup where an
|
|
21
|
+
* organization id equals the deleted tenant.
|
|
22
|
+
* - **Filter-bound** (`purgeByFilter`): the predicate is a compiled
|
|
23
|
+
* range/compound filter (e.g. a `civilDate` window `gte/lte`,
|
|
24
|
+
* `{ status: 'archived', createdAt: { $lt: cutoff } }`). This is the
|
|
25
|
+
* GDPR/retention "anonymize a PII dimension across a range while
|
|
26
|
+
* RETAINING measures" op — no native equality analogue. Mirrors how
|
|
27
|
+
* the archive port's factory (`createMongoArchivePort`) binds a
|
|
28
|
+
* pre-compiled Filter rather than a `field/value` pair.
|
|
29
|
+
*
|
|
30
|
+
* Because `runChunkedPurge` drives ANY `PurgePort`, both factories reuse
|
|
31
|
+
* the same orchestrator unchanged — only the bound base predicate differs.
|
|
32
|
+
*
|
|
15
33
|
* **Plugin-bypass invariant.** Implementations MUST bypass tenant
|
|
16
|
-
* scoping in plugin hooks — the caller's `field = value`
|
|
17
|
-
* the authoritative scope; a tenant-injecting
|
|
18
|
-
* wrong tenant. Pass `bypassTenant: true` on
|
|
19
|
-
* (which keeps audit / cache hooks active but
|
|
34
|
+
* scoping in plugin hooks — the caller's bound predicate (`field = value`
|
|
35
|
+
* OR the compiled filter) IS the authoritative scope; a tenant-injecting
|
|
36
|
+
* hook would narrow to the wrong tenant. Pass `bypassTenant: true` on
|
|
37
|
+
* inner Repository calls (which keeps audit / cache hooks active but
|
|
38
|
+
* disables tenant injection).
|
|
20
39
|
*
|
|
21
40
|
* **Throughput contract.** Implementations should issue the minimum
|
|
22
41
|
* number of round-trips a chunk requires:
|
|
@@ -38,6 +57,23 @@ interface PurgePort {
|
|
|
38
57
|
* Returning `0` signals "no more matching rows"; the orchestrator
|
|
39
58
|
* exits. Returning a partial batch (`< limit`) is also a terminal
|
|
40
59
|
* signal — saves one round-trip on the last chunk.
|
|
60
|
+
*
|
|
61
|
+
* **PROGRESSION CONTRACT (mandatory).** Successive calls MUST advance
|
|
62
|
+
* through the match set for EVERY strategy. `hard` advances naturally
|
|
63
|
+
* (deleted rows leave the predicate's match set) — but `soft` and
|
|
64
|
+
* `anonymize` mutate rows that usually STILL satisfy the base
|
|
65
|
+
* predicate, so a port that re-runs `find(filter).limit(n)` re-selects
|
|
66
|
+
* the same first chunk forever and the orchestrator never terminates.
|
|
67
|
+
* Implementations must use **stable keyset progression**: order by the
|
|
68
|
+
* primary key and keep an internal `pk > lastSeen` cursor across calls
|
|
69
|
+
* (advance it only after the chunk's write succeeds, so a retried
|
|
70
|
+
* chunk re-selects the same rows). Offsets (`skip`) are not acceptable
|
|
71
|
+
* — they shift under concurrent writes and re-scan the head.
|
|
72
|
+
*
|
|
73
|
+
* A port instance is single-run state: build a fresh port per
|
|
74
|
+
* `runChunkedPurge` invocation, never share one across runs.
|
|
75
|
+
* `runPurgeConformance` (from `@classytic/repo-core/testing`) proves a
|
|
76
|
+
* kit satisfies this contract with match sets larger than `batchSize`.
|
|
41
77
|
*/
|
|
42
78
|
purgeChunk(strategy: WritingPurgeStrategy, limit: number): Promise<number>;
|
|
43
79
|
}
|
|
@@ -1598,10 +1598,13 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
|
|
|
1598
1598
|
* - sqlite: `EXPLAIN QUERY PLAN SELECT … WHERE field = ?` shows
|
|
1599
1599
|
* `SEARCH … USING INDEX`, never `SCAN`.
|
|
1600
1600
|
*
|
|
1601
|
-
* **Idempotent.** Re-running with the same arguments is
|
|
1602
|
-
*
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1601
|
+
* **Idempotent by outcome.** Re-running with the same arguments is
|
|
1602
|
+
* safe: `hard` rows are gone, and `soft`/`anonymize` rows converge to
|
|
1603
|
+
* the same terminal field values on a second pass (the rows generally
|
|
1604
|
+
* DO still match the predicate — which is exactly why ports must use
|
|
1605
|
+
* keyset progression, not re-selection, to advance WITHIN a run; see
|
|
1606
|
+
* the {@link PurgePort} progression contract). Crucial for
|
|
1607
|
+
* at-least-once cascade workers that may retry after partial failure.
|
|
1605
1608
|
*
|
|
1606
1609
|
* **Plugin composition.** Kits route the underlying chunked ops
|
|
1607
1610
|
* through their standard `before:deleteMany` / `before:updateMany`
|
|
@@ -1619,6 +1622,41 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
|
|
|
1619
1622
|
* @param options Chunking, session, progress, abort signal.
|
|
1620
1623
|
*/
|
|
1621
1624
|
purgeByField?(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
|
|
1625
|
+
/**
|
|
1626
|
+
* Range/filter-scoped variant of {@link purgeByField} — processes every
|
|
1627
|
+
* row matching an arbitrary `filter` (not just `field = value`) under the
|
|
1628
|
+
* given {@link TenantPurgeStrategy}. The compliance primitive for
|
|
1629
|
+
* "purge/anonymize a slice across a RANGE while retaining measures":
|
|
1630
|
+
* redact a PII dimension across a `civilDate` window, hard-delete rows
|
|
1631
|
+
* older than a retention cutoff, soft-delete a compound-predicate cohort.
|
|
1632
|
+
*
|
|
1633
|
+
* `purgeByField(field, value, ...)` is the equality special case
|
|
1634
|
+
* (`{ [field]: value }`); this method takes the full portable
|
|
1635
|
+
* {@link FilterInput} (Filter IR or a plain kit-native record), compiled
|
|
1636
|
+
* once by the kit before the chunk loop — the same dual-dialect rule
|
|
1637
|
+
* every other verb follows.
|
|
1638
|
+
*
|
|
1639
|
+
* Everything else is identical to `purgeByField`:
|
|
1640
|
+
* - Strategy → kit-native primitive (`hard`/`soft`/`anonymize`/`skip`).
|
|
1641
|
+
* - **Chunking mandatory** — implementations MUST honor `batchSize`.
|
|
1642
|
+
* - **Index requirement** — the filter's leading field(s) MUST be indexed
|
|
1643
|
+
* or every chunk re-scans the collection / table.
|
|
1644
|
+
* - **Idempotent** — re-running with the same arguments is safe; already
|
|
1645
|
+
* purged rows simply don't match the next pass.
|
|
1646
|
+
* - **Plugin composition** — the chunked ops route through the kit's
|
|
1647
|
+
* `before:deleteMany` / `before:updateMany` hooks so audit /
|
|
1648
|
+
* cache-invalidation / observability plugins fire naturally.
|
|
1649
|
+
* - **Narrowed-write re-assertion** — kits re-assert the base filter on
|
|
1650
|
+
* the `{ _id: { $in: ids }, ...filter }` write, defending against a row
|
|
1651
|
+
* that left the matching set between the id select and the write.
|
|
1652
|
+
*
|
|
1653
|
+
* Optional method; gate on `capabilities.purgeByFilter`.
|
|
1654
|
+
*
|
|
1655
|
+
* @param filter Predicate selecting rows to purge (Filter IR or record).
|
|
1656
|
+
* @param strategy Strategy declaration — see {@link TenantPurgeStrategy}.
|
|
1657
|
+
* @param options Chunking, session, progress, abort signal.
|
|
1658
|
+
*/
|
|
1659
|
+
purgeByFilter?(filter: FilterInput, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
|
|
1622
1660
|
/**
|
|
1623
1661
|
* Chunked cold-storage extraction — move every row matching `filter`
|
|
1624
1662
|
* into a host-provided {@link ArchiveSink}, then remove it from the hot
|
package/dist/tenant/index.d.mts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy } from "./types.mjs";
|
|
2
|
-
import { DEFAULT_TENANT_CONFIG, resolveTenantConfig } from "./resolve.mjs";
|
|
3
|
-
export { DEFAULT_TENANT_CONFIG, type ResolvedTenantConfig, type TenantConfig, type TenantFieldType, type TenantStrategy, resolveTenantConfig };
|
|
2
|
+
import { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
|
|
3
|
+
export { DEFAULT_TENANT_CONFIG, type ResolvedTenantConfig, type TenantConfig, type TenantFieldType, type TenantStrategy, resolveTenantConfig, resolveTenantField };
|
package/dist/tenant/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { DEFAULT_TENANT_CONFIG, resolveTenantConfig } from "./resolve.mjs";
|
|
2
|
-
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };
|
|
1
|
+
import { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
|
|
2
|
+
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
|
|
@@ -12,5 +12,21 @@ import { ResolvedTenantConfig, TenantConfig } from "./types.mjs";
|
|
|
12
12
|
type TenantDefaults = { [K in 'strategy' | 'enabled' | 'tenantField' | 'fieldType' | 'ref' | 'contextKey' | 'required']-?: Exclude<TenantConfig[K], undefined>; };
|
|
13
13
|
declare const DEFAULT_TENANT_CONFIG: TenantDefaults;
|
|
14
14
|
declare function resolveTenantConfig(config?: TenantConfig | boolean): ResolvedTenantConfig;
|
|
15
|
+
/**
|
|
16
|
+
* The single `tenantField` value a resource layer wants, or `false` when the
|
|
17
|
+
* option disables scoping entirely.
|
|
18
|
+
*
|
|
19
|
+
* Packages composing an arc resource need exactly this shape for
|
|
20
|
+
* `defineResource({ tenantField })`, and four spine modules independently
|
|
21
|
+
* hand-rolled it — each re-deriving `'organizationId'` as the default and
|
|
22
|
+
* unwrapping `{ tenantField }` by hand. One of those copies omitted the
|
|
23
|
+
* disable branch, so that package silently could not be configured
|
|
24
|
+
* company-wide. This wraps {@link resolveTenantConfig} so the default, the
|
|
25
|
+
* disable semantics, and the object-unwrapping have ONE definition.
|
|
26
|
+
*
|
|
27
|
+
* `false` / `{ enabled: false }` / `{ strategy: 'none' }` all mean "no tenant
|
|
28
|
+
* scoping" and all return `false` — callers get one thing to branch on.
|
|
29
|
+
*/
|
|
30
|
+
declare function resolveTenantField(config?: TenantConfig | boolean): string | false;
|
|
15
31
|
//#endregion
|
|
16
|
-
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };
|
|
32
|
+
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
|
package/dist/tenant/resolve.mjs
CHANGED
|
@@ -51,5 +51,25 @@ function resolveTenantConfig(config) {
|
|
|
51
51
|
enabled: cleaned.enabled ?? true
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The single `tenantField` value a resource layer wants, or `false` when the
|
|
56
|
+
* option disables scoping entirely.
|
|
57
|
+
*
|
|
58
|
+
* Packages composing an arc resource need exactly this shape for
|
|
59
|
+
* `defineResource({ tenantField })`, and four spine modules independently
|
|
60
|
+
* hand-rolled it — each re-deriving `'organizationId'` as the default and
|
|
61
|
+
* unwrapping `{ tenantField }` by hand. One of those copies omitted the
|
|
62
|
+
* disable branch, so that package silently could not be configured
|
|
63
|
+
* company-wide. This wraps {@link resolveTenantConfig} so the default, the
|
|
64
|
+
* disable semantics, and the object-unwrapping have ONE definition.
|
|
65
|
+
*
|
|
66
|
+
* `false` / `{ enabled: false }` / `{ strategy: 'none' }` all mean "no tenant
|
|
67
|
+
* scoping" and all return `false` — callers get one thing to branch on.
|
|
68
|
+
*/
|
|
69
|
+
function resolveTenantField(config) {
|
|
70
|
+
const resolved = resolveTenantConfig(config);
|
|
71
|
+
if (!resolved.enabled || resolved.strategy === "none") return false;
|
|
72
|
+
return resolved.tenantField;
|
|
73
|
+
}
|
|
54
74
|
//#endregion
|
|
55
|
-
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };
|
|
75
|
+
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
|
package/dist/testing/index.d.mts
CHANGED
|
@@ -2,5 +2,6 @@ import { AggregateOpsSupport } from "../repository/capabilities.mjs";
|
|
|
2
2
|
import { ConformanceContext, ConformanceDoc, ConformanceFeatures, ConformanceHarness } from "./types.mjs";
|
|
3
3
|
import { runStandardRepoConformance } from "./conformance.mjs";
|
|
4
4
|
import { LockConformanceHarness, runLockAdapterConformance } from "./lock-conformance.mjs";
|
|
5
|
+
import { PurgeConformanceContext, PurgeConformanceHarness, runPurgeConformance } from "./purge-conformance.mjs";
|
|
5
6
|
import { UsageConformanceHarness, runUsageStoreContract } from "./usage-conformance.mjs";
|
|
6
|
-
export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type LockConformanceHarness, type UsageConformanceHarness, runLockAdapterConformance, runStandardRepoConformance, runUsageStoreContract };
|
|
7
|
+
export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type LockConformanceHarness, type PurgeConformanceContext, type PurgeConformanceHarness, type UsageConformanceHarness, runLockAdapterConformance, runPurgeConformance, runStandardRepoConformance, runUsageStoreContract };
|
package/dist/testing/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runStandardRepoConformance } from "./conformance.mjs";
|
|
2
2
|
import { runLockAdapterConformance } from "./lock-conformance.mjs";
|
|
3
|
+
import { runPurgeConformance } from "./purge-conformance.mjs";
|
|
3
4
|
import { runUsageStoreContract } from "./usage-conformance.mjs";
|
|
4
|
-
export { runLockAdapterConformance, runStandardRepoConformance, runUsageStoreContract };
|
|
5
|
+
export { runLockAdapterConformance, runPurgeConformance, runStandardRepoConformance, runUsageStoreContract };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { TenantPurgeOptions, TenantPurgeResult, TenantPurgeStrategy } from "../repository/types.mjs";
|
|
2
|
+
//#region src/testing/purge-conformance.d.ts
|
|
3
|
+
/** Everything the shared scenarios need from a kit. */
|
|
4
|
+
interface PurgeConformanceContext {
|
|
5
|
+
/**
|
|
6
|
+
* Seed `inScope` docs matching the purge scope and `outOfScope` docs
|
|
7
|
+
* outside it. Every in-scope doc must carry:
|
|
8
|
+
* - a string field `email` set to `'user-<i>@test.local'` (anonymize target);
|
|
9
|
+
* - a numeric field `amount` (a measure that must SURVIVE soft/anonymize).
|
|
10
|
+
*/
|
|
11
|
+
seed(inScope: number, outOfScope: number): Promise<void>;
|
|
12
|
+
/** Run the kit's chunked purge over the bound scope. */
|
|
13
|
+
purge(strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
|
|
14
|
+
/** RAW physical count of in-scope rows — MUST bypass soft-delete query filters. */
|
|
15
|
+
countRaw(): Promise<number>;
|
|
16
|
+
/** RAW count of in-scope rows carrying the soft-deleted flag. */
|
|
17
|
+
countSoftFlagged(): Promise<number>;
|
|
18
|
+
/** RAW count of in-scope rows whose `email` equals `value`. */
|
|
19
|
+
countEmail(value: string): Promise<number>;
|
|
20
|
+
/** RAW sum of `amount` across in-scope rows (proves measures retained). */
|
|
21
|
+
sumAmount(): Promise<number>;
|
|
22
|
+
/** RAW physical count of OUT-of-scope rows — must never change. */
|
|
23
|
+
countOutOfScope(): Promise<number>;
|
|
24
|
+
}
|
|
25
|
+
interface PurgeConformanceHarness {
|
|
26
|
+
/** Kit name — the top-level describe() label. */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Fresh isolated context per test (own collection/table). */
|
|
29
|
+
setup(): Promise<PurgeConformanceContext>;
|
|
30
|
+
}
|
|
31
|
+
declare function runPurgeConformance(harness: PurgeConformanceHarness): void;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { PurgeConformanceContext, PurgeConformanceHarness, runPurgeConformance };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
//#region src/testing/purge-conformance.ts
|
|
3
|
+
/**
|
|
4
|
+
* `runPurgeConformance` — cross-kit chunked-purge contract suite.
|
|
5
|
+
*
|
|
6
|
+
* Proves a kit's `purgeByField`/`purgeByFilter` port makes **stable
|
|
7
|
+
* progress for EVERY strategy** when the match set exceeds `batchSize`
|
|
8
|
+
* — the exact property the naive "re-query the same predicate" port
|
|
9
|
+
* shape violates: `hard` self-advances (deleted rows leave the match
|
|
10
|
+
* set) but `soft`/`anonymize` re-select the same first chunk forever
|
|
11
|
+
* because the mutated rows still satisfy the base predicate.
|
|
12
|
+
*
|
|
13
|
+
* Every scenario seeds MORE rows than `batchSize`, so a port without
|
|
14
|
+
* keyset progression fails here instead of hanging production. A chunk
|
|
15
|
+
* budget (via `onProgress` + abort) converts the would-be infinite loop
|
|
16
|
+
* into a crisp assertion failure.
|
|
17
|
+
*
|
|
18
|
+
* ## Usage from a kit
|
|
19
|
+
*
|
|
20
|
+
* import { runPurgeConformance } from '@classytic/repo-core/testing';
|
|
21
|
+
*
|
|
22
|
+
* describe('mongokit purge conformance', () => {
|
|
23
|
+
* runPurgeConformance({
|
|
24
|
+
* name: 'mongokit',
|
|
25
|
+
* async setup() { …return a PurgeConformanceContext… },
|
|
26
|
+
* });
|
|
27
|
+
* });
|
|
28
|
+
*/
|
|
29
|
+
const IN_SCOPE = 25;
|
|
30
|
+
const OUT_SCOPE = 5;
|
|
31
|
+
const BATCH = 10;
|
|
32
|
+
const AMOUNT_EACH = 7;
|
|
33
|
+
/**
|
|
34
|
+
* Wrap TenantPurgeOptions with a chunk budget: when the port stops
|
|
35
|
+
* progressing, the loop would otherwise run forever — the budget aborts
|
|
36
|
+
* it after `limit` chunks so the suite fails with a readable assertion
|
|
37
|
+
* instead of a vitest timeout.
|
|
38
|
+
*/
|
|
39
|
+
function budgeted(limit, chunks = []) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
return {
|
|
42
|
+
batchSize: BATCH,
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
onProgress({ chunkSize }) {
|
|
45
|
+
chunks.push(chunkSize);
|
|
46
|
+
if (chunks.length >= limit) controller.abort();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** ceil(25/10) + 1 slack — a progressing port never needs more. */
|
|
51
|
+
const CHUNK_BUDGET = Math.ceil(IN_SCOPE / BATCH) + 1;
|
|
52
|
+
function runPurgeConformance(harness) {
|
|
53
|
+
describe(`${harness.name} — chunked purge conformance`, () => {
|
|
54
|
+
let ctx;
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
ctx = await harness.setup();
|
|
57
|
+
});
|
|
58
|
+
it("hard: drains a multi-batch scope exactly once per row", async () => {
|
|
59
|
+
await ctx.seed(IN_SCOPE, OUT_SCOPE);
|
|
60
|
+
const chunks = [];
|
|
61
|
+
const res = await ctx.purge({ type: "hard" }, budgeted(CHUNK_BUDGET, chunks));
|
|
62
|
+
expect(res.ok).toBe(true);
|
|
63
|
+
expect(res.processed).toBe(IN_SCOPE);
|
|
64
|
+
expect(chunks).toEqual([
|
|
65
|
+
10,
|
|
66
|
+
10,
|
|
67
|
+
5
|
|
68
|
+
]);
|
|
69
|
+
expect(await ctx.countRaw()).toBe(0);
|
|
70
|
+
expect(await ctx.countOutOfScope()).toBe(OUT_SCOPE);
|
|
71
|
+
});
|
|
72
|
+
it("soft: progresses across batches WITHOUT caller-supplied exclusion predicates", async () => {
|
|
73
|
+
await ctx.seed(IN_SCOPE, OUT_SCOPE);
|
|
74
|
+
const chunks = [];
|
|
75
|
+
const res = await ctx.purge({ type: "soft" }, budgeted(CHUNK_BUDGET, chunks));
|
|
76
|
+
expect(res.ok).toBe(true);
|
|
77
|
+
expect(res.processed).toBe(IN_SCOPE);
|
|
78
|
+
expect(chunks).toEqual([
|
|
79
|
+
10,
|
|
80
|
+
10,
|
|
81
|
+
5
|
|
82
|
+
]);
|
|
83
|
+
expect(await ctx.countRaw()).toBe(IN_SCOPE);
|
|
84
|
+
expect(await ctx.countSoftFlagged()).toBe(IN_SCOPE);
|
|
85
|
+
expect(await ctx.sumAmount()).toBe(IN_SCOPE * AMOUNT_EACH);
|
|
86
|
+
expect(await ctx.countOutOfScope()).toBe(OUT_SCOPE);
|
|
87
|
+
});
|
|
88
|
+
it("anonymize (static): progresses across batches and retains measures", async () => {
|
|
89
|
+
await ctx.seed(IN_SCOPE, OUT_SCOPE);
|
|
90
|
+
const chunks = [];
|
|
91
|
+
const res = await ctx.purge({
|
|
92
|
+
type: "anonymize",
|
|
93
|
+
fields: { email: "redacted@example.invalid" }
|
|
94
|
+
}, budgeted(CHUNK_BUDGET, chunks));
|
|
95
|
+
expect(res.ok).toBe(true);
|
|
96
|
+
expect(res.processed).toBe(IN_SCOPE);
|
|
97
|
+
expect(chunks).toEqual([
|
|
98
|
+
10,
|
|
99
|
+
10,
|
|
100
|
+
5
|
|
101
|
+
]);
|
|
102
|
+
expect(await ctx.countEmail("redacted@example.invalid")).toBe(IN_SCOPE);
|
|
103
|
+
expect(await ctx.sumAmount()).toBe(IN_SCOPE * AMOUNT_EACH);
|
|
104
|
+
expect(await ctx.countRaw()).toBe(IN_SCOPE);
|
|
105
|
+
expect(await ctx.countOutOfScope()).toBe(OUT_SCOPE);
|
|
106
|
+
});
|
|
107
|
+
it("anonymize (function-form): progresses across batches", async () => {
|
|
108
|
+
await ctx.seed(IN_SCOPE, OUT_SCOPE);
|
|
109
|
+
const res = await ctx.purge({
|
|
110
|
+
type: "anonymize",
|
|
111
|
+
fields: { email: () => "fn-redacted@example.invalid" }
|
|
112
|
+
}, budgeted(CHUNK_BUDGET));
|
|
113
|
+
expect(res.ok).toBe(true);
|
|
114
|
+
expect(res.processed).toBe(IN_SCOPE);
|
|
115
|
+
expect(await ctx.countEmail("fn-redacted@example.invalid")).toBe(IN_SCOPE);
|
|
116
|
+
expect(await ctx.sumAmount()).toBe(IN_SCOPE * AMOUNT_EACH);
|
|
117
|
+
});
|
|
118
|
+
it("exact-batch boundary: inScope === batchSize processes each row once", async () => {
|
|
119
|
+
await ctx.seed(BATCH, OUT_SCOPE);
|
|
120
|
+
const res = await ctx.purge({ type: "soft" }, budgeted(CHUNK_BUDGET));
|
|
121
|
+
expect(res.ok).toBe(true);
|
|
122
|
+
expect(res.processed).toBe(BATCH);
|
|
123
|
+
expect(await ctx.countSoftFlagged()).toBe(BATCH);
|
|
124
|
+
});
|
|
125
|
+
it("skip: declared no-op reaches no rows", async () => {
|
|
126
|
+
await ctx.seed(3, 0);
|
|
127
|
+
const res = await ctx.purge({
|
|
128
|
+
type: "skip",
|
|
129
|
+
reason: "retention-owned"
|
|
130
|
+
});
|
|
131
|
+
expect(res.ok).toBe(true);
|
|
132
|
+
expect(res.processed).toBe(0);
|
|
133
|
+
expect(res.skipReason).toBe("retention-owned");
|
|
134
|
+
expect(await ctx.countRaw()).toBe(3);
|
|
135
|
+
});
|
|
136
|
+
it("empty scope: terminates immediately with zero processed", async () => {
|
|
137
|
+
await ctx.seed(0, OUT_SCOPE);
|
|
138
|
+
const res = await ctx.purge({ type: "hard" }, budgeted(CHUNK_BUDGET));
|
|
139
|
+
expect(res.ok).toBe(true);
|
|
140
|
+
expect(res.processed).toBe(0);
|
|
141
|
+
expect(await ctx.countOutOfScope()).toBe(OUT_SCOPE);
|
|
142
|
+
});
|
|
143
|
+
it("abort between chunks: committed chunks stay, result is ok:false", async () => {
|
|
144
|
+
await ctx.seed(IN_SCOPE, OUT_SCOPE);
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const res = await ctx.purge({ type: "hard" }, {
|
|
147
|
+
batchSize: BATCH,
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
onProgress() {
|
|
150
|
+
controller.abort();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
expect(res.ok).toBe(false);
|
|
154
|
+
expect(res.processed).toBe(BATCH);
|
|
155
|
+
expect(await ctx.countRaw()).toBe(IN_SCOPE - BATCH);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
export { runPurgeConformance };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/repo-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design — no plugins ship here; each kit owns its own.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -102,6 +102,10 @@
|
|
|
102
102
|
"./sync": {
|
|
103
103
|
"types": "./dist/sync/index.d.mts",
|
|
104
104
|
"default": "./dist/sync/index.mjs"
|
|
105
|
+
},
|
|
106
|
+
"./cleanup": {
|
|
107
|
+
"types": "./dist/cleanup/index.d.mts",
|
|
108
|
+
"default": "./dist/cleanup/index.mjs"
|
|
105
109
|
}
|
|
106
110
|
},
|
|
107
111
|
"keywords": [
|