proactive-gate 0.2.5 → 0.3.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/README.md +19 -1
- package/dist/src/store-contract.d.ts +11 -0
- package/dist/src/store-contract.js +118 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -436,6 +436,23 @@ adding a package dependency. It was contributed by
|
|
|
436
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
|
|
437
437
|
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.
|
|
438
438
|
|
|
439
|
+
**Writing your own store?** `proactive-gate/store-contract` exports the same suite these three are
|
|
440
|
+
held to, so you can prove yours behaves rather than hope:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import { storeContract } from "proactive-gate/store-contract";
|
|
444
|
+
storeContract("PostgresStore", (clock) => new PostgresStore({ clock }));
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
It checks `get`, `set` and `del`, `incr` from an absent key, concurrent `incr` atomicity, the
|
|
448
|
+
expiry boundary, and that a TTL given to `set` and one given to `incr` agree, then replays a
|
|
449
|
+
seeded random operation sequence against `MemoryStore`. A store whose backend owns the clock
|
|
450
|
+
passes `expiry: "skip"` and those cases are reported as skipped rather than quietly dropped. It
|
|
451
|
+
was contributed by [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) in
|
|
452
|
+
[#24](https://github.com/Bubblegunn/proactive-gate/pull/24), and lives on its own subpath so
|
|
453
|
+
importing the package never pulls `node:test` into your bundle. See
|
|
454
|
+
[docs/store-contract.md](docs/store-contract.md).
|
|
455
|
+
|
|
439
456
|
## Fail open, on purpose
|
|
440
457
|
|
|
441
458
|
When a store-backed check throws (Redis is down), the default lets the candidate
|
|
@@ -724,7 +741,8 @@ before. [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) wrote `
|
|
|
724
741
|
([#3](https://github.com/Bubblegunn/proactive-gate/pull/3)) and
|
|
725
742
|
[@edwardsong08](https://github.com/edwardsong08) wrote the weekly budget
|
|
726
743
|
([#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.
|
|
744
|
+
every release since, including the one you install today. @aaqib-hafeez-khan-in came back for a
|
|
745
|
+
second one and wrote the store contract suite in [#24](https://github.com/Bubblegunn/proactive-gate/pull/24).
|
|
728
746
|
|
|
729
747
|
## Cite this
|
|
730
748
|
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proactive-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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"
|
|
@@ -65,9 +69,9 @@
|
|
|
65
69
|
"agents",
|
|
66
70
|
"proactive",
|
|
67
71
|
"notifications",
|
|
68
|
-
"rate-limit",
|
|
69
72
|
"quiet-hours",
|
|
70
73
|
"consent",
|
|
74
|
+
"rate-limit",
|
|
71
75
|
"budget",
|
|
72
76
|
"llm",
|
|
73
77
|
"policy",
|