@classytic/repo-core 0.10.0 → 0.12.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,38 @@ 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.12.0] - 2026-07-15
8
+
9
+ ### Added — `StandardRepo.transition?()` contract (state-machine CAS with history)
10
+
11
+ - **`TransitionMachine`** — the canonical structural contract for the state
12
+ machine (`{ name, assertTransition }`). Primitives'
13
+ `defineStateMachine()` output and mongokit's local `TransitionMachine`
14
+ are structurally identical by design; nobody imports anybody (same
15
+ policy as `ClaimTransition`).
16
+ - **`TransitionArgs`** — `{ from, to, field?, set?, push?, where?, by?,
17
+ note?, history?, at? }`.
18
+ - **`StandardRepo.transition?(id, machine, args, options)`** — OPTIONAL
19
+ member declaring the state-machine-backed CAS transition mongokit 3.22
20
+ ships as a class primitive: legality pre-flight via the machine (which
21
+ throws the domain's typed error), CAS via `claim`, `$push`ed status
22
+ history, and accurate race-loss diagnosis (machine error from the row's
23
+ CURRENT state / 404 `TRANSITION_TARGET_MISSING` / 409
24
+ `TRANSITION_RACE_LOST`). Unlike `claim`/`claimVersion` it THROWS rather
25
+ than returning `null` — it owns the error liturgy domain packages used
26
+ to copy-paste. Promote to required once sqlitekit implements it.
27
+
28
+ ### Added — `./usage` subpath (period-bucketed counter contract)
29
+
30
+ - **`UsageStore`** — driver-agnostic interface for atomic period-bucketed counters: `increment(bucket, amount)` + `summary(actor, period)`. The storage seam under platform accounting (quotas, plan enforcement, usage-based billing). One cell = `(actor, period, kind)`; one write = atomic upsert; one read = all counters for an actor-period pair. Kits ship adapters (`@classytic/mongokit/usage`, `@classytic/sqlitekit/usage`, …) without depending on arc; `@classytic/arc/usage` consumes this contract structurally.
31
+ - **`UsageBucket`** — `{ actor, period, kind }` tuple. `kind` is dot-namespaced: `api.requests`, `ai.tokens.input`, `storage.egress.bytes`.
32
+ - **`usagePeriod(date?)`** — canonical UTC calendar-month key (`"2026-07"`). Monthly is the billing-native granularity.
33
+ - **`createMemoryUsageStore()`** — in-process reference implementation for tests and single-instance apps. Returns `UsageStore & { clear() }`. Multi-replica deployments need a shared adapter.
34
+
35
+ ### Added — `runUsageStoreContract` in `./testing`
36
+
37
+ - **`runUsageStoreContract(harness)`** — cross-kit conformance suite. Kits import once and pass their adapter; all canonical scenarios run automatically. Same pattern as `runLockAdapterConformance`. `UsageConformanceHarness` is exported from `./testing` for harness typing.
38
+
7
39
  ## [0.10.0] - 2026-07-13
8
40
 
9
41
  ### Added — `ValidationErrorMeta.path` + `.meta` (field-scoped validation errors)
@@ -6,8 +6,8 @@ import { ArchiveOptions, ArchivePort, ArchiveProgress, ArchiveResult, ArchiveSin
6
6
  import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
7
7
  import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
8
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";
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, TransitionArgs, TransitionMachine, UpdateManyResult, WatchOptions, WriteOptions } from "./types.mjs";
10
10
  import { DistributionConfig, DistributionMissHandler, createDistributionGuard, filterReferencesKey } from "./distribution.mjs";
11
11
  import { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey } from "./options.mjs";
12
12
  import { PurgePort, WritingPurgeStrategy, runChunkedPurge } from "./purge.mjs";
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 };
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 TransitionArgs, type TransitionMachine, type UpdateInput, type UpdateManyResult, type WatchOptions, type WriteOptions, type WritingPurgeStrategy, createDistributionGuard, filterReferencesKey, nestDottedKeys, nestDottedKeysAll, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
@@ -342,6 +342,46 @@ interface ClaimTransition {
342
342
  */
343
343
  where?: Record<string, unknown>;
344
344
  }
