@classytic/repo-core 0.7.0 → 0.8.1
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 +17 -0
- package/README.md +2 -0
- package/dist/adapter/types.d.mts +19 -1
- package/dist/filter/index.mjs +2 -2
- package/dist/filter/scope.mjs +1 -1
- package/dist/repository/archive.d.mts +107 -0
- package/dist/repository/archive.mjs +64 -0
- package/dist/repository/capabilities.d.mts +6 -0
- package/dist/repository/distribution.d.mts +48 -0
- package/dist/repository/distribution.mjs +73 -0
- package/dist/repository/index.d.mts +5 -3
- package/dist/repository/index.mjs +4 -2
- package/dist/repository/types.d.mts +55 -2
- package/dist/sync/index.d.mts +5 -5
- package/dist/testing/conformance.mjs +142 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ 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.1] - 2026-07-11
|
|
8
|
+
|
|
9
|
+
### Docs — `DataAdapter.close()` ownership rule
|
|
10
|
+
|
|
11
|
+
- Clarified the `close?()` contract (doc-only, no type/runtime change): the adapter does **not** own the database connection — the host does, and `close()` must release only adapter/kit-owned resources (TTL/vacuum timers, change streams), never disconnect a shared client/pool. Kits that genuinely own a connection gate its disposal behind an explicit opt-in. This standardizes cleanup semantics across mongokit/sqlitekit/pgkit/prismakit.
|
|
12
|
+
|
|
13
|
+
## [0.8.0] - 2026-07-08
|
|
14
|
+
|
|
15
|
+
### Added — data-lifecycle contract (archive, streaming, distribution awareness)
|
|
16
|
+
|
|
17
|
+
Additive release: existing kits on 0.7 semantics compile and pass conformance unchanged (new scenarios are capability-gated and default to skipped).
|
|
18
|
+
|
|
19
|
+
- **`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`.
|
|
20
|
+
- **`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.
|
|
21
|
+
- **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).
|
|
22
|
+
- **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).
|
|
23
|
+
|
|
7
24
|
## [0.7.0] - 2026-07-04
|
|
8
25
|
|
|
9
26
|
### 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).
|
package/dist/adapter/types.d.mts
CHANGED
|
@@ -191,7 +191,25 @@ interface DataAdapter<TDoc = unknown> {
|
|
|
191
191
|
* SQL adapters, non-Mongo operators, or kits that compile Filter IR.
|
|
192
192
|
*/
|
|
193
193
|
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
194
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* Release resources this ADAPTER (or its repository) allocated —
|
|
196
|
+
* background timers (TTL / vacuum sweepers), open change streams,
|
|
197
|
+
* kit-internal caches. Safe to call more than once.
|
|
198
|
+
*
|
|
199
|
+
* **Ownership rule (load-bearing).** The adapter does NOT own the
|
|
200
|
+
* database connection — the HOST does. The host wrote
|
|
201
|
+
* `new PrismaClient()` / `new Pool()` / `mongoose.connect()`, so the
|
|
202
|
+
* host closes it (typically in its own `onClose`). `close()` MUST NOT
|
|
203
|
+
* disconnect a connection it didn't open: several resources commonly
|
|
204
|
+
* share one client/pool, so tearing it down from one adapter would
|
|
205
|
+
* break every sibling resource. Kits that must dispose a connection
|
|
206
|
+
* they genuinely own gate it behind an explicit opt-in (e.g.
|
|
207
|
+
* prismakit's `ownsClient`).
|
|
208
|
+
*
|
|
209
|
+
* Kits with nothing kit-owned to release still MAY implement this as a
|
|
210
|
+
* documented no-op so hosts can call `adapter.close()` uniformly across
|
|
211
|
+
* every backend without feature-detecting.
|
|
212
|
+
*/
|
|
195
213
|
close?(): Promise<void>;
|
|
196
214
|
/**
|
|
197
215
|
* Optional: does the underlying schema declare a path with this name?
|
package/dist/filter/index.mjs
CHANGED
|
@@ -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 };
|
package/dist/filter/scope.mjs
CHANGED
|
@@ -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 };
|
package/dist/sync/index.d.mts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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>,
|
|
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,
|
|
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>,
|
|
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.
|
|
3
|
+
"version": "0.8.1",
|
|
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,
|