@classytic/repo-core 0.6.1 → 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,34 @@ 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
+
18
+ ## [0.7.0] - 2026-07-04
19
+
20
+ ### Added — `./sync` change-log / cursor contract
21
+
22
+ Storage-agnostic data-sync spine for offline-first + incremental replication:
23
+ `ChangeLogStore`, `ChangeEntry` (tombstone deletes), pull `ChangesPage`
24
+ (opaque exclusive cursors, `hasMore` paging), Replicache-style `PushMutation`
25
+ / `PushVerdict` (idempotent client mutation ids, server-authoritative
26
+ conflicts), `CursorExpiredError` (compaction horizon → full resync), and the
27
+ `MemoryChangeLogStore` reference impl pinning the contract semantics.
28
+
29
+ Synthesized from CouchDB `_changes` (tombstones/checkpoints), Drive/Graph
30
+ delta APIs (opaque server-issued cursors), Mongo change-stream resume tokens,
31
+ and Replicache push/pull. Contract only — kits (mongokit/prismakit/sqlitekit)
32
+ implement capture + durable stores via repository plugins; arc re-exports the
33
+ surface at `@classytic/arc/sync`; sync HTTP endpoints are an arc module.
34
+
7
35
  ## [0.6.1] - 2026-07-04
8
36
 