345
+ /**
346
+ * Structural contract for the state machine consumed by
347
+ * `StandardRepo.transition()` — the CANONICAL shape; mongokit's
348
+ * `TransitionMachine` and primitives' `StateMachine` (from
349
+ * `@classytic/primitives/state-machine`, whose `defineStateMachine()`
350
+ * satisfies it as-is) are structurally identical by design. Neither
351
+ * repo-core nor the kits import primitives — compatibility is
352
+ * structural, same policy as `ClaimTransition` above.
353
+ *
354
+ * `assertTransition` MUST throw (the domain's typed error, via the
355
+ * machine's own error factory) when `from → to` is illegal. Kits never
356
+ * invent their own transition-error vocabulary — the machine owns it.
357
+ */
358
+ interface TransitionMachine {
359
+ /** Aggregate name — surfaces in race/missing error messages. */
360
+ readonly name: string;
361
+ assertTransition(entityId: string, from: string, to: string): void;
362
+ }
363
+ /**
364
+ * Args for `StandardRepo.transition()` — the state-machine-backed CAS
365
+ * with status history. See the method JSDoc for semantics.
366
+ */
367
+ interface TransitionArgs {
368
+ from: string | readonly string[];
369
+ to: string;
370
+ /** State field (dotted paths allowed on kits that support them). Default `'status'`. */
371
+ field?: string;
372
+ /** Set-payload applied alongside the state write (field-shape). */
373
+ set?: Record<string, unknown>;
374
+ /** Kit-specific extra append entries merged beside the history append. */
375
+ push?: Record<string, unknown>;
376
+ /** Additional CAS guards, AND-merged into the match (same as `ClaimTransition.where`). */
377
+ where?: Record<string, unknown>;
378
+ by?: string;
379
+ note?: string;
380
+ /** History field name (default `'statusHistory'`), or `false` to skip the append. */
381
+ history?: string | false;
382
+ /** Timestamp for the history entry — default `now`. */
383
+ at?: Date;
384
+ }
345
385
  /**
346
386
  * Transition spec for `StandardRepo.claimVersion()` — optimistic-
347
387
  * concurrency CAS via a version stamp. Sibling to `ClaimTransition`;
@@ -1410,6 +1450,27 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1410
1450
  * same shape and compile down to flat column writes.
1411
1451
  */
1412
1452
  claimVersion(id: string, transition: ClaimVersionTransition, update: Record<string, unknown>, options?: WriteOptions): Promise<TDoc | null>;
