@classytic/repo-core 0.7.0 → 0.8.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,17 @@ 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.8.0] - 2026-07-08
8
+
9
+ ### Added — data-lifecycle contract (archive, streaming, distribution awareness)
10
+
11
+ Additive release: existing kits on 0.7 semantics compile and pass conformance unchanged (new scenarios are capability-gated and default to skipped).
12
+
13
+ - **`archiveByFilter` + `runChunkedArchive`** (`repository/archive.ts`) — the cold-storage twin of `purgeByField`. Hosts provide an `ArchiveSink` (archive table/collection, JSONL, warehouse loader); kits provide an `ArchivePort` (`readChunk` in stable PK order / `deleteChunk` by docs). The orchestrator enforces **write-before-delete** (a crash re-archives, never loses; sinks must be duplicate-tolerant — at-least-once by design), chunked `batchSize` (default 1000), per-step retry via the shared `RetryPolicy`, abort between chunks, cumulative progress, and phase-tagged errors (`read` / `sink` / `delete` — a `sink` failure guarantees rows are still hot). New capability flag: `archiveByFilter`.
14
+ - **`cursor()` declared on `StandardRepo`** (+ `CursorOptions`) — the streaming-reads method mongokit and sqlitekit already implement is now part of the portable contract (`AsyncIterable<TDoc>`, internal `batchSize` fetching, non-snapshot semantics documented). Gated by the existing `streaming` capability.
15
+ - **Distribution-key awareness** (`repository/distribution.ts`) — `DistributionConfig` (`key`, `onMissingKey: 'warn' | 'throw' | 'off'`, `exemptOperations`) + `createDistributionGuard` + `filterReferencesKey` (Filter IR via `collectFields`, raw records incl. `$and`/`$or`/`AND`/`OR` branches). Catches shard/partition-key-missing scatter-gather queries at the access layer; deliberately does NOT implement routing/rebalancing/partition DDL — those stay database-native (Mongo sharding, pg_partman/Timescale).
16
+ - **Conformance**: new capability-gated scenario groups — `archiveByFilter` (sink round-trip, write-before-delete on sink failure, chunking + progress, idempotent re-run, abort partial) and `cursor` (exact-once iteration across batch boundaries, early-break safety).
17
+
7
18
  ## [0.7.0] - 2026-07-04
8
19
 
9
20
  ### Added — `./sync` change-log / cursor contract
package/README.md CHANGED
@@ -232,6 +232,8 @@ Consumed by:
232
232
 
233
233
  See [INFRA.md](./INFRA.md) for the architectural principles, subpath map, build/tooling decisions, and the roadmap for pgkit / prismakit.
234
234
 
235
+ See [docs/data-lifecycle.md](./docs/data-lifecycle.md) for the billion-row runbook — retention/TTL, `archiveByFilter` cold-storage extraction, `cursor()` streaming scans, tenant purge, the distribution-key guard, and the per-backend sharding/partitioning/backup playbook (what the kits own vs what the database owns).
236
+
235
237
  ## License
236
238
 
237
239
  MIT — see [LICENSE](./LICENSE).
@@ -1,7 +1,7 @@
1
+ import { isFilter } from "./guard.mjs";
2
+ import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
1
3
  import { FALSE, TRUE, and, between, contains, endsWith, eq, exists, gt, gte, iEq, in_, isNotNull, isNull, like, lt, lte, ne, nin, not, or, raw, regex, startsWith } from "./builders.mjs";
2
4
  import { recordToFilter } from "./from-record.mjs";
3
- import { isFilter } from "./guard.mjs";
4
5
  import { asPredicate, matchFilter } from "./match.mjs";
5
6
  import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
6
- import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
7
7
  export { FALSE, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, recordToFilter, regex, startsWith, walkFilter };
@@ -1,5 +1,5 @@
1
- import { TRUE, and, eq } from "./builders.mjs";
2
1
  import { isFilter } from "./guard.mjs";
2
+ import { TRUE, and, eq } from "./builders.mjs";
3
3
  //#region src/filter/scope.ts
