proactive-gate 0.2.5 → 0.3.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/README.md CHANGED
@@ -4,10 +4,8 @@ English | [Türkçe](README.tr.md)
4
4
 
5
5
  <p>
6
6
  <img src="https://img.shields.io/npm/v/proactive-gate?style=flat-square&color=111111&label=npm" alt="npm">
7
- <img src="https://img.shields.io/npm/dm/proactive-gate?style=flat-square&color=111111" alt="npm downloads">
8
7
  <img src="https://img.shields.io/github/actions/workflow/status/Bubblegunn/proactive-gate/ci.yml?style=flat-square&color=111111&label=ci" alt="ci">
9
8
  <img src="https://img.shields.io/bundlephobia/minzip/proactive-gate?style=flat-square&color=111111" alt="minzipped size">
10
- <img src="https://img.shields.io/github/stars/Bubblegunn/proactive-gate?style=flat-square&color=111111" alt="stars">
11
9
  <img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT">
12
10
  <a href="https://doi.org/10.5281/zenodo.22393512"><img src="https://img.shields.io/badge/DOI-10.5281%2Fzenodo.22393512-111111?style=flat-square" alt="DOI"></a>
13
11
  </p>
@@ -49,6 +47,13 @@ preset you named, and prints the preset's own source next to the few lines that
49
47
  the gate into that framework. `npx proactive-gate init --list` shows the fourteen
50
48
  platform and legal presets and the four frameworks.
51
49
 
