@vllnt/convex-idempotency 0.1.0-canary.6abc175
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/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/client/index.d.ts +135 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +117 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +78 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +3 -0
- package/dist/client/types.js.map +1 -0
- package/dist/component/_generated/api.d.ts +40 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +65 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +7 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/crons.d.ts +16 -0
- package/dist/component/crons.d.ts.map +1 -0
- package/dist/component/crons.js +19 -0
- package/dist/component/crons.js.map +1 -0
- package/dist/component/mutations.d.ts +91 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +177 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +9 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +22 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +26 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +21 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/validators.d.ts +80 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +42 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/shared.d.ts +17 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +17 -0
- package/dist/shared.js.map +1 -0
- package/package.json +96 -0
- package/src/client/index.ts +251 -0
- package/src/client/types.ts +87 -0
- package/src/component/_generated/api.ts +56 -0
- package/src/component/_generated/component.ts +67 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/crons.ts +23 -0
- package/src/component/mutations.ts +195 -0
- package/src/component/queries.ts +24 -0
- package/src/component/schema.ts +21 -0
- package/src/component/validators.ts +56 -0
- package/src/shared.ts +21 -0
- package/src/test.ts +12 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { query } from "./_generated/server";
|
|
3
|
+
import { keyState } from "./validators";
|
|
4
|
+
export const get = query({
|
|
5
|
+
args: { key: v.string(), scope: v.string() },
|
|
6
|
+
returns: v.union(v.null(), keyState),
|
|
7
|
+
handler: async (ctx, args) => {
|
|
8
|
+
const row = await ctx.db
|
|
9
|
+
.query("keys")
|
|
10
|
+
.withIndex("by_scope_key", (q) => q.eq("scope", args.scope).eq("key", args.key))
|
|
11
|
+
.unique();
|
|
12
|
+
if (row === null) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
status: row.status,
|
|
17
|
+
result: row.result,
|
|
18
|
+
expiresAt: row.expiresAt,
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
//# sourceMappingURL=queries.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queries.js","sourceRoot":"","sources":["../../src/component/queries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAClC,OAAO,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC5C,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAExC,MAAM,CAAC,MAAM,GAAG,GAAG,KAAK,CAAC;IACvB,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE;IAC5C,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,QAAQ,CAAC;IACpC,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC3B,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,EAAE;aACrB,KAAK,CAAC,MAAM,CAAC;aACb,SAAS,CAAC,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE,CAC/B,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAC9C;aACA,MAAM,EAAE,CAAC;QACZ,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;YACjB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,OAAO;YACL,MAAM,EAAE,GAAG,CAAC,MAAM;YAClB,MAAM,EAAE,GAAG,CAAC,MAAM;YAClB,SAAS,EAAE,GAAG,CAAC,SAAS;SACzB,CAAC;IACJ,CAAC;CACF,CAAC,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed tables — the idempotency ledger's own concern. A `key` is unique
|
|
3
|
+
* within a `scope`; `result` carries the opaque host-owned outcome (never
|
|
4
|
+
* inspected). `expiresAt` is an absolute ms timestamp that bounds the grace
|
|
5
|
+
* window after which a key may be re-minted.
|
|
6
|
+
*/
|
|
7
|
+
declare const _default: import("convex/server").SchemaDefinition<{
|
|
8
|
+
keys: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
9
|
+
result?: any;
|
|
10
|
+
key: string;
|
|
11
|
+
scope: string;
|
|
12
|
+
status: "inflight" | "done";
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
}, {
|
|
15
|
+
key: import("convex/values").VString<string, "required">;
|
|
16
|
+
scope: import("convex/values").VString<string, "required">;
|
|
17
|
+
status: import("convex/values").VUnion<"inflight" | "done", [import("convex/values").VLiteral<"inflight", "required">, import("convex/values").VLiteral<"done", "required">], "required", never>;
|
|
18
|
+
result: import("convex/values").VAny<any, "optional", string>;
|
|
19
|
+
expiresAt: import("convex/values").VFloat64<number, "required">;
|
|
20
|
+
}, "required", "key" | "scope" | "result" | "status" | "expiresAt" | `result.${string}`>, {
|
|
21
|
+
by_scope_key: ["scope", "key", "_creationTime"];
|
|
22
|
+
by_expires: ["expiresAt", "_creationTime"];
|
|
23
|
+
}, {}, {}>;
|
|
24
|
+
}, true>;
|
|
25
|
+
export default _default;
|
|
26
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;;;;;;;;;;;;;;;;;;;AACH,wBAUG"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { jsonValue } from "./validators";
|
|
4
|
+
/**
|
|
5
|
+
* Sandboxed tables — the idempotency ledger's own concern. A `key` is unique
|
|
6
|
+
* within a `scope`; `result` carries the opaque host-owned outcome (never
|
|
7
|
+
* inspected). `expiresAt` is an absolute ms timestamp that bounds the grace
|
|
8
|
+
* window after which a key may be re-minted.
|
|
9
|
+
*/
|
|
10
|
+
export default defineSchema({
|
|
11
|
+
keys: defineTable({
|
|
12
|
+
key: v.string(),
|
|
13
|
+
scope: v.string(),
|
|
14
|
+
status: v.union(v.literal("inflight"), v.literal("done")),
|
|
15
|
+
result: v.optional(jsonValue),
|
|
16
|
+
expiresAt: v.number(),
|
|
17
|
+
})
|
|
18
|
+
.index("by_scope_key", ["scope", "key"])
|
|
19
|
+
.index("by_expires", ["expiresAt"]),
|
|
20
|
+
});
|
|
21
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAClC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzC;;;;;GAKG;AACH,eAAe,YAAY,CAAC;IAC1B,IAAI,EAAE,WAAW,CAAC;QAChB,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE;QACf,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;QACjB,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACzD,MAAM,EAAE,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAC7B,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;KACtB,CAAC;SACC,KAAK,CAAC,cAAc,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;SACvC,KAAK,CAAC,YAAY,EAAE,CAAC,WAAW,CAAC,CAAC;CACtC,CAAC,CAAC"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque host-owned outcome stored against a completed key. The component never
|
|
3
|
+
* inspects it — last-resort arbitrary data, aliased here rather than left bare
|
|
4
|
+
* in function signatures. The host narrows it at the {@link Idempotency} client
|
|
5
|
+
* boundary via an optional `resultValidator`.
|
|
6
|
+
*
|
|
7
|
+
* This is the single documented `v.any()` escape hatch in the component; the
|
|
8
|
+
* lint rule `convex-rules/no-bare-v-any` is satisfied by routing every arbitrary
|
|
9
|
+
* payload through this alias instead of a bare `v.any()`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const jsonValue: import("convex/values").VAny<any, "required", string>;
|
|
12
|
+
/**
|
|
13
|
+
* Result of {@link begin}. `fresh` — the key was just minted, the caller should
|
|
14
|
+
* do the work and then `complete`. `inflight` — another attempt holds the key;
|
|
15
|
+
* `expiresAt` is when the lease frees and `retryAfterMs` how long to back off
|
|
16
|
+
* before retrying. `done` — a prior attempt already completed; `result` carries
|
|
17
|
+
* the recorded outcome for a short-circuit replay.
|
|
18
|
+
*/
|
|
19
|
+
export declare const beginResult: import("convex/values").VUnion<{
|
|
20
|
+
state: "fresh";
|
|
21
|
+
} | {
|
|
22
|
+
state: "inflight";
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
retryAfterMs: number;
|
|
25
|
+
} | {
|
|
26
|
+
result?: any;
|
|
27
|
+
state: "done";
|
|
28
|
+
}, [import("convex/values").VObject<{
|
|
29
|
+
state: "fresh";
|
|
30
|
+
}, {
|
|
31
|
+
state: import("convex/values").VLiteral<"fresh", "required">;
|
|
32
|
+
}, "required", "state">, import("convex/values").VObject<{
|
|
33
|
+
state: "inflight";
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
retryAfterMs: number;
|
|
36
|
+
}, {
|
|
37
|
+
state: import("convex/values").VLiteral<"inflight", "required">;
|
|
38
|
+
expiresAt: import("convex/values").VFloat64<number, "required">;
|
|
39
|
+
retryAfterMs: import("convex/values").VFloat64<number, "required">;
|
|
40
|
+
}, "required", "state" | "expiresAt" | "retryAfterMs">, import("convex/values").VObject<{
|
|
41
|
+
result?: any;
|
|
42
|
+
state: "done";
|
|
43
|
+
}, {
|
|
44
|
+
state: import("convex/values").VLiteral<"done", "required">;
|
|
45
|
+
result: import("convex/values").VAny<any, "optional", string>;
|
|
46
|
+
}, "required", "result" | "state" | `result.${string}`>], "required", "result" | "state" | "expiresAt" | "retryAfterMs" | `result.${string}`>;
|
|
47
|
+
/**
|
|
48
|
+
* Result of {@link complete}. `recorded: true` — the outcome was written.
|
|
49
|
+
* `recorded: false` — the claim was lost; `reason` distinguishes a never-claimed
|
|
50
|
+
* key (`missing`), a lease that expired before completion (`expired`), and a key
|
|
51
|
+
* already marked done by a prior attempt (`already_done`). A host that sees
|
|
52
|
+
* `recorded: false` knows its work finished but the ledger row was gone.
|
|
53
|
+
*/
|
|
54
|
+
export declare const completeResult: import("convex/values").VUnion<{
|
|
55
|
+
recorded: true;
|
|
56
|
+
} | {
|
|
57
|
+
recorded: false;
|
|
58
|
+
reason: "missing" | "expired" | "already_done";
|
|
59
|
+
}, [import("convex/values").VObject<{
|
|
60
|
+
recorded: true;
|
|
61
|
+
}, {
|
|
62
|
+
recorded: import("convex/values").VLiteral<true, "required">;
|
|
63
|
+
}, "required", "recorded">, import("convex/values").VObject<{
|
|
64
|
+
recorded: false;
|
|
65
|
+
reason: "missing" | "expired" | "already_done";
|
|
66
|
+
}, {
|
|
67
|
+
recorded: import("convex/values").VLiteral<false, "required">;
|
|
68
|
+
reason: import("convex/values").VUnion<"missing" | "expired" | "already_done", [import("convex/values").VLiteral<"missing", "required">, import("convex/values").VLiteral<"expired", "required">, import("convex/values").VLiteral<"already_done", "required">], "required", never>;
|
|
69
|
+
}, "required", "recorded" | "reason">], "required", "recorded" | "reason">;
|
|
70
|
+
/** Stored projection of a key returned by {@link get}. */
|
|
71
|
+
export declare const keyState: import("convex/values").VObject<{
|
|
72
|
+
result?: any;
|
|
73
|
+
status: "inflight" | "done";
|
|
74
|
+
expiresAt: number;
|
|
75
|
+
}, {
|
|
76
|
+
status: import("convex/values").VUnion<"inflight" | "done", [import("convex/values").VLiteral<"inflight", "required">, import("convex/values").VLiteral<"done", "required">], "required", never>;
|
|
77
|
+
result: import("convex/values").VAny<any, "optional", string>;
|
|
78
|
+
expiresAt: import("convex/values").VFloat64<number, "required">;
|
|
79
|
+
}, "required", "result" | "status" | "expiresAt" | `result.${string}`>;
|
|
80
|
+
//# sourceMappingURL=validators.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../src/component/validators.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,eAAO,MAAM,SAAS,uDAAU,CAAC;AAEjC;;;;;;GAMG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;6IAQvB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;0EAU1B,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,QAAQ;;;;;;;;sEAInB,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
/**
|
|
3
|
+
* Opaque host-owned outcome stored against a completed key. The component never
|
|
4
|
+
* inspects it — last-resort arbitrary data, aliased here rather than left bare
|
|
5
|
+
* in function signatures. The host narrows it at the {@link Idempotency} client
|
|
6
|
+
* boundary via an optional `resultValidator`.
|
|
7
|
+
*
|
|
8
|
+
* This is the single documented `v.any()` escape hatch in the component; the
|
|
9
|
+
* lint rule `convex-rules/no-bare-v-any` is satisfied by routing every arbitrary
|
|
10
|
+
* payload through this alias instead of a bare `v.any()`.
|
|
11
|
+
*/
|
|
12
|
+
export const jsonValue = v.any();
|
|
13
|
+
/**
|
|
14
|
+
* Result of {@link begin}. `fresh` — the key was just minted, the caller should
|
|
15
|
+
* do the work and then `complete`. `inflight` — another attempt holds the key;
|
|
16
|
+
* `expiresAt` is when the lease frees and `retryAfterMs` how long to back off
|
|
17
|
+
* before retrying. `done` — a prior attempt already completed; `result` carries
|
|
18
|
+
* the recorded outcome for a short-circuit replay.
|
|
19
|
+
*/
|
|
20
|
+
export const beginResult = v.union(v.object({ state: v.literal("fresh") }), v.object({
|
|
21
|
+
state: v.literal("inflight"),
|
|
22
|
+
expiresAt: v.number(),
|
|
23
|
+
retryAfterMs: v.number(),
|
|
24
|
+
}), v.object({ state: v.literal("done"), result: v.optional(jsonValue) }));
|
|
25
|
+
/**
|
|
26
|
+
* Result of {@link complete}. `recorded: true` — the outcome was written.
|
|
27
|
+
* `recorded: false` — the claim was lost; `reason` distinguishes a never-claimed
|
|
28
|
+
* key (`missing`), a lease that expired before completion (`expired`), and a key
|
|
29
|
+
* already marked done by a prior attempt (`already_done`). A host that sees
|
|
30
|
+
* `recorded: false` knows its work finished but the ledger row was gone.
|
|
31
|
+
*/
|
|
32
|
+
export const completeResult = v.union(v.object({ recorded: v.literal(true) }), v.object({
|
|
33
|
+
recorded: v.literal(false),
|
|
34
|
+
reason: v.union(v.literal("missing"), v.literal("expired"), v.literal("already_done")),
|
|
35
|
+
}));
|
|
36
|
+
/** Stored projection of a key returned by {@link get}. */
|
|
37
|
+
export const keyState = v.object({
|
|
38
|
+
status: v.union(v.literal("inflight"), v.literal("done")),
|
|
39
|
+
result: v.optional(jsonValue),
|
|
40
|
+
expiresAt: v.number(),
|
|
41
|
+
});
|
|
42
|
+
//# sourceMappingURL=validators.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validators.js","sourceRoot":"","sources":["../../src/component/validators.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAElC;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;AAEjC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAChC,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EACvC,CAAC,CAAC,MAAM,CAAC;IACP,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC;IAC5B,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;IACrB,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE;CACzB,CAAC,EACF,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,CACtE,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CACnC,CAAC,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,EACvC,CAAC,CAAC,MAAM,CAAC;IACP,QAAQ,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC;IAC1B,MAAM,EAAE,CAAC,CAAC,KAAK,CACb,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,EACpB,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,EACpB,CAAC,CAAC,OAAO,CAAC,cAAc,CAAC,CAC1B;CACF,CAAC,CACH,CAAC;AAEF,0DAA0D;AAC1D,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/B,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACzD,MAAM,EAAE,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;IAC7B,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAAC,CAAC"}
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Shared constants used by both `client/` and `component/`. */
|
|
2
|
+
export declare const COMPONENT_NAME = "idempotency";
|
|
3
|
+
/** Default namespace when the host does not scope a key. */
|
|
4
|
+
export declare const DEFAULT_SCOPE = "global";
|
|
5
|
+
/**
|
|
6
|
+
* Default inflight lease: 60 seconds, in milliseconds. Short by design so a
|
|
7
|
+
* crashed worker's claim self-heals quickly and the key can be re-minted.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_INFLIGHT_TTL_MS = 60000;
|
|
10
|
+
/**
|
|
11
|
+
* Default done grace window: 24 hours, in milliseconds. How long a recorded
|
|
12
|
+
* outcome replays before the key may be re-minted.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_DONE_TTL_MS = 86400000;
|
|
15
|
+
/** Default page size for a `purge` pass before the sweep self-reschedules. */
|
|
16
|
+
export declare const DEFAULT_PURGE_BATCH = 200;
|
|
17
|
+
//# sourceMappingURL=shared.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAEhE,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAE5C,4DAA4D;AAC5D,eAAO,MAAM,aAAa,WAAW,CAAC;AAEtC;;;GAGG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,mBAAmB,WAAa,CAAC;AAE9C,8EAA8E;AAC9E,eAAO,MAAM,mBAAmB,MAAM,CAAC"}
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Shared constants used by both `client/` and `component/`. */
|
|
2
|
+
export const COMPONENT_NAME = "idempotency";
|
|
3
|
+
/** Default namespace when the host does not scope a key. */
|
|
4
|
+
export const DEFAULT_SCOPE = "global";
|
|
5
|
+
/**
|
|
6
|
+
* Default inflight lease: 60 seconds, in milliseconds. Short by design so a
|
|
7
|
+
* crashed worker's claim self-heals quickly and the key can be re-minted.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_INFLIGHT_TTL_MS = 60_000;
|
|
10
|
+
/**
|
|
11
|
+
* Default done grace window: 24 hours, in milliseconds. How long a recorded
|
|
12
|
+
* outcome replays before the key may be re-minted.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_DONE_TTL_MS = 86_400_000;
|
|
15
|
+
/** Default page size for a `purge` pass before the sweep self-reschedules. */
|
|
16
|
+
export const DEFAULT_PURGE_BATCH = 200;
|
|
17
|
+
//# sourceMappingURL=shared.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.js","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAEhE,MAAM,CAAC,MAAM,cAAc,GAAG,aAAa,CAAC;AAE5C,4DAA4D;AAC5D,MAAM,CAAC,MAAM,aAAa,GAAG,QAAQ,CAAC;AAEtC;;;GAGG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC;AAE9C;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,UAAU,CAAC;AAE9C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vllnt/convex-idempotency",
|
|
3
|
+
"version": "0.1.0-canary.6abc175",
|
|
4
|
+
"description": "Exactly-once idempotency key ledger for retried operations, as a Convex component",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"packageManager": "pnpm@9.15.4",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/client/index.d.ts",
|
|
16
|
+
"default": "./dist/client/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./test": "./src/test.ts",
|
|
19
|
+
"./_generated/component.js": {
|
|
20
|
+
"types": "./dist/component/_generated/component.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./_generated/component": {
|
|
23
|
+
"types": "./dist/component/_generated/component.d.ts"
|
|
24
|
+
},
|
|
25
|
+
"./convex.config": {
|
|
26
|
+
"types": "./dist/component/convex.config.d.ts",
|
|
27
|
+
"default": "./dist/component/convex.config.js"
|
|
28
|
+
},
|
|
29
|
+
"./convex.config.js": {
|
|
30
|
+
"types": "./dist/component/convex.config.d.ts",
|
|
31
|
+
"default": "./dist/component/convex.config.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"types": "./dist/client/index.d.ts",
|
|
35
|
+
"module": "./dist/client/index.js",
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc --project ./tsconfig.build.json",
|
|
38
|
+
"build:codegen": "pnpm convex codegen --component-dir ./src/component && pnpm build",
|
|
39
|
+
"build:clean": "rm -rf dist *.tsbuildinfo && pnpm build:codegen",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"typecheck:ci": "tsc --noEmit --project tsconfig.ci.json",
|
|
42
|
+
"lint": "eslint .",
|
|
43
|
+
"test": "vitest run --passWithNoTests",
|
|
44
|
+
"test:watch": "vitest --typecheck --clearScreen false",
|
|
45
|
+
"test:coverage": "vitest run --coverage --coverage.reporter=text",
|
|
46
|
+
"generate:llms": "node scripts/generate-llms.mjs",
|
|
47
|
+
"preversion": "pnpm install --frozen-lockfile && pnpm build && pnpm test:coverage && pnpm typecheck && pnpm generate:llms",
|
|
48
|
+
"prepublishOnly": "npm whoami || npm login",
|
|
49
|
+
"alpha": "npm version prerelease --preid alpha && npm publish --tag alpha && git push --follow-tags",
|
|
50
|
+
"release": "npm version patch && npm publish && git push --follow-tags"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"convex": "^1.41.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@edge-runtime/vm": "^5.0.0",
|
|
58
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
59
|
+
"@vllnt/eslint-config": "^1.0.0",
|
|
60
|
+
"@vllnt/typescript": "^1.0.0",
|
|
61
|
+
"convex": "^1.41.0",
|
|
62
|
+
"convex-test": "^0.0.53",
|
|
63
|
+
"eslint": "^9.0.0",
|
|
64
|
+
"prettier": "^3.4.0",
|
|
65
|
+
"typescript": "^5.7.0",
|
|
66
|
+
"vitest": "^4.1.8"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/vllnt/convex-idempotency#readme",
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/vllnt/convex-idempotency/issues"
|
|
71
|
+
},
|
|
72
|
+
"author": {
|
|
73
|
+
"name": "bntvllnt",
|
|
74
|
+
"url": "https://bntvllnt.com"
|
|
75
|
+
},
|
|
76
|
+
"funding": {
|
|
77
|
+
"type": "github",
|
|
78
|
+
"url": "https://github.com/sponsors/bntvllnt"
|
|
79
|
+
},
|
|
80
|
+
"repository": {
|
|
81
|
+
"type": "git",
|
|
82
|
+
"url": "https://github.com/vllnt/convex-idempotency"
|
|
83
|
+
},
|
|
84
|
+
"engines": {
|
|
85
|
+
"node": ">=18"
|
|
86
|
+
},
|
|
87
|
+
"publishConfig": {
|
|
88
|
+
"access": "public"
|
|
89
|
+
},
|
|
90
|
+
"sideEffects": false,
|
|
91
|
+
"keywords": [
|
|
92
|
+
"convex",
|
|
93
|
+
"convex-component",
|
|
94
|
+
"idempotency"
|
|
95
|
+
]
|
|
96
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FunctionArgs,
|
|
3
|
+
FunctionReference,
|
|
4
|
+
FunctionReturnType,
|
|
5
|
+
} from "convex/server";
|
|
6
|
+
import type {
|
|
7
|
+
BeginResult,
|
|
8
|
+
CompleteResult,
|
|
9
|
+
IdempotencyOptions,
|
|
10
|
+
KeyState,
|
|
11
|
+
ResultParser,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_DONE_TTL_MS,
|
|
15
|
+
DEFAULT_INFLIGHT_TTL_MS,
|
|
16
|
+
DEFAULT_PURGE_BATCH,
|
|
17
|
+
DEFAULT_SCOPE,
|
|
18
|
+
} from "../shared.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The component's raw `begin` return, before the client narrows `result`. The
|
|
22
|
+
* stored outcome is opaque here (`unknown`); the {@link Idempotency} client runs
|
|
23
|
+
* the host `resultValidator` over it at its typed boundary.
|
|
24
|
+
*/
|
|
25
|
+
type RawBegin =
|
|
26
|
+
| { state: "fresh" }
|
|
27
|
+
| { state: "inflight"; expiresAt: number; retryAfterMs: number }
|
|
28
|
+
| { state: "done"; result?: unknown };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The idempotency component's function references, as exposed on the host via
|
|
32
|
+
* `components.idempotency`. The host's stored outcome type is opaque here
|
|
33
|
+
* (`unknown`); the {@link Idempotency} client narrows it to `TResult` at its
|
|
34
|
+
* own typed boundary.
|
|
35
|
+
*/
|
|
36
|
+
export interface IdempotencyComponent {
|
|
37
|
+
mutations: {
|
|
38
|
+
begin: FunctionReference<
|
|
39
|
+
"mutation",
|
|
40
|
+
"internal",
|
|
41
|
+
{ key: string; scope: string; inflightTtlMs: number },
|
|
42
|
+
RawBegin
|
|
43
|
+
>;
|
|
44
|
+
complete: FunctionReference<
|
|
45
|
+
"mutation",
|
|
46
|
+
"internal",
|
|
47
|
+
{
|
|
48
|
+
key: string;
|
|
49
|
+
scope: string;
|
|
50
|
+
result?: unknown;
|
|
51
|
+
doneTtlMs: number;
|
|
52
|
+
upsertOnMissing: boolean;
|
|
53
|
+
},
|
|
54
|
+
CompleteResult
|
|
55
|
+
>;
|
|
56
|
+
purge: FunctionReference<
|
|
57
|
+
"mutation",
|
|
58
|
+
"internal",
|
|
59
|
+
{ before?: number; batch: number },
|
|
60
|
+
number
|
|
61
|
+
>;
|
|
62
|
+
};
|
|
63
|
+
queries: {
|
|
64
|
+
get: FunctionReference<
|
|
65
|
+
"query",
|
|
66
|
+
"internal",
|
|
67
|
+
{ key: string; scope: string },
|
|
68
|
+
{
|
|
69
|
+
status: "inflight" | "done";
|
|
70
|
+
result?: unknown;
|
|
71
|
+
expiresAt: number;
|
|
72
|
+
} | null
|
|
73
|
+
>;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface RunQueryCtx {
|
|
78
|
+
runQuery<Q extends FunctionReference<"query", "internal">>(
|
|
79
|
+
reference: Q,
|
|
80
|
+
args: FunctionArgs<Q>,
|
|
81
|
+
): Promise<FunctionReturnType<Q>>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface RunMutationCtx {
|
|
85
|
+
runMutation<M extends FunctionReference<"mutation", "internal">>(
|
|
86
|
+
reference: M,
|
|
87
|
+
args: FunctionArgs<M>,
|
|
88
|
+
): Promise<FunctionReturnType<M>>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Per-call overrides for `scope` and the inflight lease. */
|
|
92
|
+
interface BeginOptions {
|
|
93
|
+
scope?: string;
|
|
94
|
+
inflightTtlMs?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Per-call overrides for `scope`, the done grace window, and lost-claim upsert. */
|
|
98
|
+
interface CompleteOptions {
|
|
99
|
+
scope?: string;
|
|
100
|
+
doneTtlMs?: number;
|
|
101
|
+
upsertOnMissing?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Consumer-facing client for the exactly-once idempotency key ledger. The host
|
|
106
|
+
* owns meaning and auth; it passes an opaque `key` and an optional `scope`, and
|
|
107
|
+
* stores an arbitrary `TResult` outcome that replays return verbatim. Pass a
|
|
108
|
+
* `resultValidator` to narrow the opaque stored value to `TResult` at the
|
|
109
|
+
* boundary — there is no unchecked cast.
|
|
110
|
+
*
|
|
111
|
+
* @typeParam TResult - The host's stored outcome type (defaults to `unknown`).
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* const idem = new Idempotency(components.idempotency, {
|
|
116
|
+
* resultValidator: v.object({ chargeId: v.string() }).parse,
|
|
117
|
+
* });
|
|
118
|
+
* const r = await idem.begin(ctx, requestId);
|
|
119
|
+
* if (r.state === "done") return r.result; // typed replay
|
|
120
|
+
* if (r.state === "inflight") throw new Error(`retry in ${r.retryAfterMs}ms`);
|
|
121
|
+
* const out = await doWork(); // r.state === "fresh"
|
|
122
|
+
* const done = await idem.complete(ctx, requestId, out);
|
|
123
|
+
* if (!done.recorded) log.warn("claim lost", done.reason); // work ran, row gone
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export class Idempotency<TResult = unknown> {
|
|
127
|
+
private readonly defaultScope: string;
|
|
128
|
+
private readonly defaultInflightTtlMs: number;
|
|
129
|
+
private readonly defaultDoneTtlMs: number;
|
|
130
|
+
private readonly defaultUpsertOnMissing: boolean;
|
|
131
|
+
private readonly resultValidator: ResultParser<TResult> | undefined;
|
|
132
|
+
|
|
133
|
+
constructor(
|
|
134
|
+
private readonly component: IdempotencyComponent,
|
|
135
|
+
options: IdempotencyOptions<TResult> = {},
|
|
136
|
+
) {
|
|
137
|
+
this.defaultScope = options.defaultScope ?? DEFAULT_SCOPE;
|
|
138
|
+
this.defaultInflightTtlMs =
|
|
139
|
+
options.defaultInflightTtlMs ?? DEFAULT_INFLIGHT_TTL_MS;
|
|
140
|
+
this.defaultDoneTtlMs = options.defaultDoneTtlMs ?? DEFAULT_DONE_TTL_MS;
|
|
141
|
+
this.defaultUpsertOnMissing = options.upsertOnMissing ?? false;
|
|
142
|
+
this.resultValidator = options.resultValidator;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private scopeOf(scope: string | undefined): string {
|
|
146
|
+
return scope ?? this.defaultScope;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Narrow an opaque stored `result` to `TResult` via the host validator. Absent
|
|
151
|
+
* values pass through untouched; with no validator the value is returned as-is
|
|
152
|
+
* (the host accepted the unchecked-type tradeoff by omitting one).
|
|
153
|
+
*/
|
|
154
|
+
private parseResult(value: unknown): TResult | undefined {
|
|
155
|
+
if (value === undefined) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (this.resultValidator === undefined) {
|
|
159
|
+
return value as TResult;
|
|
160
|
+
}
|
|
161
|
+
return this.resultValidator(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Claim `key`. `fresh` mints an inflight key (do the work, then `complete`);
|
|
166
|
+
* `inflight` means a concurrent attempt holds it — back off `retryAfterMs`;
|
|
167
|
+
* `done` returns the validated recorded `result` for a short-circuit replay.
|
|
168
|
+
*/
|
|
169
|
+
async begin(
|
|
170
|
+
ctx: RunMutationCtx,
|
|
171
|
+
key: string,
|
|
172
|
+
opts: BeginOptions = {},
|
|
173
|
+
): Promise<BeginResult<TResult>> {
|
|
174
|
+
const r: RawBegin = await ctx.runMutation(this.component.mutations.begin, {
|
|
175
|
+
key,
|
|
176
|
+
scope: this.scopeOf(opts.scope),
|
|
177
|
+
inflightTtlMs: opts.inflightTtlMs ?? this.defaultInflightTtlMs,
|
|
178
|
+
});
|
|
179
|
+
if (r.state === "done") {
|
|
180
|
+
return { state: "done", result: this.parseResult(r.result) };
|
|
181
|
+
}
|
|
182
|
+
return r;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Mark `key` `done`, recording `result` and extending the done grace window.
|
|
187
|
+
* Returns a discriminated outcome: `recorded: true` on success, or
|
|
188
|
+
* `recorded: false` with a `reason` when the claim was lost (missing, expired,
|
|
189
|
+
* or already done) so the host can react to dropped work. `result` is validated
|
|
190
|
+
* against the host validator before it is stored.
|
|
191
|
+
*/
|
|
192
|
+
complete(
|
|
193
|
+
ctx: RunMutationCtx,
|
|
194
|
+
key: string,
|
|
195
|
+
result?: TResult,
|
|
196
|
+
opts: CompleteOptions = {},
|
|
197
|
+
): Promise<CompleteResult> {
|
|
198
|
+
const validated =
|
|
199
|
+
result === undefined ? undefined : this.parseResult(result);
|
|
200
|
+
return ctx.runMutation(this.component.mutations.complete, {
|
|
201
|
+
key,
|
|
202
|
+
scope: this.scopeOf(opts.scope),
|
|
203
|
+
result: validated,
|
|
204
|
+
doneTtlMs: opts.doneTtlMs ?? this.defaultDoneTtlMs,
|
|
205
|
+
upsertOnMissing: opts.upsertOnMissing ?? this.defaultUpsertOnMissing,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The stored state for `key`, or `null` if no key is held in the scope. */
|
|
210
|
+
async get(
|
|
211
|
+
ctx: RunQueryCtx,
|
|
212
|
+
key: string,
|
|
213
|
+
scope?: string,
|
|
214
|
+
): Promise<KeyState<TResult> | null> {
|
|
215
|
+
const row = await ctx.runQuery(this.component.queries.get, {
|
|
216
|
+
key,
|
|
217
|
+
scope: this.scopeOf(scope),
|
|
218
|
+
});
|
|
219
|
+
if (row === null) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
status: row.status,
|
|
224
|
+
result: this.parseResult(row.result),
|
|
225
|
+
expiresAt: row.expiresAt,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Delete expired keys in bounded batches, oldest first. `before` defaults to
|
|
231
|
+
* the server clock; `batch` caps each pass and the sweep self-reschedules until
|
|
232
|
+
* the tail is clean. Returns the count removed in the first pass.
|
|
233
|
+
*/
|
|
234
|
+
purge(
|
|
235
|
+
ctx: RunMutationCtx,
|
|
236
|
+
opts: { before?: number; batch?: number } = {},
|
|
237
|
+
): Promise<number> {
|
|
238
|
+
return ctx.runMutation(this.component.mutations.purge, {
|
|
239
|
+
before: opts.before,
|
|
240
|
+
batch: opts.batch ?? DEFAULT_PURGE_BATCH,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export type {
|
|
246
|
+
BeginResult,
|
|
247
|
+
CompleteResult,
|
|
248
|
+
IdempotencyOptions,
|
|
249
|
+
KeyState,
|
|
250
|
+
ResultParser,
|
|
251
|
+
};
|