1453
+ /**
1454
+ * State-machine-backed CAS transition with status history — the
1455
+ * canonical domain-verb shape (mongokit 3.22 `Repository.transition`).
1456
+ * One call: machine legality pre-flight (the machine throws the
1457
+ * DOMAIN's typed error), CAS via `claim`, appended history entry
1458
+ * (`{ status: to, occurredAt, by?, note? }` onto `args.history` /
1459
+ * `statusHistory`), and accurate race-loss diagnosis — on CAS miss
1460
+ * the kit re-reads the row and throws the machine's error from the
1461
+ * row's CURRENT state, a 404-shaped `TRANSITION_TARGET_MISSING`
1462
+ * error when the row vanished, or a 409 `TRANSITION_RACE_LOST`
1463
+ * when the move is still legal (caller may re-read and retry).
1464
+ *
1465
+ * Unlike `claim`/`claimVersion`, this method THROWS instead of
1466
+ * returning `null` — it exists precisely to own the error liturgy
1467
+ * that every domain package copy-pasted around `claim`.
1468
+ *
1469
+ * **Optional (for now).** Mongokit ≥3.22 ships it; promote to
1470
+ * required once sqlitekit implements it (SQL kits compile the
1471
+ * history append to their JSON-array/audit-table strategy).
1472
+ */
1473
+ transition?(id: string, machine: TransitionMachine, args: TransitionArgs, options?: WriteOptions): Promise<TDoc>;
1413
1474
  /**
1414
1475
  * Classify an error from a write as a unique-constraint violation.
1415
1476
  * Arc's idempotency + outbox adapters need this to distinguish
@@ -1750,4 +1811,4 @@ interface CursorOptions {
1750
1811
  [key: string]: unknown;
1751
1812
  }
1752
1813
  //#endregion
1753
- 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 };
1814
+ 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, TransitionArgs, TransitionMachine, UpdateManyResult, WatchOptions, WriteOptions };
@@ -2,4 +2,5 @@ import { AggregateOpsSupport } from "../repository/capabilities.mjs";
2
2
  import { ConformanceContext, ConformanceDoc, ConformanceFeatures, ConformanceHarness } from "./types.mjs";
3
3
  import { runStandardRepoConformance } from "./conformance.mjs";
4
4
  import { LockConformanceHarness, runLockAdapterConformance } from "./lock-conformance.mjs";
5
- export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type LockConformanceHarness, runLockAdapterConformance, runStandardRepoConformance };
5
+ import { UsageConformanceHarness, runUsageStoreContract } from "./usage-conformance.mjs";
6
+ export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type LockConformanceHarness, type UsageConformanceHarness, runLockAdapterConformance, runStandardRepoConformance, runUsageStoreContract };
@@ -1,3 +1,4 @@
1
1
  import { runStandardRepoConformance } from "./conformance.mjs";
2
2
  import { runLockAdapterConformance } from "./lock-conformance.mjs";
3
- export { runLockAdapterConformance, runStandardRepoConformance };
3
+ import { runUsageStoreContract } from "./usage-conformance.mjs";
4
+ export { runLockAdapterConformance, runStandardRepoConformance, runUsageStoreContract };
@@ -0,0 +1,18 @@
1
+ import { UsageStore } from "../usage/index.mjs";
2
+ //#region src/testing/usage-conformance.d.ts
3
+ interface UsageConformanceHarness {
4
+ /**
5
+ * Construct the store under test. May be async (SQL migrations,
6
+ * index creation). The same instance is shared by every test —
7
+ * `beforeEach` clears residual counters.
8
+ */
9
+ createStore(): UsageStore | Promise<UsageStore>;
10
+ /**
11
+ * Wipe every counter between tests. Mongo: `deleteMany({})`.
12
+ * SQL: `DELETE FROM kit_usage`. Memory: `clear()`.
13
+ */
14
+ beforeEach?(store: UsageStore): void | Promise<void>;
15
+ }
16
+ declare function runUsageStoreContract(harness: UsageConformanceHarness): void;
17
+ //#endregion
18
+ export { UsageConformanceHarness, runUsageStoreContract };
@@ -0,0 +1,101 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ //#region src/testing/usage-conformance.ts
3
+ /**
4
+ * `runUsageStoreContract` — cross-kit usage-store contract suite.
5
+ *
6
+ * Wires a kit-specific harness to the canonical scenarios every
7
+ * `UsageStore` implementation must pass — so "swap mongokit/usage for
8
+ * sqlitekit/usage" is a provable claim and drift shows up here before
9
+ * it ships. Written ONCE here; kits import it instead of hand-writing
10
+ * conformance (same shape as `runLockAdapterConformance`).
11
+ *
12
+ * ## Usage from a kit
13
+ *
14
+ * import { runUsageStoreContract } from '@classytic/repo-core/testing';
15
+ * import { createMongoUsageStore } from '../../src/usage/index.js';
16
+ *
17
+ * describe('mongokit/usage conformance', () => {
18
+ * runUsageStoreContract({
19
+ * createStore: () => createMongoUsageStore({ connection }),
20
+ * async beforeEach() { await clearCounters(); },
21
+ * });
22
+ * });
23
+ */
24
+ function runUsageStoreContract(harness) {
25
+ describe("UsageStore contract", () => {
26
+ let store;
27
+ beforeEach(async () => {
28
+ store = await harness.createStore();
29
+ await harness.beforeEach?.(store);
30
+ });
31
+ it("accumulates increments per (actor, period, kind)", async () => {
32
+ const bucket = {
33
+ actor: "org-1",
34
+ period: "2026-07",
35
+ kind: "api.requests"
36
+ };
37
+ await store.increment(bucket, 1);
38
+ await store.increment(bucket, 2);
39
+ await store.increment({
40
+ ...bucket,
41
+ kind: "ai.tokens.input"
42
+ }, 500);
43
+ expect(await store.summary("org-1", "2026-07")).toEqual({
44
+ "api.requests": 3,
45
+ "ai.tokens.input": 500
46
+ });
47
+ });
48
+ it("treats a missing bucket as 0 (first increment creates it)", async () => {
49
+ await store.increment({
50
+ actor: "a",
51
+ period: "2026-07",
52
+ kind: "k"
53
+ }, 7);
54
+ expect(await store.summary("a", "2026-07")).toEqual({ k: 7 });
55
+ });
56
+ it("isolates actors and periods", async () => {
57
+ await store.increment({
58
+ actor: "org-1",
59
+ period: "2026-06",
60
+ kind: "k"
61
+ }, 5);
62
+ await store.increment({
63
+ actor: "org-1",
64
+ period: "2026-07",
65
+ kind: "k"
66
+ }, 7);
67
+ await store.increment({
68
+ actor: "org-2",
69
+ period: "2026-07",
70
+ kind: "k"
71
+ }, 11);
72
+ expect(await store.summary("org-1", "2026-06")).toEqual({ k: 5 });
73
+ expect(await store.summary("org-1", "2026-07")).toEqual({ k: 7 });
74
+ expect(await store.summary("org-2", "2026-07")).toEqual({ k: 11 });
75
+ });
76
+ it("dotted kind names round-trip exactly (no path nesting)", async () => {
77
+ await store.increment({
78
+ actor: "a",
79
+ period: "2026-07",
80
+ kind: "storage.egress.bytes"
81
+ }, 42);
82
+ const summary = await store.summary("a", "2026-07");
83
+ expect(summary["storage.egress.bytes"]).toBe(42);
84
+ expect(Object.keys(summary)).toEqual(["storage.egress.bytes"]);
85
+ });
86
+ it("returns {} for unknown actors/periods (never throws)", async () => {
87
+ expect(await store.summary("nobody", "2099-01")).toEqual({});
88
+ });
89
+ it("increment is atomic per bucket under concurrency", async () => {
90
+ const bucket = {
91
+ actor: "org-c",
92
+ period: "2026-07",
93
+ kind: "api.requests"
94
+ };
95
+ await Promise.all(Array.from({ length: 50 }, () => store.increment(bucket, 1)));
96
+ expect((await store.summary("org-c", "2026-07"))["api.requests"]).toBe(50);
97
+ });
98
+ });
99
+ }
100
+ //#endregion
101
+ export { runUsageStoreContract };
@@ -0,0 +1,77 @@
1
+ //#region src/usage/index.d.ts
2
+ /**
3
+ * Usage-counter contract for the @classytic ecosystem.
4
+ *
5
+ * Period-bucketed counters per actor — the storage seam under
6
+ * platform accounting (quotas, plan enforcement, usage-based
7
+ * billing). One cell = `(actor, period, kind)`; one write op =
8
+ * atomic increment; one read op = all counters for an actor-period.
9
+ *
10
+ * ## Why this lives in repo-core
11
+ *
12
+ * Same reasoning as `./lock`: the contract is driver-free — any
13
+ * store with an atomic increment-upsert implements it (Mongo `$inc`
14
+ * upsert, SQL `ON CONFLICT ... DO UPDATE SET n = n + ?`, Redis
15
+ * `HINCRBY`). Kits ship their adapters (`@classytic/mongokit/usage`,
16
+ * `@classytic/sqlitekit/usage`, ...) WITHOUT depending on arc;
17
+ * `@classytic/arc/usage`'s `usagePlugin` consumes the contract
18
+ * structurally (its local `UsageStore` mirrors this shape the same
19
+ * way its `ScheduleLockLike` mirrors `LockAdapter`) so arc's
20
+ * repo-core peer floor never bumps for it.
21
+ *
22
+ * Distinct from itemized event/usage RECORD stores (e.g.
23
+ * `@classytic/arc-ai/usage`'s per-run records): this contract holds
24
+ * AGGREGATES only. Itemized layers sink into it; they don't replace it.
25
+ *
26
+ * ## Semantics
27
+ *
28
+ * - `increment` MUST be atomic per bucket (concurrent writers never
29
+ * lose counts) and MUST treat a missing bucket as `0`.
30
+ * - `summary` returns `{}` (never throws) for unknown actors/periods.
31
+ * - `period` keys are opaque strings to the store; `usagePeriod()`
32
+ * is the ecosystem's canonical key (UTC calendar month, `2026-07`).
33
+ * Stores wanting finer windows shard internally without changing
34
+ * the contract.
35
+ * - Sync-or-async: memory adapter is sync; DB adapters async;
36
+ * consumers `await` either way.
37
+ *
38
+ * ## Why one file, not a barrel
39
+ *
40
+ * Contract + reference memory adapter + the period helper are under
41
+ * 100 LOC with no internal seams — same single-file rule as `./lock`.
42
+ */
43
+ /** One counter cell: (actor, period, kind). */
44
+ interface UsageBucket {
45
+ /** Who consumed — org / user / client id, or a host-chosen fallback key. */
46
+ actor: string;
47
+ /** Aggregation period key — canonically `usagePeriod()`'s `YYYY-MM`. */
48
+ period: string;
49
+ /** Namespaced counter, dot-separated: `api.requests`, `ai.tokens.input`, `storage.egress.bytes`. */
50
+ kind: string;
51
+ }
52
+ /** Period-bucketed usage counters. See module header for semantics. */
53
+ interface UsageStore {
54
+ /** Store name for diagnostics (e.g. 'memory', 'mongo', 'redis'). */
55
+ readonly name: string;
56
+ /** Atomically add `amount` to the bucket's counter, creating it at 0. */
57
+ increment(bucket: UsageBucket, amount: number): Promise<void> | void;
58
+ /** Every counter for an actor in a period: `{ 'api.requests': 40231, ... }`. */
59
+ summary(actor: string, period: string): Promise<Record<string, number>>;
60
+ /** Optional cleanup hook (connections, timers). */
61
+ close?(): Promise<void>;
62
+ }
63
+ /**
64
+ * Canonical period key for a date — calendar month, UTC: `2026-07`.
65
+ * Monthly is the billing-native granularity.
66
+ */
67
+ declare function usagePeriod(date?: Date): string;
68
+ /**
69
+ * In-memory reference implementation — tests and single-instance
70
+ * apps. Counters are per-process; multi-replica deployments need a
71
+ * shared adapter (kit- or Redis-backed).
72
+ */
73
+ declare function createMemoryUsageStore(): UsageStore & {
74
+ clear(): void;
75
+ };
76
+ //#endregion
77
+ export { UsageBucket, UsageStore, createMemoryUsageStore, usagePeriod };
@@ -0,0 +1,36 @@
1
+ //#region src/usage/index.ts
2
+ /**
3
+ * Canonical period key for a date — calendar month, UTC: `2026-07`.
4
+ * Monthly is the billing-native granularity.
5
+ */
6
+ function usagePeriod(date = /* @__PURE__ */ new Date()) {
7
+ return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
8
+ }
9
+ /**
10
+ * In-memory reference implementation — tests and single-instance
11
+ * apps. Counters are per-process; multi-replica deployments need a
12
+ * shared adapter (kit- or Redis-backed).
13
+ */
14
+ function createMemoryUsageStore() {
15
+ /** actor → period → kind → count */
16
+ const counters = /* @__PURE__ */ new Map();
17
+ return {
18
+ name: "memory",
19
+ increment(bucket, amount) {
20
+ const periods = counters.get(bucket.actor) ?? /* @__PURE__ */ new Map();
21
+ const kinds = periods.get(bucket.period) ?? /* @__PURE__ */ new Map();
22
+ kinds.set(bucket.kind, (kinds.get(bucket.kind) ?? 0) + amount);
23
+ periods.set(bucket.period, kinds);
24
+ counters.set(bucket.actor, periods);
25
+ },
26
+ async summary(actor, period) {
27
+ const kinds = counters.get(actor)?.get(period);
28
+ return kinds ? Object.fromEntries(kinds) : {};
29
+ },
30
+ clear() {
31
+ counters.clear();
32
+ }
33
+ };
34
+ }
35
+ //#endregion
36
+ export { createMemoryUsageStore, usagePeriod };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.10.0",
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.",
3
+ "version": "0.12.0",
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,
7
7
  "files": [
@@ -94,6 +94,10 @@
94
94
  "types": "./dist/lock/index.d.mts",
95
95
  "default": "./dist/lock/index.mjs"
96
96
  },
97
+ "./usage": {
98
+ "types": "./dist/usage/index.d.mts",
99
+ "default": "./dist/usage/index.mjs"
100
+ },
97
101
  "./package.json": "./package.json",
98
102
  "./sync": {
99
103
  "types": "./dist/sync/index.d.mts",