50
+ If you would rather see the argument than read it: `npm run bench:compare` replays a
51
+ committed day through this gate and through five hand-written `if` statements, and prints
52
+ the six places they disagree: a critical alert the cap should have let through, a two-day-old
53
+ account, a snooze, a run of dismissals, and a local day boundary that silences one user and
54
+ pays another twice. The output, and why none of the six is a matter of taste, is in
55
+ [Compared with hand-rolled checks](#compared-with-hand-rolled-checks-and-feature-flags).
56
+
52
57
  Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
53
58
  between "the model produced something" and "the user's phone buzzed", whichever
54
59
  model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
@@ -433,8 +438,29 @@ Set `windowSeconds` from your own transport's retry horizon.
433
438
  `MemoryStore` keeps values in process memory and is useful for a single instance. `RedisStore`
434
439
  shares values across instances. `SqliteStore` persists values in a SQLite database without
435
440
  adding a package dependency. It was contributed by
436
- [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) in [#3](https://github.com/Bubblegunn/proactive-gate/pull/3). `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
441
+ [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) in [#3](https://github.com/Bubblegunn/proactive-gate/pull/3). `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
437
442
  is loaded only when the store is constructed so the package can still be used on Node.js 20. On Node 22 the module prints an ExperimentalWarning on first use; it is stable from Node 24.
443
+ Expired rows are removed when a read touches them, and every `set` or `incr` first removes the
444
+ rows that have already expired, so a key nobody reads again still disappears: a year of daily
445
+ budget keys leaves the live rows in the table rather than one dead row per day. The write-time sweep and its partial index were contributed by
446
+ [@LouisDeconinck](https://github.com/LouisDeconinck) in [#27](https://github.com/Bubblegunn/proactive-gate/pull/27).
447
+
448
+ **Writing your own store?** `proactive-gate/store-contract` exports the same suite these three are
449
+ held to, so you can prove yours behaves rather than hope:
450
+
451
+ ```ts
452
+ import { storeContract } from "proactive-gate/store-contract";
453
+ storeContract("PostgresStore", (clock) => new PostgresStore({ clock }));
454
+ ```
455
+
456
+ It checks `get`, `set` and `del`, `incr` from an absent key, concurrent `incr` atomicity, the
457
+ expiry boundary, and that a TTL given to `set` and one given to `incr` agree, then replays a
458
+ seeded random operation sequence against `MemoryStore`. A store whose backend owns the clock
459
+ passes `expiry: "skip"` and those cases are reported as skipped rather than quietly dropped. It
460
+ was contributed by [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) in
461
+ [#24](https://github.com/Bubblegunn/proactive-gate/pull/24), and lives on its own subpath so
462
+ importing the package never pulls `node:test` into your bundle. See
463
+ [docs/store-contract.md](docs/store-contract.md).
438
464
 
439
465
  ## Fail open, on purpose
440
466
 
@@ -546,6 +572,15 @@ candidates with the clock taken from each line, and `node examples/ai-sdk/run.mj
546
572
  day of tool-approval requests, one of which is a critical alert that a legal window (the TCPA
547
573
  preset) still refuses. Both are part of `npm run examples` and of the test suite.
548
574
 
575
+ The other four, the `.ts` files, are illustrations rather than fixtures: they import
576
+ `@langchain/langgraph`, `@mastra/core` and the AI SDK, none of which is a dependency here, so
577
+ they are neither compiled nor executed and this README does not claim they are. What *is*
578
+ checked is the half we control: `test/example-imports.test.mjs` asserts that every symbol they
579
+ import from `proactive-gate` still exists, as a value or as a type, so renaming an export
580
+ cannot leave a published snippet quietly telling readers to import something that is gone. It
581
+ cannot tell you a framework changed its own API, and installing four agent frameworks to
582
+ type-check four snippets is a worse trade than saying which files are executed.
583
+
549
584
  ## Python
550
585
 
551
586
  ```
@@ -720,11 +755,14 @@ trace, and adds the part they leave out: the budget consumed at send time.
720
755
  ## Thanks
721
756
 
722
757
  Two people sent pull requests on the day this was published, neither of whom I had spoken to
723
- before. [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) wrote `SqliteStore`
758
+ before. [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) wrote `SqliteStore`
724
759
  ([#3](https://github.com/Bubblegunn/proactive-gate/pull/3)) and
725
760
  [@edwardsong08](https://github.com/edwardsong08) wrote the weekly budget
726
761
  ([#9](https://github.com/Bubblegunn/proactive-gate/pull/9)). Both shipped in 0.1.2 and are in
727
- every release since, including the one you install today.
762
+ every release since, including the one you install today. @Aaqibhafeezkhan came back for a
763
+ second one and wrote the store contract suite in [#24](https://github.com/Bubblegunn/proactive-gate/pull/24).
764
+
765
+ A third person arrived from the other direction. [@LouisDeconinck](https://github.com/LouisDeconinck) took [#26](https://github.com/Bubblegunn/proactive-gate/issues/26), an issue this project filed against itself to admit that expired rows were never cleaned up, and twelve minutes later sent the fix in both languages with the test that would have caught the original bug ([#27](https://github.com/Bubblegunn/proactive-gate/pull/27), in 0.3.1).
728
766
 
729
767
  ## Cite this
730
768
 
package/README.tr.md CHANGED
@@ -37,6 +37,14 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
37
37
  [bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). Python:
38
38
  [`python/`](python/README.md).
39
39
 
40
+ Okumak yerine görmeyi tercih ederseniz: `npm run bench:compare`, kayıtlı bir günü hem bu kapıdan
41
+ hem de elle yazılmış beş `if` ifadesinden geçirir ve ayrıştıkları altı yeri yazdırır: kotanın
42
+ geçirmesi gereken kritik bir uyarı, iki günlük bir hesap, bir erteleme, arka arkaya gelen
43
+ reddedişler, ve bir kullanıcıyı susturup bir diğerine iki kat mesaj veren yerel gün sınırı. Altısının da
44
+ neden zevk meselesi olmadığı, bu dosyanın kısaltılmış olması nedeniyle yalnızca İngilizce
45
+ README'de anlatılıyor: [Compared with hand-rolled
46
+ checks](README.md#compared-with-hand-rolled-checks-and-feature-flags).
47
+
40
48
  ## Bir karar neye benzer
41
49
 
42
50
  ```ts
@@ -330,6 +338,16 @@ yazmadığı bir sayıyı kodlardı; o yüzden yok.
330
338
  Adaptörler framework paketine değil, çağrının biçimine göre tiplenmiştir; başka bir şey
331
339
  kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi tüketir.
332
340
 
341
+ Örneklerden ikisi framework kurulu olmadan ve ağ olmadan çalışır: `node examples/mastra/run.mjs`
342
+ ve `node examples/ai-sdk/run.mjs`. İkisi de `npm run examples` ve test paketinin parçası.
343
+ Diğer dört `.ts` dosyası ise fikstür değil, örnekleme: `@langchain/langgraph`, `@mastra/core` ve
344
+ AI SDK'yı içe aktarıyorlar, hiçbiri buranın bağımlılığı değil, dolayısıyla ne derleniyor ne
345
+ çalıştırılıyorlar ve bu README onların çalıştığını iddia etmiyor. Denetlenen şey, bizim
346
+ denetleyebildiğimiz yarısı: `test/example-imports.test.mjs`, bu dosyaların `proactive-gate`'ten içe
347
+ aktardığı her sembolün değer ya da tip olarak hâlâ var olduğunu doğrular, böylece bir export'un adı
348
+ değiştiğinde yayımlanmış bir örnek okuyucuya artık var olmayan bir şeyi içe aktarmasını söylemeye
349
+ sessizce devam edemez.
350
+
333
351
  ## Python
334
352
 
335
353
  ```
@@ -337,8 +355,10 @@ pip install proactive-gate
337
355
  ```
338
356
 
339
357
  Yayınlanmamış bir durumu denemek için depodan kurulur: `pip install "proactive-gate @
340
- git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. Yayınlanan sürüm yerel bir
341
- derlemeden token ile yüklendi; npm paketinin aksine derleme kanıtı taşımıyor.
358
+ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. Python paketi npm paketiyle
359
+ aynı workflow tarafından yayımlanıyor, dolayısıyla her dosyayı hangi deponun ve hangi workflow'un
360
+ ürettiğini adlandıran PyPI yayın attestation'ları taşıyor. 0.2.2 öncesi sürümler yerel bir
361
+ derlemeden token ile yüklendi ve hiçbir kanıt taşımıyor.
342
362
 
343
363
  `python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
344
364
  `Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
@@ -0,0 +1,11 @@
1
+ import type { Store } from "./types.js";
2
+ export interface StoreContractHandle {
3
+ store: Store;
4
+ teardown?: () => void | Promise<void>;
5
+ }
6
+ export type StoreContractFactory = (clock?: () => number) => Store | StoreContractHandle | Promise<Store | StoreContractHandle>;
7
+ export interface StoreContractOptions {
8
+ expiry?: "injected" | "skip";
9
+ skip?: string;
10
+ }
11
+ export declare function storeContract(name: string, factory: StoreContractFactory, options?: StoreContractOptions): void;
@@ -0,0 +1,118 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { MemoryStore } from "./stores.js";
4
+ function asHandle(value) {
5
+ return "store" in value ? value : { store: value };
6
+ }
7
+ export function storeContract(name, factory, options = {}) {
8
+ const expiry = options.expiry ?? "injected";
9
+ const skip = options.skip;
10
+ const testOptions = skip ? { skip } : {};
11
+ const expiryOptions = expiry === "skip" ? { skip: "expiry cases skipped: this store does not accept an injected clock" } : testOptions;
12
+ const withStore = async (clock, run) => {
13
+ const handle = asHandle(await factory(clock));
14
+ try {
15
+ return await run(handle.store);
16
+ }
17
+ finally {
18
+ await handle.teardown?.();
19
+ }
20
+ };
21
+ test(`${name}: get, set and del`, testOptions, async () => {
22
+ await withStore(undefined, async (store) => {
23
+ assert.equal(await store.get("missing"), null);
24
+ await store.set("key", "value");
25
+ assert.equal(await store.get("key"), "value");
26
+ await store.del("key");
27
+ assert.equal(await store.get("key"), null);
28
+ });
29
+ });
30
+ test(`${name}: incr from absent starts at one`, testOptions, async () => {
31
+ await withStore(undefined, async (store) => {
32
+ assert.equal(await store.incr("counter"), 1);
33
+ assert.equal(await store.incr("counter"), 2);
34
+ });
35
+ });
36
+ test(`${name}: incr is atomic`, testOptions, async () => {
37
+ await withStore(undefined, async (store) => {
38
+ const results = await Promise.all(Array.from({ length: 100 }, () => store.incr("counter")));
39
+ assert.deepEqual([...results].sort((a, b) => a - b), Array.from({ length: 100 }, (_, i) => i + 1));
40
+ assert.equal(await store.get("counter"), "100");
41
+ });
42
+ });
43
+ test(`${name}: set and incr TTLs expire at the same boundary`, expiryOptions, async () => {
44
+ let now = 0;
45
+ const clock = () => now;
46
+ await withStore(clock, async (store) => {
47
+ await store.set("set", "value", 2);
48
+ await store.incr("incr", 2);
49
+ now = 1999;
50
+ assert.equal(await store.get("set"), "value");
51
+ assert.equal(await store.get("incr"), "1");
52
+ now = 2000;
53
+ assert.equal(await store.get("set"), null);
54
+ assert.equal(await store.get("incr"), null);
55
+ });
56
+ });
57
+ test(`${name}: expiry is inclusive at the boundary`, expiryOptions, async () => {
58
+ let now = 1000;
59
+ const clock = () => now;
60
+ await withStore(clock, async (store) => {
61
+ await store.set("key", "value", 1);
62
+ assert.equal(await store.get("key"), "value");
63
+ now = 1999;
64
+ assert.equal(await store.get("key"), "value");
65
+ now = 2000;
66
+ assert.equal(await store.get("key"), null);
67
+ });
68
+ });
69
+ test(`${name}: random operations match MemoryStore`, testOptions, async () => {
70
+ for (let seed = 1; seed <= 40; seed++) {
71
+ let now = 0;
72
+ const clock = () => now;
73
+ await withStore(expiry === "injected" ? clock : undefined, async (store) => {
74
+ const reference = new MemoryStore(clock);
75
+ const keys = ["a", "b", "c"];
76
+ let state = 0x6d2b79f5 ^ seed;
77
+ const random = () => {
78
+ state = (Math.imul(state ^ (state >>> 16), 2246822507) + 3266489909) >>> 0;
79
+ return state / 4294967296;
80
+ };
81
+ try {
82
+ for (let step = 0; step < 40; step++) {
83
+ const key = keys[Math.floor(random() * keys.length)];
84
+ const ttl = expiry === "injected" && random() > 0.5 ? 1 + Math.floor(random() * 3) : undefined;
85
+ const op = Math.floor(random() * 5);
86
+ if (op === 0) {
87
+ const value = String(Math.floor(random() * 100));
88
+ await reference.set(key, value, ttl);
89
+ await store.set(key, value, ttl);
90
+ }
91
+ else if (op === 1) {
92
+ assert.equal(await store.incr(key, ttl), await reference.incr(key, ttl), `seed ${seed} step ${step}: incr disagreed`);
93
+ }
94
+ else if (op === 2) {
95
+ await reference.del(key);
96
+ await store.del(key);
97
+ }
98
+ else if (op === 3) {
99
+ if (expiry === "injected")
100
+ now += Math.floor(random() * 3000);
101
+ }
102
+ else {
103
+ assert.equal(await store.get(key), await reference.get(key), `seed ${seed} step ${step}: get disagreed on ${key}`);
104
+ }
105
+ }
106
+ for (const key of keys) {
107
+ assert.equal(await store.get(key), await reference.get(key), `seed ${seed}: final state disagreed on ${key}`);
108
+ }
109
+ }
110
+ finally {
111
+ await reference.del("a");
112
+ await reference.del("b");
113
+ await reference.del("c");
114
+ }
115
+ });
116
+ }
117
+ });
118
+ }
@@ -40,10 +40,17 @@ export declare class SqliteStore implements Store {
40
40
  private readonly database;
41
41
  private readonly clock;
42
42
  constructor(path: string, clock?: () => number);
43
+ /**
44
+ * A read prunes only the key it touches; a write first clears every row that
45
+ * has already expired, so a key nobody reads again does not live forever.
46
+ */
47
+ private sweep;
43
48
  private live;
44
49
  get(key: string): Promise<string | null>;
45
50
  set(key: string, value: string, ttlSeconds?: number): Promise<void>;
46
51
  incr(key: string, ttlSeconds?: number): Promise<number>;
47
52
  del(key: string): Promise<void>;
53
+ /** Test helper. */
54
+ size(): number;
48
55
  close(): void;
49
56
  }
@@ -78,6 +78,15 @@ export class SqliteStore {
78
78
  this.database = new DatabaseSync(path);
79
79
  this.clock = clock;
80
80
  this.database.exec("CREATE TABLE IF NOT EXISTS proactive_gate_store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, expires_at INTEGER)");
81
+ // The partial index keeps a sweep proportional to the dead rows instead of a table scan.
82
+ this.database.exec("CREATE INDEX IF NOT EXISTS proactive_gate_store_expires_at ON proactive_gate_store (expires_at) WHERE expires_at IS NOT NULL");
83
+ }
84
+ /**
85
+ * A read prunes only the key it touches; a write first clears every row that
86
+ * has already expired, so a key nobody reads again does not live forever.
87
+ */
88
+ sweep(now) {
89
+ this.database.prepare("DELETE FROM proactive_gate_store WHERE expires_at IS NOT NULL AND expires_at <= ?").run(now);
81
90
  }
82
91
  live(key) {
83
92
  const row = this.database.prepare("SELECT value, expires_at FROM proactive_gate_store WHERE key = ?").get(key);
@@ -93,7 +102,9 @@ export class SqliteStore {
93
102
  return this.live(key)?.value ?? null;
94
103
  }
95
104
  async set(key, value, ttlSeconds) {
96
- const expiresAt = ttlSeconds ? this.clock() + ttlSeconds * 1000 : null;
105
+ const now = this.clock();
106
+ const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null;
107
+ this.sweep(now);
97
108
  this.database
98
109
  .prepare("INSERT INTO proactive_gate_store (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at")
99
110
  .run(key, value, expiresAt);
@@ -101,6 +112,7 @@ export class SqliteStore {
101
112
  async incr(key, ttlSeconds) {
102
113
  const now = this.clock();
103
114
  const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null;
115
+ this.sweep(now);
104
116
  const row = this.database
105
117
  .prepare("INSERT INTO proactive_gate_store (key, value, expires_at) VALUES (?, '1', ?) ON CONFLICT(key) DO UPDATE SET value = CASE WHEN proactive_gate_store.expires_at IS NOT NULL AND proactive_gate_store.expires_at <= ? THEN '1' ELSE CAST(CAST(proactive_gate_store.value AS INTEGER) + 1 AS TEXT) END, expires_at = CASE WHEN proactive_gate_store.expires_at IS NOT NULL AND proactive_gate_store.expires_at <= ? THEN excluded.expires_at ELSE proactive_gate_store.expires_at END RETURNING value")
106
118
  .get(key, expiresAt, now, now);
@@ -109,6 +121,11 @@ export class SqliteStore {
109
121
  async del(key) {
110
122
  this.database.prepare("DELETE FROM proactive_gate_store WHERE key = ?").run(key);
111
123
  }
124
+ /** Test helper. */
125
+ size() {
126
+ const row = this.database.prepare("SELECT COUNT(*) AS n FROM proactive_gate_store").get();
127
+ return row.n;
128
+ }
112
129
  close() {
113
130
  this.database.close();
114
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks as code or JSON, a conformance spec, presets for platform and legal limits, adapters for AI SDK, Mastra, LangChain and OpenAI Agents, and a Python sibling.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -14,6 +14,10 @@
14
14
  "types": "./dist/src/presets.d.ts",
15
15
  "import": "./dist/src/presets.js"
16
16
  },
17
+ "./store-contract": {
18
+ "types": "./dist/src/store-contract.d.ts",
19
+ "import": "./dist/src/store-contract.js"
20
+ },
17
21
  "./ai-sdk": {
18
22
  "types": "./dist/src/adapters/ai-sdk.d.ts",
19
23
  "import": "./dist/src/adapters/ai-sdk.js"
@@ -44,7 +48,7 @@
44
48
  "sideEffects": false,
45
49
  "scripts": {
46
50
  "build": "tsc -p tsconfig.json",
47
- "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js test/release.test.mjs test/examples.test.mjs test/naive.test.mjs test/suite.test.mjs",
51
+ "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js test/release.test.mjs test/examples.test.mjs test/example-imports.test.mjs test/naive.test.mjs test/suite.test.mjs",
48
52
  "lint": "tsc -p tsconfig.json --noEmit",
49
53
  "spec-lint": "node test/spec-lint.mjs",
50
54
  "conformance": "npm run build && node dist/src/cli.js replay --fixtures spec/fixtures",
@@ -55,7 +59,8 @@
55
59
  "release-gate": "npm run build && node scripts/release-gate.mjs",
56
60
  "trace-svg": "npm run build && node scripts/trace-svg.mjs",
57
61
  "bench:compare": "npm run build && node bench/compare.mjs",
58
- "conformance-table": "node scripts/conformance-table.mjs"
62
+ "conformance-table": "node scripts/conformance-table.mjs",
63
+ "og": "npm run build && node scripts/og-image.mjs"
59
64
  },
60
65
  "engines": {
61
66
  "node": ">=20"
@@ -65,9 +70,9 @@
65
70
  "agents",
66
71
  "proactive",
67
72
  "notifications",
68
- "rate-limit",
69
73
  "quiet-hours",
70
74
  "consent",
75
+ "rate-limit",
71
76
  "budget",
72
77
  "llm",
73
78
  "policy",