@classytic/repo-core 0.17.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 CHANGED
@@ -4,6 +4,43 @@ 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
+
7
44
  ## [0.17.0] - 2026-07-25
8
45
 
9
46
  ### Added — `./cleanup`: framework-free cleanup provider step contract
@@ -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 };
@@ -1,2 +1,3 @@
1
1
  import { CleanupStep, CleanupStepCheck, CleanupStepContext, CleanupStepEstimate, CleanupStepExecuteContext, CleanupStepLogger, CleanupStepOutcome, CleanupStepProgress } from "./types.mjs";
2
- export type { CleanupStep, CleanupStepCheck, CleanupStepContext, CleanupStepEstimate, CleanupStepExecuteContext, CleanupStepLogger, CleanupStepOutcome, CleanupStepProgress };
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,2 @@
1
+ import { REPOSITORY_UNAVAILABLE, SCOPE_REQUIRED, definePurgeStep } from "./define-purge-step.mjs";
2
+ export { REPOSITORY_UNAVAILABLE, SCOPE_REQUIRED, definePurgeStep };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -2,6 +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 { UsageConformanceHarness, runUsageStoreContract } from "./usage-conformance.mjs";
6
5
  import { PurgeConformanceContext, PurgeConformanceHarness, runPurgeConformance } from "./purge-conformance.mjs";
6
+ import { UsageConformanceHarness, runUsageStoreContract } from "./usage-conformance.mjs";
7
7
  export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type LockConformanceHarness, type PurgeConformanceContext, type PurgeConformanceHarness, type UsageConformanceHarness, runLockAdapterConformance, runPurgeConformance, runStandardRepoConformance, runUsageStoreContract };
@@ -1,5 +1,5 @@
1
1
  import { runStandardRepoConformance } from "./conformance.mjs";
2
2
  import { runLockAdapterConformance } from "./lock-conformance.mjs";
3
- import { runUsageStoreContract } from "./usage-conformance.mjs";
4
3
  import { runPurgeConformance } from "./purge-conformance.mjs";
4
+ import { runUsageStoreContract } from "./usage-conformance.mjs";
5
5
  export { runLockAdapterConformance, runPurgeConformance, runStandardRepoConformance, runUsageStoreContract };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.17.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,