9
37
  ### Changed — `TenantConfig` optionals widened to `T | undefined` (P10)
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 };
@@ -0,0 +1,131 @@
1
+ //#region src/sync/index.d.ts
2
+ /**
3
+ * Change-log / cursor contract — the data-sync spine for offline-first and
4
+ * incremental replication (`arc-sync`-style capability packages, POS offline,
5
+ * bulk delta export, cross-service read models).
6
+ *
7
+ * Synthesized from the proven protocols, taking the best of each:
8
+ *
9
+ * - CouchDB `_changes` → append-only feed, TOMBSTONES for deletes,
10
+ * client checkpoints
11
+ * - Drive/Graph delta APIs → OPAQUE server-issued cursors (`deltaLink` /
12
+ * `startPageToken`) — clients never parse them
13
+ * - Mongo change streams → resumable, ordered, per-scope feeds
14
+ * - Replicache push/pull → client mutation ids for idempotent PUSH,
15
+ * server-authoritative conflict verdicts
16
+ *
17
+ * CONTRACT ONLY — storage-agnostic (repo-core is the cross-kit contract home,
18
+ * like DataAdapter/EventTransport/IdempotencyStore). `MemoryChangeLogStore` is
19
+ * the reference impl. Durable stores + capture plugins live in the KITS
20
+ * (mongokit/prismakit/sqlitekit repository plugins); the HTTP surface
21
+ * (`/sync/pull`, `/sync/push`) is an arc module; arc re-exports this contract
22
+ * at `@classytic/arc/sync`. repo-core is source of truth.
23
+ *
24
+ * ## Required semantics (stores MUST honor)
25
+ *
26
+ * 1. **Cursors are opaque, totally ordered per store.** Clients echo them
27
+ * verbatim. A cursor from one store/scope-set is meaningless elsewhere.
28
+ * 2. **`append` is ordered and atomic with the business write** when a
29
+ * `session` is provided (same transaction — the outbox discipline).
30
+ * 3. **Deletes are tombstones**, never gaps. A client at cursor C must be able
31
+ * to converge by applying every entry after C — including deletions.
32
+ * 4. **`since` is exclusive** of the given cursor and returns entries in
33
+ * cursor order. `hasMore: true` means call again with the new cursor.
34
+ * 5. **Compaction (`prune`) may drop superseded intermediate versions but
35
+ * NEVER the latest state or an un-consumed tombstone horizon** — a store
36
+ * advertises its horizon so clients older than it do a full resync.
37
+ */
38
+ /** What happened to a document. Field-level patches are deliberately out of
39
+ * scope — version-checked upserts + server authority beat CRDT complexity
40
+ * for ERP data (the Sheets/Replicache position, not the Figma one). */
41
+ type ChangeOp = 'upsert' | 'delete';
42
+ interface ChangeEntry<TDoc = unknown> {
43
+ /** Which logical collection/resource this change belongs to (e.g. `pos-order`). */
44
+ readonly scope: string;
45
+ /** Document identity within the scope. */
46
+ readonly docId: string;
47
+ readonly op: ChangeOp;
48
+ /**
49
+ * Monotonic per-document version — the optimistic-concurrency token.
50
+ * Conflict rule: a push carrying `baseVersion < current` is a conflict.
51
+ */
52
+ readonly version: number;
53
+ /** Full document snapshot for `upsert`; absent for `delete` (tombstone). */
54
+ readonly doc?: TDoc;
55
+ /** Tenant partition (organizationId) — sync feeds are tenant-scoped. */
56
+ readonly tenantId?: string;
57
+ /** Server clock at capture — informational; ORDERING comes from the cursor. */
58
+ readonly at: Date;
59
+ /** Opaque position of THIS entry in the feed (assigned by the store). */
60
+ readonly cursor: string;
61
+ }
62
+ interface ChangesSinceOptions {
63
+ /** Max entries to return. Stores should default sensibly (e.g. 500). */
64
+ readonly limit?: number;
65
+ /** Restrict to these scopes (a client syncs the resources it opted into). */
66
+ readonly scopes?: readonly string[];
67
+ /** Tenant partition — REQUIRED by multi-tenant stores. */
68
+ readonly tenantId?: string;
69
+ }
70
+ interface ChangesPage<TDoc = unknown> {
71
+ readonly changes: ReadonlyArray<ChangeEntry<TDoc>>;
72
+ /** Checkpoint AFTER applying this page — echo into the next `since`. */
73
+ readonly cursor: string;
74
+ /** True → more entries exist; pull again immediately. */
75
+ readonly hasMore: boolean;
76
+ }
77
+ interface PushMutation<TDoc = unknown> {
78
+ readonly scope: string;
79
+ readonly docId: string;
80
+ readonly op: ChangeOp;
81
+ /** Version the client last saw — the optimistic-concurrency precondition. */
82
+ readonly baseVersion?: number;
83
+ readonly doc?: TDoc;
84
+ /**
85
+ * Client-unique id (`<clientId>:<seq>`) — replays of the same id MUST be
86
+ * acknowledged as already-applied, never re-executed (at-least-once safe).
87
+ */
88
+ readonly mutationId: string;
89
+ }
90
+ type PushVerdictStatus = 'applied' | 'already_applied' | 'conflict' | 'rejected';
91
+ interface PushVerdict<TDoc = unknown> {
92
+ readonly mutationId: string;
93
+ readonly status: PushVerdictStatus;
94
+ /** Authoritative post-push version (also on conflict: the WINNING version). */
95
+ readonly version?: number;
96
+ /** Authoritative doc on conflict so the client can rebase (server wins). */
97
+ readonly current?: TDoc;
98
+ readonly reason?: string;
99
+ }
100
+ interface ChangeLogAppendOptions {
101
+ /** DB session/transaction handle — append atomically with the business write. */
102
+ readonly session?: unknown;
103
+ }
104
+ interface ChangeLogStore<TDoc = unknown> {
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>>;
107
+ /** Entries strictly AFTER `cursor` (empty string = from the beginning). */
108
+ since(cursor: string, options?: ChangesSinceOptions): Promise<ChangesPage<TDoc>>;
109
+ /** The current head checkpoint — what a fresh client stores after a full load. */
110
+ latestCursor(options?: Pick<ChangesSinceOptions, 'tenantId' | 'scopes'>): Promise<string>;
111
+ /**
112
+ * Compact entries older than `before`, keeping per-doc latest state.
113
+ * Returns the new HORIZON cursor: clients checkpointed before it must full-resync.
114
+ */
115
+ prune?(before: Date): Promise<string>;
116
+ }
117
+ /** Client checkpoint older than the store's compaction horizon → full resync. */
118
+ declare class CursorExpiredError extends Error {
119
+ readonly cursor: string;
120
+ readonly horizon: string;
121
+ constructor(cursor: string, horizon: string);
122
+ }
123
+ declare class MemoryChangeLogStore<TDoc = unknown> implements ChangeLogStore<TDoc> {
124
+ private entries;
125
+ private seq;
126
+ append(entry: Omit<ChangeEntry<TDoc>, 'cursor' | 'at'>, _options?: ChangeLogAppendOptions): Promise<ChangeEntry<TDoc>>;
127
+ since(cursor: string, options?: ChangesSinceOptions): Promise<ChangesPage<TDoc>>;
128
+ latestCursor(): Promise<string>;
129
+ }
130
+ //#endregion
131
+ export { ChangeEntry, ChangeLogAppendOptions, ChangeLogStore, ChangeOp, ChangesPage, ChangesSinceOptions, CursorExpiredError, MemoryChangeLogStore, PushMutation, PushVerdict, PushVerdictStatus };
@@ -0,0 +1,41 @@
1
+ //#region src/sync/index.ts
2
+ /** Client checkpoint older than the store's compaction horizon → full resync. */
3
+ var CursorExpiredError = class extends Error {
4
+ constructor(cursor, horizon) {
5
+ super(`[repo-core:sync] cursor "${cursor}" predates the compaction horizon — full resync required.`);
6
+ this.cursor = cursor;
7
+ this.horizon = horizon;
8
+ this.name = "CursorExpiredError";
9
+ }
10
+ };
11
+ var MemoryChangeLogStore = class {
12
+ entries = [];
13
+ seq = 0;
14
+ async append(entry, _options) {
15
+ const cursor = String(++this.seq).padStart(16, "0");
16
+ const full = {
17
+ ...entry,
18
+ cursor,
19
+ at: /* @__PURE__ */ new Date()
20
+ };
21
+ this.entries.push(full);
22
+ return full;
23
+ }
24
+ async since(cursor, options = {}) {
25
+ const { limit = 500, scopes, tenantId } = options;
26
+ const filtered = this.entries.filter((e) => e.cursor > cursor && (!scopes || scopes.includes(e.scope)) && (tenantId === void 0 || e.tenantId === tenantId));
27
+ const page = filtered.slice(0, limit);
28
+ const last = page[page.length - 1];
29
+ return {
30
+ changes: page,
31
+ cursor: last ? last.cursor : cursor,
32
+ hasMore: filtered.length > page.length
33
+ };
34
+ }
35
+ async latestCursor() {
36
+ const last = this.entries[this.entries.length - 1];
37
+ return last ? last.cursor : "";
38
+ }
39
+ };
40
+ //#endregion
41
+ export { CursorExpiredError, MemoryChangeLogStore };
@@ -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.6.1",
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,
@@ -94,7 +94,11 @@
94
94
  "types": "./dist/lock/index.d.mts",
95
95
  "default": "./dist/lock/index.mjs"
96
96
  },
97
- "./package.json": "./package.json"
97
+ "./package.json": "./package.json",
98
+ "./sync": {
99
+ "types": "./dist/sync/index.d.mts",
100
+ "default": "./dist/sync/index.mjs"
101
+ }
98
102
  },
99
103
  "keywords": [
100
104
  "repository",