4
4
  /**
5
5
  * Scope-injection helpers.
@@ -0,0 +1,107 @@
1
+ import { RetryPolicy } from "./resilience.mjs";
2
+
3
+ //#region src/repository/archive.d.ts
4
+ /**
5
+ * Destination for archived documents — implemented by the HOST, not the
6
+ * kit. One method so anything writable fits: an archive collection/table,
7
+ * an object-store JSONL writer, a warehouse ingestion API.
8
+ *
9
+ * **Idempotency requirement.** `write` MAY receive the same chunk more
10
+ * than once (crash between write and delete, or a chunk-level retry).
11
+ * Implementations must tolerate duplicates: upsert by primary key when the
12
+ * destination is a table/collection; dedup on load when it's a file/queue.
13
+ */
14
+ interface ArchiveSink<TDoc = unknown> {
15
+ /**
16
+ * Persist one chunk. Throwing aborts the run BEFORE the chunk is
17
+ * deleted from the hot store — the failed chunk stays hot, nothing is
18
+ * lost. Retries (when configured) wrap this call.
19
+ */
20
+ write(docs: readonly TDoc[]): Promise<void>;
21
+ /**
22
+ * Optional finalize hook — called once after the run completes without
23
+ * error (flush buffers, close multipart uploads, commit manifests).
24
+ * NOT called on abort/error; partial sink output must already be safe
25
+ * by construction (see idempotency requirement).
26
+ */
27
+ flush?(): Promise<void>;
28
+ }
29
+ /**
30
+ * Driver-facing port the orchestrator drives. Each kit implements one
31
+ * closure over its driver primitives + the archive predicate.
32
+ *
33
+ * **Plugin-bypass invariant.** Like `PurgePort`, implementations MUST
34
+ * bypass tenant-scope injection on inner reads/deletes — the caller's
35
+ * filter IS the authoritative predicate.
36
+ */
37
+ interface ArchivePort<TDoc = unknown> {
38
+ /**
39
+ * Read the next chunk of matching rows in a stable order (primary-key
40
+ * ascending), at most `limit`. Returning `[]` signals "no more matching
41
+ * rows"; a partial chunk (`< limit`) is also terminal after processing.
42
+ */
43
+ readChunk(limit: number): Promise<readonly TDoc[]>;
44
+ /**
45
+ * Remove the given (already-sunk) docs from the hot store. Returns the
46
+ * count actually deleted — may be `< docs.length` when a concurrent
47
+ * writer already removed some; the orchestrator reports what the port
48
+ * returns.
49
+ */
50
+ deleteChunk(docs: readonly TDoc[]): Promise<number>;
51
+ }
52
+ /** Per-call options for `archiveByFilter`. Mirrors `TenantPurgeOptions`. */
53
+ interface ArchiveOptions {
54
+ /** Rows per chunk. Default 1000. */
55
+ batchSize?: number;
56
+ /** Per-chunk progress callback. `processed` is cumulative deleted count. */
57
+ onProgress?: (event: ArchiveProgress) => void | Promise<void>;
58
+ /**
59
+ * Abort signal. Checked between chunks — never mid-chunk. Chunks already
60
+ * written + deleted stay archived (at-least-once semantics).
61
+ */
62
+ signal?: AbortSignal;
63
+ /**
64
+ * Retry transient failures at the STEP level (read, sink write, delete
65
+ * each retry independently). Default: no retry — first error aborts.
66
+ */
67
+ retry?: RetryPolicy;
68
+ }
69
+ /** Chunk-level progress event. */
70
+ interface ArchiveProgress {
71
+ /** Rows fully archived so far (written to sink AND deleted). */
72
+ processed: number;
73
+ /** Rows in the chunk that just completed. */
74
+ chunkSize: number;
75
+ /** Wall-clock ms elapsed since the call started. */
76
+ elapsedMs: number;
77
+ }
78
+ /** Final result of an `archiveByFilter` invocation. */
79
+ interface ArchiveResult {
80
+ /** Rows fully archived (sunk + removed from the hot store). */
81
+ processed: number;
82
+ /** True iff the run completed without abort / error. */
83
+ ok: boolean;
84
+ /** Wall-clock ms. */
85
+ durationMs: number;
86
+ /**
87
+ * First error if `ok: false`. `phase` says which step failed — a
88
+ * `'sink'` failure guarantees the hot store still holds the chunk.
89
+ */
90
+ error?: {
91
+ message: string;
92
+ phase: 'read' | 'sink' | 'delete';
93
+ processed: number;
94
+ };
95
+ }
96
+ /**
97
+ * Drive a chunked archive to completion. Returns an `ArchiveResult`
98
+ * envelope — never throws for in-run errors (those wrap into
99
+ * `result.error`); only throws for invalid input (`batchSize < 1`).
100
+ *
101
+ * @param options Chunking + signal + progress + optional retry.
102
+ * @param sink Host-provided destination (idempotent writes).
103
+ * @param port Kit-specific driver glue (readChunk / deleteChunk).
104
+ */
105
+ declare function runChunkedArchive<TDoc = unknown>(options: ArchiveOptions, sink: ArchiveSink<TDoc>, port: ArchivePort<TDoc>): Promise<ArchiveResult>;
106
+ //#endregion
107
+ export { ArchiveOptions, ArchivePort, ArchiveProgress, ArchiveResult, ArchiveSink, runChunkedArchive };
@@ -0,0 +1,64 @@
1
+ import { withRetry } from "./resilience.mjs";
2
+ //#region src/repository/archive.ts
3
+ /**
4
+ * Drive a chunked archive to completion. Returns an `ArchiveResult`
5
+ * envelope — never throws for in-run errors (those wrap into
6
+ * `result.error`); only throws for invalid input (`batchSize < 1`).
7
+ *
8
+ * @param options Chunking + signal + progress + optional retry.
9
+ * @param sink Host-provided destination (idempotent writes).
10
+ * @param port Kit-specific driver glue (readChunk / deleteChunk).
11
+ */
12
+ async function runChunkedArchive(options, sink, port) {
13
+ const start = Date.now();
14
+ const batchSize = options.batchSize ?? 1e3;
15
+ if (!Number.isInteger(batchSize) || batchSize < 1) throw new Error("archiveByFilter: batchSize must be a positive integer");
16
+ const retry = options.retry;
17
+ let processed = 0;
18
+ let phase = "read";
19
+ try {
20
+ while (true) {
21
+ if (options.signal?.aborted) return {
22
+ processed,
23
+ ok: false,
24
+ durationMs: Date.now() - start
25
+ };
26
+ phase = "read";
27
+ const docs = await withRetry(() => port.readChunk(batchSize), retry, options.signal);
28
+ if (docs.length === 0) break;
29
+ phase = "sink";
30
+ await withRetry(() => sink.write(docs), retry, options.signal);
31
+ phase = "delete";
32
+ const deleted = await withRetry(() => port.deleteChunk(docs), retry, options.signal);
33
+ processed += deleted;
34
+ if (options.onProgress) await options.onProgress({
35
+ processed,
36
+ chunkSize: deleted,
37
+ elapsedMs: Date.now() - start
38
+ });
39
+ if (docs.length < batchSize) break;
40
+ }
41
+ if (sink.flush) {
42
+ phase = "sink";
43
+ await withRetry(() => sink.flush?.() ?? Promise.resolve(), retry, options.signal);
44
+ }
45
+ } catch (err) {
46
+ return {
47
+ processed,
48
+ ok: false,
49
+ durationMs: Date.now() - start,
50
+ error: {
51
+ message: err instanceof Error ? err.message : String(err),
52
+ phase,
53
+ processed
54
+ }
55
+ };
56
+ }
57
+ return {
58
+ processed,
59
+ ok: true,
60
+ durationMs: Date.now() - start
61
+ };
62
+ }
63
+ //#endregion
64
+ export { runChunkedArchive };
@@ -126,6 +126,12 @@ interface RepoCapabilities {
126
126
  * tenant cleanup primitive.
127
127
  */
128
128
  purgeByField?: boolean;
129
+ /**
130
+ * `archiveByFilter(filter, sink, options)` — chunked cold-storage
131
+ * extraction (write-before-delete, at-least-once). The data-lifecycle
132
+ * twin of `purgeByField`.
133
+ */
134
+ archiveByFilter?: boolean;
129
135
  /**
130
136
  * Mongo-style array update operators (`$push`, `$pull`, `$addToSet`,
131
137
  * `$pop`, `$pullAll`). Mongokit: native. Sqlitekit: implemented over
@@ -0,0 +1,48 @@
1
+ import { FilterInput } from "./types.mjs";
2
+
3
+ //#region src/repository/distribution.d.ts
4
+ /** Per-repo declaration of how the underlying table/collection is distributed. */
5
+ interface DistributionConfig {
6
+ /**
7
+ * The shard key (Mongo) / partition key (Postgres, Timescale) / primary
8
+ * access dimension. Filters that omit this field fan out across every
9
+ * shard/partition.
10
+ */
11
+ key: string;
12
+ /**
13
+ * What to do when a filter misses the key:
14
+ * - `'warn'` (default) — invoke `onMiss` (kits default it to a
15
+ * once-per-operation console warning outside production).
16
+ * - `'throw'` — reject the operation; for hosts where scatter-gather
17
+ * is never acceptable.
18
+ * - `'off'` — declaration only (still surfaced via metadata).
19
+ */
20
+ onMissingKey?: 'warn' | 'throw' | 'off';
21
+ /**
22
+ * Operations exempt from the check. `getById` and other primary-key
23
+ * lookups never carry the filter, so kits only guard the filter-taking
24
+ * verbs; list verbs a host legitimately runs cross-shard here
25
+ * (e.g. `'aggregate'` for global dashboards).
26
+ */
27
+ exemptOperations?: readonly string[];
28
+ }
29
+ /**
30
+ * Returns true when `filter` references the distribution key anywhere in
31
+ * the tree (Filter IR or raw record, including `$and`/`$or`/`AND`/`OR`
32
+ * branches). A key inside an `$or` still bounds the fan-out on Mongo and
33
+ * prunes partitions on Postgres, so it counts.
34
+ */
35
+ declare function filterReferencesKey(filter: FilterInput | undefined, key: string): boolean;
36
+ /** Callback invoked when a guarded operation misses the distribution key. */
37
+ type DistributionMissHandler = (info: {
38
+ operation: string;
39
+ key: string;
40
+ }) => void;
41
+ /**
42
+ * Build a checker kits call at the top of each filter-taking verb.
43
+ * Stateless and allocation-free on the hit path; `onMiss` fires at most
44
+ * once per operation name per guard instance to keep logs readable.
45
+ */
46
+ declare function createDistributionGuard(config: DistributionConfig, onMiss?: DistributionMissHandler): (operation: string, filter: FilterInput | undefined) => void;
47
+ //#endregion
48
+ export { DistributionConfig, DistributionMissHandler, createDistributionGuard, filterReferencesKey };
@@ -0,0 +1,73 @@
1
+ import { isFilter } from "../filter/guard.mjs";
2
+ import { collectFields } from "../filter/walk.mjs";
3
+ //#region src/repository/distribution.ts
4
+ /**
5
+ * Distribution-key awareness — the lean, industry-standard slice of
6
+ * "sharding support" that belongs in an access layer.
7
+ *
8
+ * Sharding and partitioning themselves are DATABASE features (Mongo
9
+ * `sh.shardCollection`, Postgres declarative partitioning / pg_partman /
10
+ * TimescaleDB hypertables) — repo-core deliberately does NOT reimplement
11
+ * routing, rebalancing, or partition DDL. What the access layer CAN do is
12
+ * catch the classic production regression: a query that omits the
13
+ * shard/partition key and silently degrades to a scatter-gather (Mongo)
14
+ * or full-partition scan (Postgres) at 100× the cost.
15
+ *
16
+ * A kit (or host) declares the repo's distribution key once; the guard
17
+ * inspects each filter and reports whether the key is present. Kits wire
18
+ * it as a dev-time warning (default) or a hard throw for strict hosts.
19
+ *
20
+ * Multi-tenant note: when the tenant field IS the shard key (the common
21
+ * design — tenant-prefixed shard keys / tenant-hash partitions), kits with
22
+ * tenant-scope injection already guarantee the key on every query; the
23
+ * guard then only fires on `bypassTenant` escape hatches — exactly the
24
+ * calls that deserve scrutiny.
25
+ */
26
+ /**
27
+ * Returns true when `filter` references the distribution key anywhere in
28
+ * the tree (Filter IR or raw record, including `$and`/`$or`/`AND`/`OR`
29
+ * branches). A key inside an `$or` still bounds the fan-out on Mongo and
30
+ * prunes partitions on Postgres, so it counts.
31
+ */
32
+ function filterReferencesKey(filter, key) {
33
+ if (!filter) return false;
34
+ if (isFilter(filter)) return collectFields(filter).includes(key);
35
+ return recordReferencesKey(filter, key);
36
+ }
37
+ function recordReferencesKey(record, key) {
38
+ for (const [field, value] of Object.entries(record)) {
39
+ if (field === key) return true;
40
+ if (/^(\$and|\$or|\$nor|AND|OR)$/.test(field) && Array.isArray(value)) {
41
+ if (value.some((child) => recordReferencesKey(child, key))) return true;
42
+ continue;
43
+ }
44
+ if (/^(\$not|NOT)$/.test(field) && value && typeof value === "object") {
45
+ if (recordReferencesKey(value, key)) return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ /**
51
+ * Build a checker kits call at the top of each filter-taking verb.
52
+ * Stateless and allocation-free on the hit path; `onMiss` fires at most
53
+ * once per operation name per guard instance to keep logs readable.
54
+ */
55
+ function createDistributionGuard(config, onMiss) {
56
+ const mode = config.onMissingKey ?? "warn";
57
+ const exempt = new Set(config.exemptOperations ?? []);
58
+ const warned = /* @__PURE__ */ new Set();
59
+ return (operation, filter) => {
60
+ if (mode === "off" || exempt.has(operation)) return;
61
+ if (filterReferencesKey(filter, config.key)) return;
62
+ if (mode === "throw") throw new Error(`Distribution guard: ${operation} filter omits the distribution key "${config.key}" — this fans out across every shard/partition. Include the key, exempt the operation, or set onMissingKey: 'off'.`);
63
+ if (!warned.has(operation)) {
64
+ warned.add(operation);
65
+ onMiss?.({
66
+ operation,
67
+ key: config.key
68
+ });
69
+ }
70
+ };
71
+ }
72
+ //#endregion
73
+ export { createDistributionGuard, filterReferencesKey };
@@ -1,11 +1,13 @@
1
1
  import { LookupPopulateOptions, LookupPopulateResult, LookupRow, LookupSpec } from "../lookup/types.mjs";
2
2
  import { UpdateInput } from "../update/types.mjs";
3
3
  import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
4
+ import { RetryPolicy, throwIfAborted, withRetry } from "./resilience.mjs";
5
+ import { ArchiveOptions, ArchivePort, ArchiveProgress, ArchiveResult, ArchiveSink, runChunkedArchive } from "./archive.mjs";
4
6
  import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
5
7
  import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
6
8
  import { AggregateOpsSupport, RepoCapabilities } from "./capabilities.mjs";
9
+ import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ChangeEvent, ClaimTransition, ClaimVersionTransition, CursorOptions, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, TenantPurgeOptions, TenantPurgeProgress, TenantPurgeResult, TenantPurgeStrategy, UpdateManyResult, WatchOptions, WriteOptions } from "./types.mjs";
10
+ import { DistributionConfig, DistributionMissHandler, createDistributionGuard, filterReferencesKey } from "./distribution.mjs";
7
11
  import { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey } from "./options.mjs";
8
- import { RetryPolicy, throwIfAborted, withRetry } from "./resilience.mjs";
9
- import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ChangeEvent, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, TenantPurgeOptions, TenantPurgeProgress, TenantPurgeResult, TenantPurgeStrategy, UpdateManyResult, WatchOptions, WriteOptions } from "./types.mjs";
10
12
  import { PurgePort, WritingPurgeStrategy, runChunkedPurge } from "./purge.mjs";
11
- export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type AggregateOpsSupport, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ChangeEvent, type ClaimTransition, type ClaimVersionTransition, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type PurgePort, type QueryOptions, type RepoCapabilities, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type RetryPolicy, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type TenantPurgeOptions, type TenantPurgeProgress, type TenantPurgeResult, type TenantPurgeStrategy, type UpdateInput, type UpdateManyResult, type WatchOptions, type WriteOptions, type WritingPurgeStrategy, nestDottedKeys, nestDottedKeysAll, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
13
+ export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type AggregateOpsSupport, type ArchiveOptions, type ArchivePort, type ArchiveProgress, type ArchiveResult, type ArchiveSink, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ChangeEvent, type ClaimTransition, type ClaimVersionTransition, type CursorOptions, type DeleteManyResult, type DeleteOptions, type DeleteResult, type DistributionConfig, type DistributionMissHandler, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type PurgePort, type QueryOptions, type RepoCapabilities, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type RetryPolicy, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type TenantPurgeOptions, type TenantPurgeProgress, type TenantPurgeResult, type TenantPurgeStrategy, type UpdateInput, type UpdateManyResult, type WatchOptions, type WriteOptions, type WritingPurgeStrategy, createDistributionGuard, filterReferencesKey, nestDottedKeys, nestDottedKeysAll, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
@@ -1,7 +1,9 @@
1
1
  import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
2
+ import { throwIfAborted, withRetry } from "./resilience.mjs";
3
+ import { runChunkedArchive } from "./archive.mjs";
2
4
  import { PLUGIN_ORDER_CONSTRAINTS, validatePluginOrder } from "./plugin-types.mjs";
3
5
  import { RepositoryBase } from "./base.mjs";
6
+ import { createDistributionGuard, filterReferencesKey } from "./distribution.mjs";
4
7
  import { STANDARD_REPO_OPTION_KEYS } from "./options.mjs";
5
- import { throwIfAborted, withRetry } from "./resilience.mjs";
6
8
  import { runChunkedPurge } from "./purge.mjs";
7
- export { PLUGIN_ORDER_CONSTRAINTS, RepositoryBase, STANDARD_REPO_OPTION_KEYS, nestDottedKeys, nestDottedKeysAll, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
9
+ export { PLUGIN_ORDER_CONSTRAINTS, RepositoryBase, STANDARD_REPO_OPTION_KEYS, createDistributionGuard, filterReferencesKey, nestDottedKeys, nestDottedKeysAll, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
@@ -2,9 +2,10 @@ import { Filter } from "../filter/types.mjs";
2
2
  import { OffsetPaginationResult } from "../pagination/types.mjs";
3
3
  import { LookupPopulateOptions, LookupPopulateResult, LookupSpec } from "../lookup/types.mjs";
4
4
  import { UpdateInput } from "../update/types.mjs";
5
+ import { RetryPolicy } from "./resilience.mjs";
6
+ import { ArchiveOptions, ArchiveResult, ArchiveSink } from "./archive.mjs";
5
7
  import { RepoCapabilities } from "./capabilities.mjs";
6
8
  import { CacheOptions } from "../cache/options.mjs";
7
- import { RetryPolicy } from "./resilience.mjs";
8
9
 
9
10
  //#region src/repository/types.d.ts
10
11
  /**
@@ -1525,6 +1526,46 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1525
1526
  * @param options Chunking, session, progress, abort signal.
1526
1527
  */
1527
1528
  purgeByField?(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
1529
+ /**
1530
+ * Chunked cold-storage extraction — move every row matching `filter`
1531
+ * into a host-provided {@link ArchiveSink}, then remove it from the hot
1532
+ * store. THE data-lifecycle primitive for retention windows and
1533
+ * hot-table size control ("archive orders older than 18 months").
1534
+ *
1535
+ * **Write-before-delete.** Rows leave the hot store only after the sink
1536
+ * acknowledged the chunk — a crash re-archives the same chunk (sinks
1537
+ * must be duplicate-tolerant); data is never lost. At-least-once, the
1538
+ * same envelope semantics as `purgeByField`.
1539
+ *
1540
+ * **Index requirement.** Same as purge: the filter's leading field(s)
1541
+ * must be indexed or every chunk re-scans the table.
1542
+ *
1543
+ * **When NOT to use it.** On Postgres with time-partitioned tables,
1544
+ * detaching/dropping a partition archives a billion rows in O(1) —
1545
+ * prefer that (pg_partman / Timescale retention policies) and keep this
1546
+ * method for non-partitioned tables and portable code paths.
1547
+ *
1548
+ * Optional method; gate on `capabilities.archiveByFilter`.
1549
+ *
1550
+ * @param filter Predicate selecting rows to archive.
1551
+ * @param sink Destination (archive table/collection, JSONL, ...).
1552
+ * @param options Chunking, progress, abort signal, retry.
1553
+ */
1554
+ archiveByFilter?(filter: FilterInput, sink: ArchiveSink<TDoc>, options?: ArchiveOptions): Promise<ArchiveResult>;
1555
+ /**
1556
+ * Streaming reads — an async iterator over every row matching `filter`,
1557
+ * fetched in `batchSize` chunks (keyset-progressed on SQL kits, native
1558
+ * driver cursor on Mongo) so billion-row scans never load the result
1559
+ * set into memory. Drive with `for await`; breaking out releases the
1560
+ * underlying cursor/batch loop.
1561
+ *
1562
+ * Rows inserted behind the iteration point during the scan are not
1563
+ * revisited; rows inserted ahead may appear — the usual non-snapshot
1564
+ * cursor semantics every backend shares.
1565
+ *
1566
+ * Optional method; gate on `capabilities.streaming`.
1567
+ */
1568
+ cursor?(filter?: FilterInput, options?: CursorOptions): AsyncIterable<TDoc>;
1528
1569
  /**
1529
1570
  * Heterogeneous bulk write. Stays optional — kits dispatch each op
1530
1571
  * against the appropriate driver primitive inside a single transaction;
@@ -1668,5 +1709,17 @@ interface WatchOptions {
1668
1709
  */
1669
1710
  resumeAfter?: unknown;
1670
1711
  }
1712
+ /**
1713
+ * Options for `StandardRepo.cursor()` streaming reads. The open index
1714
+ * signature lets kit-specific knobs (mongokit's `organizationId` opt-out,
1715
+ * read-preference hints) flow without widening the portable contract.
1716
+ */
1717
+ interface CursorOptions {
1718
+ /** Rows fetched per underlying batch. Default kit-specific (~100–1000). */
1719
+ batchSize?: number;
1720
+ /** Iteration order. Kits default to primary-key ascending. */
1721
+ sort?: Record<string, 1 | -1>;
1722
+ [key: string]: unknown;
1723
+ }
1671
1724
  //#endregion
1672
- export { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ChangeEvent, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, TenantPurgeOptions, TenantPurgeProgress, TenantPurgeResult, TenantPurgeStrategy, UpdateManyResult, WatchOptions, WriteOptions };
1725
+ export { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ChangeEvent, ClaimTransition, ClaimVersionTransition, CursorOptions, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, TenantPurgeOptions, TenantPurgeProgress, TenantPurgeResult, TenantPurgeStrategy, UpdateManyResult, WatchOptions, WriteOptions };
@@ -38,7 +38,7 @@
38
38
  /** What happened to a document. Field-level patches are deliberately out of
39
39
  * scope — version-checked upserts + server authority beat CRDT complexity
40
40
  * for ERP data (the Sheets/Replicache position, not the Figma one). */
41
- type ChangeOp = "upsert" | "delete";
41
+ type ChangeOp = 'upsert' | 'delete';
42
42
  interface ChangeEntry<TDoc = unknown> {
43
43
  /** Which logical collection/resource this change belongs to (e.g. `pos-order`). */
44
44
  readonly scope: string;
@@ -87,7 +87,7 @@ interface PushMutation<TDoc = unknown> {
87
87
  */
88
88
  readonly mutationId: string;
89
89
  }
90
- type PushVerdictStatus = "applied" | "already_applied" | "conflict" | "rejected";
90
+ type PushVerdictStatus = 'applied' | 'already_applied' | 'conflict' | 'rejected';
91
91
  interface PushVerdict<TDoc = unknown> {
92
92
  readonly mutationId: string;
93
93
  readonly status: PushVerdictStatus;
@@ -103,11 +103,11 @@ interface ChangeLogAppendOptions {
103
103
  }
104
104
  interface ChangeLogStore<TDoc = unknown> {
105
105
  /** Record a change. `cursor`/`at` are ASSIGNED by the store; callers pass the rest. */
106
- append(entry: Omit<ChangeEntry<TDoc>, "cursor" | "at">, options?: ChangeLogAppendOptions): Promise<ChangeEntry<TDoc>>;
106
+ append(entry: Omit<ChangeEntry<TDoc>, 'cursor' | 'at'>, options?: ChangeLogAppendOptions): Promise<ChangeEntry<TDoc>>;
107
107
  /** Entries strictly AFTER `cursor` (empty string = from the beginning). */
108
108
  since(cursor: string, options?: ChangesSinceOptions): Promise<ChangesPage<TDoc>>;
109
109
  /** The current head checkpoint — what a fresh client stores after a full load. */
110
- latestCursor(options?: Pick<ChangesSinceOptions, "tenantId" | "scopes">): Promise<string>;
110
+ latestCursor(options?: Pick<ChangesSinceOptions, 'tenantId' | 'scopes'>): Promise<string>;
111
111
  /**
112
112
  * Compact entries older than `before`, keeping per-doc latest state.
113
113
  * Returns the new HORIZON cursor: clients checkpointed before it must full-resync.
@@ -123,7 +123,7 @@ declare class CursorExpiredError extends Error {
123
123
  declare class MemoryChangeLogStore<TDoc = unknown> implements ChangeLogStore<TDoc> {
124
124
  private entries;
125
125
  private seq;
126
- append(entry: Omit<ChangeEntry<TDoc>, "cursor" | "at">, _options?: ChangeLogAppendOptions): Promise<ChangeEntry<TDoc>>;
126
+ append(entry: Omit<ChangeEntry<TDoc>, 'cursor' | 'at'>, _options?: ChangeLogAppendOptions): Promise<ChangeEntry<TDoc>>;
127
127
  since(cursor: string, options?: ChangesSinceOptions): Promise<ChangesPage<TDoc>>;
128
128
  latestCursor(): Promise<string>;
129
129
  }
@@ -35,6 +35,8 @@ function runStandardRepoConformance(harness) {
35
35
  const skipNoStddev = !aggGate || !ops?.stddev;
36
36
  const skipNoCache = !aggGate || !ops?.cache;
37
37
  const skipNoPurge = !harness.features.purgeByField;
38
+ const skipNoArchive = !harness.features.archiveByFilter;
39
+ const skipNoStreaming = !harness.features.streaming;
38
40
  describe(`[conformance] ${harness.name}`, () => {
39
41
  let ctx;
40
42
  beforeEach(async () => {
@@ -1268,6 +1270,146 @@ function runStandardRepoConformance(harness) {
1268
1270
  expect(result.processed).toBe(3);
1269
1271
  });
1270
1272
  });
1273
+ describe("archiveByFilter (cold-storage extraction)", () => {
1274
+ /** In-memory sink — duplicate-tolerant by keying on the id field. */
1275
+ const makeSink = () => {
1276
+ const byId = /* @__PURE__ */ new Map();
1277
+ let flushes = 0;
1278
+ return {
1279
+ sink: {
1280
+ write: async (docs) => {
1281
+ for (const doc of docs) byId.set(String(doc[harness.idField]), doc);
1282
+ },
1283
+ flush: async () => {
1284
+ flushes += 1;
1285
+ }
1286
+ },
1287
+ get docs() {
1288
+ return [...byId.values()];
1289
+ },
1290
+ get flushes() {
1291
+ return flushes;
1292
+ }
1293
+ };
1294
+ };
1295
+ const seedTenants = async (hot, keep) => {
1296
+ for (let i = 0; i < hot; i++) await ctx.repo.create(harness.makeDoc({
1297
+ name: `arch-${i}`,
1298
+ category: "org-cold"
1299
+ }));
1300
+ for (let i = 0; i < keep; i++) await ctx.repo.create(harness.makeDoc({
1301
+ name: `keep-${i}`,
1302
+ category: "org-hot"
1303
+ }));
1304
+ };
1305
+ it.skipIf(skipNoArchive)("moves matching rows into the sink and removes them from the hot store", async () => {
1306
+ if (!ctx.repo.archiveByFilter) return;
1307
+ await seedTenants(3, 2);
1308
+ const memory = makeSink();
1309
+ const result = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink);
1310
+ expect(result.ok).toBe(true);
1311
+ expect(result.processed).toBe(3);
1312
+ expect(memory.docs).toHaveLength(3);
1313
+ expect(await ctx.repo.count({ category: "org-cold" })).toBe(0);
1314
+ expect(await ctx.repo.count({ category: "org-hot" })).toBe(2);
1315
+ expect(memory.flushes).toBe(1);
1316
+ });
1317
+ it.skipIf(skipNoArchive)("write-before-delete: a failing sink aborts with every row still hot", async () => {
1318
+ if (!ctx.repo.archiveByFilter) return;
1319
+ await seedTenants(3, 0);
1320
+ const result = await ctx.repo.archiveByFilter({ category: "org-cold" }, { write: async () => {
1321
+ throw new Error("sink unavailable");
1322
+ } });
1323
+ expect(result.ok).toBe(false);
1324
+ expect(result.error?.phase).toBe("sink");
1325
+ expect(result.processed).toBe(0);
1326
+ expect(await ctx.repo.count({ category: "org-cold" })).toBe(3);
1327
+ });
1328
+ it.skipIf(skipNoArchive)("chunking: batchSize honored, cumulative progress, all rows sunk", async () => {
1329
+ if (!ctx.repo.archiveByFilter) return;
1330
+ await seedTenants(25, 0);
1331
+ const memory = makeSink();
1332
+ const events = [];
1333
+ expect((await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink, {
1334
+ batchSize: 10,
1335
+ onProgress: (event) => {
1336
+ events.push({
1337
+ processed: event.processed,
1338
+ chunkSize: event.chunkSize
1339
+ });
1340
+ }
1341
+ })).processed).toBe(25);
1342
+ expect(memory.docs).toHaveLength(25);
1343
+ expect(events).toEqual([
1344
+ {
1345
+ processed: 10,
1346
+ chunkSize: 10
1347
+ },
1348
+ {
1349
+ processed: 20,
1350
+ chunkSize: 10
1351
+ },
1352
+ {
1353
+ processed: 25,
1354
+ chunkSize: 5
1355
+ }
1356
+ ]);
1357
+ });
1358
+ it.skipIf(skipNoArchive)("idempotent: a second run archives 0", async () => {
1359
+ if (!ctx.repo.archiveByFilter) return;
1360
+ await seedTenants(3, 0);
1361
+ const memory = makeSink();
1362
+ expect((await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink)).processed).toBe(3);
1363
+ const second = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink);
1364
+ expect(second.ok).toBe(true);
1365
+ expect(second.processed).toBe(0);
1366
+ });
1367
+ it.skipIf(skipNoArchive)("abort signal stops between chunks; archived chunks stay archived", async () => {
1368
+ if (!ctx.repo.archiveByFilter) return;
1369
+ await seedTenants(25, 0);
1370
+ const memory = makeSink();
1371
+ const controller = new AbortController();
1372
+ const result = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink, {
1373
+ batchSize: 10,
1374
+ signal: controller.signal,
1375
+ onProgress: (event) => {
1376
+ if (event.processed === 10) controller.abort();
1377
+ }
1378
+ });
1379
+ expect(result.ok).toBe(false);
1380
+ expect(result.processed).toBe(10);
1381
+ expect(memory.docs).toHaveLength(10);
1382
+ expect(await ctx.repo.count({ category: "org-cold" })).toBe(15);
1383
+ });
1384
+ });
1385
+ describe("cursor (streaming reads)", () => {
1386
+ it.skipIf(skipNoStreaming)("iterates every matching row exactly once across batch boundaries", async () => {
1387
+ if (!ctx.repo.cursor) return;
1388
+ for (let i = 0; i < 7; i++) await ctx.repo.create(harness.makeDoc({
1389
+ name: `stream-${i}`,
1390
+ category: "streamable"
1391
+ }));
1392
+ await ctx.repo.create(harness.makeDoc({
1393
+ name: "other",
1394
+ category: "not-streamable"
1395
+ }));
1396
+ const seen = [];
1397
+ for await (const doc of ctx.repo.cursor({ category: "streamable" }, { batchSize: 3 })) seen.push(String(doc[harness.idField]));
1398
+ expect(seen).toHaveLength(7);
1399
+ expect(new Set(seen).size).toBe(7);
1400
+ });
1401
+ it.skipIf(skipNoStreaming)("breaking out early releases the iterator cleanly", async () => {
1402
+ if (!ctx.repo.cursor) return;
1403
+ for (let i = 0; i < 5; i++) await ctx.repo.create(harness.makeDoc({ name: `brk-${i}` }));
1404
+ let taken = 0;
1405
+ for await (const _doc of ctx.repo.cursor({}, { batchSize: 2 })) {
1406
+ taken += 1;
1407
+ if (taken === 2) break;
1408
+ }
1409
+ expect(taken).toBe(2);
1410
+ expect(await ctx.repo.count({})).toBe(5);
1411
+ });
1412
+ });
1271
1413
  });
1272
1414
  }
1273
1415
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.7.0",
3
+ "version": "0.8.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,